Most Angular E2E tests look like this:
await page.locator('[data-testid="submit-btn"]').click();
await page.locator('.user-card:nth-child(2) h2').textContent();
You end up fighting CSS specificity and fragile DOM structure instead of testing Angular behavior. What if you could do this instead?
await page.locator('angular=app-button[label="Submit"]').click();
await page.locator('angular=app-user-card[user.role="admin"]').textContent();
That's exactly what @playwright-labs/selectors-angular provides.
What Is It?
A custom Playwright selector engine that queries Angular components by their actual component properties — @Input() values, signals, and nested objects — rather than DOM attributes or CSS classes.
It hooks into Angular's DevTools API (window.ng) which is available in development builds.
Installation
npm install -D @playwright-labs/selectors-angular
Setup
Register the engine in your playwright.config.ts:
import { defineConfig } from "@playwright/test";
import { AngularEngine } from "@playwright-labs/selectors-angular";
export default defineConfig({
use: {
/* ... */
},
});
// Register once at module level
import { selectors } from "@playwright/test";
selectors.register("angular", AngularEngine);
Or use the bundled test fixture which handles registration automatically:
import { test, expect } from "@playwright-labs/selectors-angular";
test("my angular test", async ({ page, $ng }) => {
// $ng and engine are auto-registered
});
The angular= Selector Syntax
The syntax mirrors CSS attribute selectors:
angular=<component-tag>[property="value"][another="value"]
Operators
| Operator | Meaning | Example |
|---|---|---|
[prop] |
truthy check | [disabled] |
[prop="val"] |
exact match | [label="Submit"] |
[prop*="val"] |
contains | [type*="ary"] |
[prop^="val"] |
starts with | [type^="prim"] |
[prop$="val"] |
ends with | [type$="ger"] |
[prop~="val"] |
word in list | [classes~="active"] |
| `[prop\ | ="val"]` | exact or hyphen-prefix |
You can also use regex:
page.locator('angular=app-button[label=/^Sub/i]')
And case-insensitive with i flag:
page.locator('angular=app-button[label="submit" i]')
Nested Properties
Dot notation works for nested objects:
page.locator('angular=app-user-card[user.role="admin"]')
page.locator('angular=app-card[config.theme.color="dark"]')
Multiple Conditions (AND)
Chain multiple attribute blocks — all must match:
page.locator('angular=app-button[type="danger"][disabled]')
The $ng Fixture
The $ng fixture wraps standard Playwright locators with Angular-aware introspection methods:
test("inspects component state", async ({ $ng }) => {
// Read @Input value
const label = await $ng("app-button").first().input<string>("label");
expect(label).toBe("Submit");
// Read WritableSignal value
const count = await $ng("app-counter").first().signal<number>("count");
expect(count).toBe(0);
// List all @Input names
const inputs = await $ng("app-button").first().inputs();
expect(inputs).toContain("label");
expect(inputs).toContain("disabled");
// Check directives
const directives = await $ng("router-outlet").directives();
expect(directives.some((d) => d.includes("RouterOutlet"))).toBe(true);
});
Narrowing
const buttons = $ng("app-button");
const first = buttons.first();
const second = buttons.nth(1);
const last = buttons.last();
Custom Matchers
Import the extended expect from the package:
import { expect } from "@playwright-labs/selectors-angular";
test("matchers", async ({ page }) => {
const button = page.locator("app-button").first();
// Is it an Angular component?
await expect(button).toBeNgComponent();
// Does it declare this @Input?
await expect(button).toHaveNgInput("label");
// Does the @Input hold this value?
await expect(button).toBeNgInput("label", "Submit");
// Does it declare this @Output?
await expect(button).toHaveNgOutput("clicked");
// Signal assertions
const counter = page.locator("app-counter").first();
await expect(counter).toHaveNgSignal("count");
await expect(counter).toBeNgSignal("count", 0);
});
Real-World Example
Here's a test that checks a disabled danger button exists and has the right label:
test("danger button is disabled and labeled Delete", async ({ page, $ng }) => {
await page.goto("/");
// Use the selector engine to find it
const btn = page.locator('angular=app-button[type="danger"][disabled]');
await expect(btn).toHaveCount(1);
// Use the fixture to read its label
const label = await $ng('angular=app-button[type="danger"][disabled]').input<string>("label");
expect(label).toBe("Delete");
});
Compare that to the equivalent DOM-based test:
// Before: fragile, depends on implementation details
const btn = page.locator('[data-testid="delete-btn"]');
await expect(btn).toBeDisabled();
The Angular-aware test is self-documenting — it describes the component contract, not the DOM structure.
Requirements
- Angular app running in development mode (
ng serveorng build --configuration=development) - Playwright 1.40+
- Angular 14+ (DevTools API required)
Wrapping Up
@playwright-labs/selectors-angular closes the gap between how Angular apps are built (component trees with typed inputs) and how they're tested (brittle CSS selectors). By querying components by their properties, your tests:
- Break less when the DOM changes
- Read like documentation
- Test the Angular contract, not the DOM implementation
Check out the playwright-labs repository for more packages in the ecosystem.
Top comments (0)