
The Gap Between Angular and Its Tests
Angular applications are composed of typed components with declared @Input() properties, signal() state, and @Output() events. Yet most E2E tests interact with these apps through raw CSS selectors — completely ignoring the component model.
This leads to tests that know too much about the DOM and too little about the Angular contract. @playwright-labs/selectors-angular fills that gap.
Installation
npm install -D @playwright-labs/selectors-angular
Quick Setup
// playwright.config.ts
import { selectors, defineConfig } from "@playwright/test";
import { AngularEngine } from "@playwright-labs/selectors-angular";
selectors.register("angular", AngularEngine);
export default defineConfig({
use: { baseURL: "http://localhost:4200" },
});
Or use the all-in-one import that registers the engine automatically:
import { test, expect } from "@playwright-labs/selectors-angular";
The angular= Selector
Query components by their @Input() values instead of DOM attributes:
// By exact @Input value
page.locator('angular=app-button[label="Submit"]')
// By nested property
page.locator('angular=app-user-card[user.role="admin"]')
// By substring
page.locator('angular=app-button[type*="primary"]')
// Multiple conditions (AND)
page.locator('angular=app-button[type="danger"][disabled]')
// Regex
page.locator('angular=app-button[label=/^Sub/i]')
// Case-insensitive
page.locator('angular=app-button[label="submit" i]')
Supported operators mirror CSS attribute selectors:
| Syntax | Meaning |
[prop] | truthy |
[prop="val"] | exact match |
[prop*="val"] | contains |
[prop^="val"] | starts with |
[prop$="val"] | ends with |
[prop~="val"] | word in list |
[prop\|="val"] | exact or hyphen-prefix |
The $ng Fixture
Access component state directly from test code:
test("reads component properties", async ({ page, $ng }) => {
await page.goto("/");
// @Input value
const label = await $ng("app-button").first().input<string>("label");
expect(label).toBe("Submit");
// WritableSignal value
const count = await $ng("app-counter").first().signal<number>("count");
expect(count).toBe(0);
// List inputs
const inputs = await $ng("app-button").first().inputs();
expect(inputs).toContain("disabled");
// Check directives
const dirs = await $ng("router-outlet").directives();
expect(dirs.some((d) => d.includes("RouterOutlet"))).toBe(true);
});
Custom Matchers
import { expect } from "@playwright-labs/selectors-angular";
test("component matchers", async ({ page }) => {
const btn = page.locator("app-button").first();
const counter = page.locator("app-counter").first();
await expect(btn).toBeNgComponent();
await expect(btn).toHaveNgInput("label");
await expect(btn).toBeNgInput("label", "Submit");
await expect(btn).toHaveNgOutput("clicked");
await expect(counter).toHaveNgSignal("count");
await expect(counter).toBeNgSignal("count", 0);
});
A Before/After Comparison
Before:
// Depends on template class names — breaks on refactor
const deleteBtn = page.locator("button.danger:disabled");
await expect(deleteBtn).toHaveCount(1);
After:
// Queries the Angular component contract directly
const deleteBtn = page.locator('angular=app-button[type="danger"][disabled]');
await expect(deleteBtn).toHaveCount(1);
The second version continues to work even if the template is rewritten to use different class names, a different element, or a different disabled mechanism — because it queries the component, not the DOM.
The engine transparently handles input() signal-based inputs alongside traditional @Input():
@Component({ selector: "app-badge" })
export class BadgeComponent {
count = input<number>(0); // signal-based input
@Input() label = ""; // traditional input
}
// Both work identically in selectors:
page.locator('angular=app-badge[count=5]')
page.locator('angular=app-badge[label="New"]')
Requirements
- Angular 14+ in development mode (
ng serve or --configuration=development)
- Playwright 1.40+
Conclusion
By teaching Playwright to speak Angular's language, @playwright-labs/selectors-angular makes E2E tests that are more resilient, more readable, and more aligned with how the application is actually built.
Source: github.com/vitalics/playwright-labs