Testing Angular Components by Properties with Playwright

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 serve or ng 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.