Angular-Aware E2E Testing: Query Components by @Input and Signals in Playwright

posted 2 min read

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.

Signal-Based Inputs (Angular 17+)

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

More Posts

5 Things This Playwright SQL Fixture Does So You Don't Have To

vitalicset - Apr 13

The Senior Angular Take‑Home That Made Me Rethink Tech Interviews

Karol Modelskiverified - Apr 2

Stop Treating Angular as a Second-Class Framework for UI Components

Karol Modelskiverified - Apr 16

4 new Playwright email templates with shadcn/ui — and how to make shadcn work in email

vitalicset - Mar 30

How We Eliminated Container Boilerplate from Playwright Integration Tests

vitalicset - Mar 26
chevron_left

Related Jobs

View all jobs →

Commenters (This Week)

3 comments
2 comments

Contribute meaningful comments to climb the leaderboard and earn badges!