When to use: Testing Angular applications -- reactive forms, Angular Material components, Angular Router navigation, lazy-loaded modules, signals, observables, and Zone.js-driven change detection. This guide covers E2E testing patterns specific to Angular behavior. Prerequisites: core/configuration.md, core/locators.md
# Install Playwright in an Angular project
npm init playwright@latest
# Run tests with Angular dev server managed by Playwright
npx playwright test
# Run against a production build (recommended for CI)
npx playwright test --project=chromium
# Debug a single test
npx playwright test tests/home.spec.ts --headed --debug
# Generate tests with codegen
npx playwright codegen http://localhost:4200TypeScript
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
testMatch: '**/*.spec.ts',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? '50%' : undefined,
use: {
baseURL: 'http://localhost:4200',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'mobile',
use: { ...devices['iPhone 14'] },
},
],
webServer: {
command: process.env.CI
? 'npx ng build && npx http-server dist/your-app/browser -p 4200 -s'
: 'npx ng serve',
url: 'http://localhost:4200',
reuseExistingServer: !process.env.CI,
timeout: 120_000, // Angular builds can be slow
},
});JavaScript
// playwright.config.js
const { defineConfig, devices } = require('@playwright/test');
module.exports = defineConfig({
testDir: './e2e',
testMatch: '**/*.spec.js',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? '50%' : undefined,
use: {
baseURL: 'http://localhost:4200',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'mobile',
use: { ...devices['iPhone 14'] },
},
],
webServer: {
command: process.env.CI
? 'npx ng build && npx http-server dist/your-app/browser -p 4200 -s'
: 'npx ng serve',
url: 'http://localhost:4200',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
});Angular projects that previously used Protractor can adopt Playwright as a direct replacement. The test directory conventionally lives at e2e/ in Angular projects.
your-angular-app/
src/
e2e/
tests/
home.spec.ts
auth.spec.ts
products.spec.ts
fixtures/
auth.fixture.ts
playwright.config.ts
angular.json
package.json
Add scripts to package.json:
{
"scripts": {
"e2e": "playwright test",
"e2e:headed": "playwright test --headed",
"e2e:debug": "playwright test --debug",
"e2e:report": "playwright show-report"
}
}Angular uses environment.ts and environment.prod.ts for build-time configuration. For test-specific settings, use environment variables passed through the Playwright config.
TypeScript
// playwright.config.ts (excerpt)
webServer: {
command: process.env.CI
? 'npx ng build --configuration=production && npx http-server dist/your-app/browser -p 4200 -s'
: 'npx ng serve --configuration=development',
url: 'http://localhost:4200',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
env: {
NG_APP_API_URL: 'http://localhost:4200/api',
},
},Use when: Targeting elements in Angular templates. Angular generates specific attribute patterns (_ngcontent-*, _nghost-*, ng-reflect-*) that you must avoid in locators. Always use semantic locators.
Avoid when: You are tempted to use [_ngcontent-abc123] or [ng-reflect-model] attributes -- they are internal and change on every build.
TypeScript
import { test, expect } from '@playwright/test';
test.describe('Angular locator strategies', () => {
test('prefer role-based locators over Angular internals', async ({ page }) => {
await page.goto('/dashboard');
// GOOD: Role-based locators work with Angular Material and native HTML
await page.getByRole('button', { name: 'Create project' }).click();
await expect(page.getByRole('heading', { name: 'New Project' })).toBeVisible();
// GOOD: Label-based for form fields
await page.getByLabel('Project name').fill('My Project');
// GOOD: Text-based for non-interactive content
await expect(page.getByText('3 projects total')).toBeVisible();
// BAD (never do this):
// page.locator('[_ngcontent-abc]') -- changes every build
// page.locator('[ng-reflect-model]') -- debug attribute, stripped in prod
// page.locator('app-dashboard .mat-card') -- component selector + internal class
});
test('use test IDs for complex Angular components', async ({ page }) => {
await page.goto('/analytics');
// Angular components with no semantic role need test IDs
const chart = page.getByTestId('revenue-chart');
await expect(chart).toBeVisible();
// Configure the testIdAttribute if your team uses a different attribute
// In playwright.config.ts: use: { testIdAttribute: 'data-cy' }
});
test('scope locators within Angular component boundaries', async ({ page }) => {
await page.goto('/users');
// Scope within a table to find specific rows
const userTable = page.getByRole('table', { name: 'Users' });
const adminRow = userTable.getByRole('row').filter({
has: page.getByRole('cell', { name: 'Admin' }),
});
await adminRow.getByRole('button', { name: 'Edit' }).click();
await expect(page.getByRole('dialog', { name: 'Edit User' })).toBeVisible();
});
});JavaScript
const { test, expect } = require('@playwright/test');
test.describe('Angular locator strategies', () => {
test('prefer role-based locators over Angular internals', async ({ page }) => {
await page.goto('/dashboard');
await page.getByRole('button', { name: 'Create project' }).click();
await expect(page.getByRole('heading', { name: 'New Project' })).toBeVisible();
await page.getByLabel('Project name').fill('My Project');
await expect(page.getByText('3 projects total')).toBeVisible();
});
test('use test IDs for complex Angular components', async ({ page }) => {
await page.goto('/analytics');
const chart = page.getByTestId('revenue-chart');
await expect(chart).toBeVisible();
});
test('scope locators within Angular component boundaries', async ({ page }) => {
await page.goto('/users');
const userTable = page.getByRole('table', { name: 'Users' });
const adminRow = userTable.getByRole('row').filter({
has: page.getByRole('cell', { name: 'Admin' }),
});
await adminRow.getByRole('button', { name: 'Edit' }).click();
await expect(page.getByRole('dialog', { name: 'Edit User' })).toBeVisible();
});
});Use when: Testing Angular reactive forms (FormGroup, FormControl, FormArray). Playwright interacts with the rendered DOM, so reactive forms are transparent -- test the user experience.
Avoid when: Testing form validation logic in isolation -- use Angular TestBed unit tests for that.
TypeScript
import { test, expect } from '@playwright/test';
test.describe('reactive forms', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/register');
});
test('shows validation errors for invalid inputs', async ({ page }) => {
// Touch and blur each field to trigger Angular's touched + dirty validators
const emailInput = page.getByLabel('Email');
await emailInput.click();
await emailInput.blur();
await expect(page.getByText('Email is required')).toBeVisible();
await emailInput.fill('not-an-email');
await emailInput.blur();
await expect(page.getByText('Invalid email format')).toBeVisible();
});
test('cross-field validation (password match)', async ({ page }) => {
await page.getByLabel('Password', { exact: true }).fill('Str0ng!Pass');
await page.getByLabel('Confirm password').fill('different-password');
await page.getByLabel('Confirm password').blur();
await expect(page.getByText('Passwords do not match')).toBeVisible();
// Fix the mismatch
await page.getByLabel('Confirm password').fill('Str0ng!Pass');
await page.getByLabel('Confirm password').blur();
await expect(page.getByText('Passwords do not match')).toBeHidden();
});
test('dynamic FormArray -- add and remove items', async ({ page }) => {
await page.goto('/profile/edit');
// Add a phone number (FormArray push)
await page.getByRole('button', { name: 'Add phone number' }).click();
const phoneInputs = page.getByLabel(/Phone number/);
await expect(phoneInputs).toHaveCount(2); // default + new one
await phoneInputs.nth(1).fill('+1-555-0199');
// Remove the first phone number
await page.getByRole('button', { name: 'Remove phone 1' }).click();
await expect(phoneInputs).toHaveCount(1);
await expect(phoneInputs.first()).toHaveValue('+1-555-0199');
});
test('submit button disabled when form is invalid', async ({ page }) => {
const submitButton = page.getByRole('button', { name: 'Create account' });
// Form starts invalid -- button should be disabled
await expect(submitButton).toBeDisabled();
// Fill all required fields
await page.getByLabel('Full name').fill('Jane Doe');
await page.getByLabel('Email').fill('jane@example.com');
await page.getByLabel('Password', { exact: true }).fill('Str0ng!Pass');
await page.getByLabel('Confirm password').fill('Str0ng!Pass');
await page.getByLabel('I agree to the terms').check();
// Now the form is valid -- button should be enabled
await expect(submitButton).toBeEnabled();
});
test('async validator shows loading state', async ({ page }) => {
// Slow down the username availability check
await page.route('**/api/check-username*', async (route) => {
await new Promise((resolve) => setTimeout(resolve, 1000));
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ available: true }),
});
});
await page.getByLabel('Username').fill('janedoe');
await page.getByLabel('Username').blur();
// Async validator fires -- shows a loading indicator
await expect(page.getByTestId('username-checking')).toBeVisible();
// After the check completes
await expect(page.getByTestId('username-checking')).toBeHidden();
await expect(page.getByText('Username is available')).toBeVisible();
});
test('form submission posts correct data', async ({ page }) => {
let submittedData: Record<string, unknown> = {};
await page.route('**/api/register', async (route) => {
submittedData = route.request().postDataJSON();
await route.fulfill({
status: 201,
contentType: 'application/json',
body: JSON.stringify({ id: 1 }),
});
});
await page.getByLabel('Full name').fill('Jane Doe');
await page.getByLabel('Email').fill('jane@example.com');
await page.getByLabel('Password', { exact: true }).fill('Str0ng!Pass');
await page.getByLabel('Confirm password').fill('Str0ng!Pass');
await page.getByLabel('I agree to the terms').check();
await page.getByRole('button', { name: 'Create account' }).click();
expect(submittedData).toMatchObject({
name: 'Jane Doe',
email: 'jane@example.com',
});
});
});JavaScript
const { test, expect } = require('@playwright/test');
test.describe('reactive forms', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/register');
});
test('shows validation errors for invalid inputs', async ({ page }) => {
const emailInput = page.getByLabel('Email');
await emailInput.click();
await emailInput.blur();
await expect(page.getByText('Email is required')).toBeVisible();
await emailInput.fill('not-an-email');
await emailInput.blur();
await expect(page.getByText('Invalid email format')).toBeVisible();
});
test('cross-field validation (password match)', async ({ page }) => {
await page.getByLabel('Password', { exact: true }).fill('Str0ng!Pass');
await page.getByLabel('Confirm password').fill('different-password');
await page.getByLabel('Confirm password').blur();
await expect(page.getByText('Passwords do not match')).toBeVisible();
await page.getByLabel('Confirm password').fill('Str0ng!Pass');
await page.getByLabel('Confirm password').blur();
await expect(page.getByText('Passwords do not match')).toBeHidden();
});
test('submit button disabled when form is invalid', async ({ page }) => {
const submitButton = page.getByRole('button', { name: 'Create account' });
await expect(submitButton).toBeDisabled();
await page.getByLabel('Full name').fill('Jane Doe');
await page.getByLabel('Email').fill('jane@example.com');
await page.getByLabel('Password', { exact: true }).fill('Str0ng!Pass');
await page.getByLabel('Confirm password').fill('Str0ng!Pass');
await page.getByLabel('I agree to the terms').check();
await expect(submitButton).toBeEnabled();
});
});Use when: Testing apps using Angular Material (mat-button, mat-input, mat-select, mat-dialog, mat-table, etc.). Angular Material components use proper ARIA attributes, making them accessible to role-based locators.
Avoid when: Using CSS class selectors like .mat-mdc-button or .mat-option -- these change between Material versions.
TypeScript
import { test, expect } from '@playwright/test';
test.describe('Angular Material components', () => {
test('mat-select dropdown', async ({ page }) => {
await page.goto('/settings');
// Angular Material select has role="combobox"
await page.getByRole('combobox', { name: 'Theme' }).click();
// Options appear in a CDK overlay (similar to a portal)
await page.getByRole('option', { name: 'Dark' }).click();
// Verify the selection
await expect(page.getByRole('combobox', { name: 'Theme' })).toContainText('Dark');
});
test('mat-autocomplete with type-ahead', async ({ page }) => {
await page.goto('/users/new');
const roleInput = page.getByRole('combobox', { name: 'Role' });
await roleInput.fill('adm');
// Autocomplete suggestions appear in a CDK overlay
await expect(page.getByRole('option', { name: 'Admin' })).toBeVisible();
await expect(page.getByRole('option', { name: 'Administrator' })).toBeVisible();
await page.getByRole('option', { name: 'Admin' }).click();
await expect(roleInput).toHaveValue('Admin');
});
test('mat-dialog opens and closes', async ({ page }) => {
await page.goto('/projects');
await page.getByRole('button', { name: 'Delete project' }).first().click();
// MatDialog renders as a CDK overlay with role="dialog"
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await expect(dialog.getByText('Are you sure?')).toBeVisible();
// Cancel
await dialog.getByRole('button', { name: 'Cancel' }).click();
await expect(dialog).toBeHidden();
});
test('mat-table sorting', async ({ page }) => {
await page.goto('/users');
// Click the column header to sort
await page.getByRole('columnheader', { name: 'Name' }).click();
// Verify sort indicator
const header = page.getByRole('columnheader', { name: 'Name' });
await expect(header).toHaveAttribute('aria-sort', 'ascending');
// Verify rows are sorted
const names = await page.getByRole('cell').filter({
has: page.locator('[data-column="name"]'),
}).allTextContents();
const sortedNames = [...names].sort();
expect(names).toEqual(sortedNames);
// Click again for descending
await page.getByRole('columnheader', { name: 'Name' }).click();
await expect(header).toHaveAttribute('aria-sort', 'descending');
});
test('mat-paginator controls table pagination', async ({ page }) => {
await page.goto('/users');
await expect(page.getByText('1 - 10 of 50')).toBeVisible();
// Navigate to next page
await page.getByRole('button', { name: 'Next page' }).click();
await expect(page.getByText('11 - 20 of 50')).toBeVisible();
// Change page size
await page.getByRole('combobox', { name: 'Items per page' }).click();
await page.getByRole('option', { name: '25' }).click();
await expect(page.getByText('1 - 25 of 50')).toBeVisible();
});
test('mat-snack-bar notification appears and dismisses', async ({ page }) => {
await page.goto('/settings');
await page.getByRole('button', { name: 'Save' }).click();
// Snackbar appears at the bottom of the screen
await expect(page.getByText('Settings saved successfully')).toBeVisible();
// Dismiss via action button
await page.getByRole('button', { name: 'Dismiss' }).click();
await expect(page.getByText('Settings saved successfully')).toBeHidden();
});
test('mat-stepper wizard flow', async ({ page }) => {
await page.goto('/onboarding');
// Step 1: Personal info
await expect(page.getByText('Step 1 of 3')).toBeVisible();
await page.getByLabel('Full name').fill('Jane Doe');
await page.getByRole('button', { name: 'Next' }).click();
// Step 2: Company info
await expect(page.getByText('Step 2 of 3')).toBeVisible();
await page.getByLabel('Company').fill('Acme Corp');
await page.getByRole('button', { name: 'Next' }).click();
// Step 3: Review
await expect(page.getByText('Step 3 of 3')).toBeVisible();
await expect(page.getByText('Jane Doe')).toBeVisible();
await expect(page.getByText('Acme Corp')).toBeVisible();
// Go back to step 1
await page.getByRole('button', { name: 'Back' }).click();
await page.getByRole('button', { name: 'Back' }).click();
await expect(page.getByText('Step 1 of 3')).toBeVisible();
});
});JavaScript
const { test, expect } = require('@playwright/test');
test.describe('Angular Material components', () => {
test('mat-select dropdown', async ({ page }) => {
await page.goto('/settings');
await page.getByRole('combobox', { name: 'Theme' }).click();
await page.getByRole('option', { name: 'Dark' }).click();
await expect(page.getByRole('combobox', { name: 'Theme' })).toContainText('Dark');
});
test('mat-dialog opens and closes', async ({ page }) => {
await page.goto('/projects');
await page.getByRole('button', { name: 'Delete project' }).first().click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await expect(dialog.getByText('Are you sure?')).toBeVisible();
await dialog.getByRole('button', { name: 'Cancel' }).click();
await expect(dialog).toBeHidden();
});
test('mat-table sorting', async ({ page }) => {
await page.goto('/users');
await page.getByRole('columnheader', { name: 'Name' }).click();
const header = page.getByRole('columnheader', { name: 'Name' });
await expect(header).toHaveAttribute('aria-sort', 'ascending');
});
test('mat-snack-bar notification appears and dismisses', async ({ page }) => {
await page.goto('/settings');
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText('Settings saved successfully')).toBeVisible();
await page.getByRole('button', { name: 'Dismiss' }).click();
await expect(page.getByText('Settings saved successfully')).toBeHidden();
});
});Use when: Testing Angular Router navigation, lazy-loaded routes, route guards, and URL parameter handling. Avoid when: Testing router configuration in isolation -- use Angular TestBed for that.
TypeScript
import { test, expect } from '@playwright/test';
test.describe('Angular Router navigation', () => {
test('lazy-loaded module loads on navigation', async ({ page }) => {
await page.goto('/');
// Navigate to a lazy-loaded route
await page.getByRole('link', { name: 'Admin' }).click();
await page.waitForURL('/admin');
// The lazy module loads and renders its component
await expect(page.getByRole('heading', { name: 'Admin Dashboard' })).toBeVisible();
});
test('route guard redirects unauthorized users', async ({ page }) => {
// Visit a route protected by AuthGuard (canActivate)
await page.goto('/admin/users');
// Guard should redirect to login
await expect(page).toHaveURL(/\/login/);
await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible();
});
test('route resolver prefetches data before navigation', async ({ page }) => {
// Intercept the API call that the resolver makes
const resolverPromise = page.waitForResponse('**/api/products/*');
await page.goto('/products/42');
// The resolver fetches data before the component renders
await resolverPromise;
// Component renders with pre-fetched data (no loading spinner)
await expect(page.getByRole('heading', { level: 1 })).toContainText('Product');
});
test('nested router-outlet renders child components', async ({ page }) => {
await page.goto('/settings/profile');
// Parent layout (SettingsComponent with its own router-outlet)
await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible();
await expect(page.getByRole('navigation', { name: 'Settings' })).toBeVisible();
// Child route (ProfileComponent rendered inside nested router-outlet)
await expect(page.getByRole('heading', { name: 'Profile', level: 2 })).toBeVisible();
// Navigate to sibling child route
await page.getByRole('link', { name: 'Security' }).click();
await page.waitForURL('/settings/security');
// Parent persists, child changes
await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Security', level: 2 })).toBeVisible();
});
test('route parameters update component state', async ({ page }) => {
await page.goto('/users/1');
await expect(page.getByRole('heading')).toContainText('User #1');
// Navigate to a different user via the URL
await page.goto('/users/2');
await expect(page.getByRole('heading')).toContainText('User #2');
});
test('query parameters drive filter behavior', async ({ page }) => {
await page.goto('/products?category=electronics&page=2');
await expect(page.getByRole('heading', { name: 'Electronics' })).toBeVisible();
await expect(page.getByText('Page 2')).toBeVisible();
});
test('browser back navigates through Angular history', async ({ page }) => {
await page.goto('/');
await page.getByRole('link', { name: 'Products' }).click();
await page.waitForURL('/products');
await page.getByRole('link', { name: 'About' }).click();
await page.waitForURL('/about');
await page.goBack();
await expect(page).toHaveURL(/\/products/);
await page.goBack();
await expect(page).toHaveURL(/\/$/);
});
});JavaScript
const { test, expect } = require('@playwright/test');
test.describe('Angular Router navigation', () => {
test('lazy-loaded module loads on navigation', async ({ page }) => {
await page.goto('/');
await page.getByRole('link', { name: 'Admin' }).click();
await page.waitForURL('/admin');
await expect(page.getByRole('heading', { name: 'Admin Dashboard' })).toBeVisible();
});
test('route guard redirects unauthorized users', async ({ page }) => {
await page.goto('/admin/users');
await expect(page).toHaveURL(/\/login/);
await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible();
});
test('nested router-outlet renders child components', async ({ page }) => {
await page.goto('/settings/profile');
await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Profile', level: 2 })).toBeVisible();
await page.getByRole('link', { name: 'Security' }).click();
await page.waitForURL('/settings/security');
await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Security', level: 2 })).toBeVisible();
});
test('browser back navigates through Angular history', async ({ page }) => {
await page.goto('/');
await page.getByRole('link', { name: 'Products' }).click();
await page.waitForURL('/products');
await page.getByRole('link', { name: 'About' }).click();
await page.waitForURL('/about');
await page.goBack();
await expect(page).toHaveURL(/\/products/);
await page.goBack();
await expect(page).toHaveURL(/\/$/);
});
});Use when: Verifying that Angular lazy-loaded feature modules load correctly when the user navigates to their routes. Lazy-loaded modules introduce network requests for JavaScript chunks. Avoid when: The module is eagerly loaded -- no separate chunk to load.
TypeScript
import { test, expect } from '@playwright/test';
test.describe('lazy-loaded modules', () => {
test('lazy module loads without errors', async ({ page }) => {
const consoleErrors: string[] = [];
page.on('console', (msg) => {
if (msg.type() === 'error') {
consoleErrors.push(msg.text());
}
});
await page.goto('/');
// Navigate to a lazy-loaded route
const chunkRequest = page.waitForResponse((response) =>
response.url().includes('.js') && response.status() === 200
);
await page.getByRole('link', { name: 'Reports' }).click();
await chunkRequest;
await page.waitForURL('/reports');
await expect(page.getByRole('heading', { name: 'Reports' })).toBeVisible();
// No chunk loading errors
const chunkErrors = consoleErrors.filter(
(e) => e.includes('ChunkLoadError') || e.includes('Loading chunk')
);
expect(chunkErrors).toEqual([]);
});
test('preloaded lazy module navigates instantly', async ({ page }) => {
await page.goto('/dashboard');
// If preloadingStrategy is configured, the module may already be cached
// Navigate and verify it renders without visible delay
const startTime = Date.now();
await page.getByRole('link', { name: 'Reports' }).click();
await page.waitForURL('/reports');
await expect(page.getByRole('heading', { name: 'Reports' })).toBeVisible();
const loadTime = Date.now() - startTime;
// Preloaded modules should render quickly (not an exact assertion, but a sanity check)
expect(loadTime).toBeLessThan(3000);
});
});JavaScript
const { test, expect } = require('@playwright/test');
test.describe('lazy-loaded modules', () => {
test('lazy module loads without errors', async ({ page }) => {
const consoleErrors = [];
page.on('console', (msg) => {
if (msg.type() === 'error') {
consoleErrors.push(msg.text());
}
});
await page.goto('/');
await page.getByRole('link', { name: 'Reports' }).click();
await page.waitForURL('/reports');
await expect(page.getByRole('heading', { name: 'Reports' })).toBeVisible();
const chunkErrors = consoleErrors.filter(
(e) => e.includes('ChunkLoadError') || e.includes('Loading chunk')
);
expect(chunkErrors).toEqual([]);
});
});Use when: Verifying that Angular signals (signal(), computed(), effect()) and RxJS observables produce correct UI updates. Playwright cannot subscribe to observables or read signals directly -- test through the rendered output.
Avoid when: Testing observable transformation logic in isolation -- use Jasmine/Jest with Angular TestBed for that.
TypeScript
import { test, expect } from '@playwright/test';
test.describe('signals (tested through UI)', () => {
test('signal-based counter updates the DOM', async ({ page }) => {
await page.goto('/counter');
// The counter uses signal() internally
await expect(page.getByTestId('count')).toHaveText('0');
await page.getByRole('button', { name: 'Increment' }).click();
await expect(page.getByTestId('count')).toHaveText('1');
await page.getByRole('button', { name: 'Increment' }).click();
await page.getByRole('button', { name: 'Increment' }).click();
await expect(page.getByTestId('count')).toHaveText('3');
await page.getByRole('button', { name: 'Reset' }).click();
await expect(page.getByTestId('count')).toHaveText('0');
});
test('computed signal updates derived values', async ({ page }) => {
await page.goto('/cart');
// Cart total is a computed() signal derived from items
await expect(page.getByTestId('cart-total')).toHaveText('$0.00');
// Add item (updates the items signal, which updates the computed total)
await page.goto('/products');
await page.getByRole('listitem')
.filter({ hasText: '$29.99' })
.getByRole('button', { name: 'Add to cart' })
.click();
await page.getByRole('link', { name: 'Cart' }).click();
await expect(page.getByTestId('cart-total')).toHaveText('$29.99');
});
});
test.describe('observables (tested through UI)', () => {
test('real-time data stream updates the UI', async ({ page }) => {
await page.goto('/dashboard');
// The component subscribes to an observable that emits stock prices
const priceElement = page.getByTestId('stock-price');
await expect(priceElement).toBeVisible();
// Get the initial value
const initialPrice = await priceElement.textContent();
// Wait for the observable to emit a new value
// Use polling assertion instead of waitForTimeout
await expect(priceElement).not.toHaveText(initialPrice!, { timeout: 10_000 });
});
test('search with debounceTime observable', async ({ page }) => {
await page.goto('/search');
const apiCalls: string[] = [];
await page.route('**/api/search*', async (route) => {
apiCalls.push(route.request().url());
await route.continue();
});
// Type quickly -- the observable's debounceTime should batch
await page.getByRole('textbox', { name: 'Search' }).pressSequentially('angular', {
delay: 50,
});
await expect(page.getByRole('listitem')).toHaveCount(5);
// debounceTime should prevent a request per keystroke
expect(apiCalls.length).toBeLessThanOrEqual(2);
});
test('switchMap cancels previous requests on new input', async ({ page }) => {
await page.goto('/search');
// Type one query
await page.getByRole('textbox', { name: 'Search' }).fill('first query');
// Immediately type a different query before results come back
await page.getByRole('textbox', { name: 'Search' }).fill('second query');
// Results should match the second query, not the first
await expect(page.getByRole('listitem').first()).toContainText(/second query/i);
});
});JavaScript
const { test, expect } = require('@playwright/test');
test.describe('signals (tested through UI)', () => {
test('signal-based counter updates the DOM', async ({ page }) => {
await page.goto('/counter');
await expect(page.getByTestId('count')).toHaveText('0');
await page.getByRole('button', { name: 'Increment' }).click();
await expect(page.getByTestId('count')).toHaveText('1');
await page.getByRole('button', { name: 'Increment' }).click();
await page.getByRole('button', { name: 'Increment' }).click();
await expect(page.getByTestId('count')).toHaveText('3');
await page.getByRole('button', { name: 'Reset' }).click();
await expect(page.getByTestId('count')).toHaveText('0');
});
});
test.describe('observables (tested through UI)', () => {
test('search with debounceTime observable', async ({ page }) => {
await page.goto('/search');
const apiCalls = [];
await page.route('**/api/search*', async (route) => {
apiCalls.push(route.request().url());
await route.continue();
});
await page.getByRole('textbox', { name: 'Search' }).pressSequentially('angular', {
delay: 50,
});
await expect(page.getByRole('listitem')).toHaveCount(5);
expect(apiCalls.length).toBeLessThanOrEqual(2);
});
});Angular uses Zone.js to detect async operations and trigger change detection. Playwright does not depend on Zone.js -- it interacts with the DOM directly. However, Zone.js can affect test behavior:
-
Change detection timing: After user interactions (click, fill), Angular schedules change detection via Zone.js. Playwright's auto-waiting handles this --
expect(locator).toHaveText('new value')retries until the DOM updates. -
Zoneless Angular (experimental): Angular 17+ supports zoneless change detection. Tests work identically with Playwright because Playwright waits for DOM changes, not Zone.js ticks.
-
Long-running async operations: If your app has
setIntervalor long-running observables, Zone.js keeps Angular in a "not stable" state. This does not affect Playwright (unlike Protractor, which waited for Angular stability). Playwright simply interacts with whatever is on the screen.
| Protractor | Playwright Equivalent |
|---|---|
element(by.css('.btn')) |
page.locator('.btn') -- but prefer page.getByRole('button', { name: '...' }) |
element(by.id('login')) |
page.getByTestId('login') or page.getByRole(...) |
element(by.buttonText('Submit')) |
page.getByRole('button', { name: 'Submit' }) |
element(by.model('user.name')) |
page.getByLabel('Name') -- Playwright cannot read ng-model |
element(by.binding('user.name')) |
page.getByText(expectedValue) -- test the rendered output |
element(by.repeater('item in items')) |
page.getByRole('listitem') or page.getByTestId(...) |
browser.waitForAngular() |
Not needed -- Playwright auto-waits; remove all instances |
browser.sleep(3000) |
await expect(locator).toBeVisible() -- never use arbitrary waits |
browser.get('/path') |
await page.goto('/path') |
protractor.ExpectedConditions |
await expect(locator).toBeVisible/toBeHidden/toHaveText(...) |
| Scenario | Build Command | Notes |
|---|---|---|
| Local development | npx ng serve |
Fast rebuild, source maps, no optimization |
| CI (production build) | npx ng build && npx http-server dist/your-app/browser -p 4200 -s |
Tests the real production bundle |
| CI (SSR/Universal) | npx ng build --ssr && node dist/your-app/server/server.mjs |
Tests server-side rendered Angular |
| Staging environment | No webServer needed |
Point baseURL to the staging URL |
The -s flag on http-server enables SPA fallback (sends index.html for all routes), which is essential for Angular Router to work correctly.
Angular Material and Angular CDK render overlays (dialogs, menus, selects, autocompletes) in a special container outside the component tree. Playwright sees these overlays in the document -- no special handling is needed. Use standard role-based locators:
// CDK overlays render into <div class="cdk-overlay-container"> at the body level
// Playwright sees them as regular DOM elements
const dialog = page.getByRole('dialog');
const menu = page.getByRole('menu');
const listbox = page.getByRole('listbox');If your Angular app uses server-side rendering:
// playwright.config.ts (SSR-specific)
webServer: {
command: process.env.CI
? 'npx ng build --ssr && node dist/your-app/server/server.mjs'
: 'npx ng serve --ssr',
url: 'http://localhost:4200',
reuseExistingServer: !process.env.CI,
timeout: 180_000, // SSR builds are slower
},Test for hydration issues the same way as with other SSR frameworks:
test('no hydration errors after SSR', async ({ page }) => {
const errors: string[] = [];
page.on('console', (msg) => {
if (msg.type() === 'error' && msg.text().includes('hydration')) {
errors.push(msg.text());
}
});
await page.goto('/');
await page.getByRole('button', { name: 'Get started' }).click();
expect(errors).toEqual([]);
});| Don't Do This | Problem | Do This Instead |
|---|---|---|
page.locator('[_ngcontent-abc123]') |
Angular scoped style attributes are random and change every build | Use getByRole, getByLabel, getByText, getByTestId |
page.locator('[ng-reflect-model="value"]') |
ng-reflect-* attributes only exist in dev mode; stripped in production |
Test the rendered value: expect(input).toHaveValue('value') |
page.locator('app-my-component') |
Angular component selectors are implementation details | Target the content the component renders using semantic locators |
page.locator('.mat-mdc-button') |
Angular Material class names change between versions (MDC migration) | page.getByRole('button', { name: 'Submit' }) |
page.evaluate(() => (window as any).ng) to access Angular internals |
Depends on debug mode; not available in production builds | Test through the DOM; never access the Angular runtime |
await page.waitForTimeout(500) after clicking a button |
Zone.js change detection timing varies; arbitrary waits are fragile | await expect(locator).toHaveText('expected value') auto-retries |
browser.waitForAngular() (Protractor pattern) |
Does not exist in Playwright; not needed -- Playwright auto-waits | Remove entirely; use web-first assertions |
Test Angular services by injecting them via page.evaluate |
Services are not accessible from the browser console in production | Test services indirectly through the UI they power; unit test with TestBed |
Use ng serve in CI |
Development server is slower, includes debug code, may hide production-only bugs | Use ng build && http-server in CI |
| Skip testing CDK overlay components (dialogs, selects, menus) | These are the most interactive parts of the app; bugs here are highly visible | Test overlays with role-based locators; they render in the regular DOM |
- core/locators.md -- locator strategies for Angular Material and CDK components
- core/assertions-and-waiting.md -- auto-waiting assertions that replace Protractor's waitForAngular
- core/forms-and-validation.md -- form testing patterns for reactive and template-driven forms
- core/accessibility.md -- accessibility testing for Angular Material components
- core/authentication.md -- authentication with Angular route guards
- migration/from-selenium.md -- migration patterns applicable to Protractor (Protractor is built on Selenium)
- core/test-architecture.md -- when to use E2E vs unit tests with Angular TestBed
- ci/ci-github-actions.md -- CI setup with Angular build caching