Testing Angular Apps with Playwright
February 13, 2026 ยท View on GitHub
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
Quick Reference
# 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:4200
Setup
Playwright Config for Angular
TypeScript
// 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 CLI Integration
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"
}
}
Environment Configuration
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',
},
},
Patterns
Angular-Specific Locator Strategies
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();
});
});
Testing Reactive Forms
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();
});
});
Testing Angular Material Components
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();
});
});
Testing Angular Router Navigation
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(/\/$/);
});
});
Testing Lazy-Loaded Modules
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([]);
});
});
Testing Signals and Observables Indirectly
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);
});
});
Framework-Specific Tips
Zone.js Considerations
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 to Playwright Migration Checklist
| 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(...) |
Angular Build Configurations
| 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.
CDK Overlay Container
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');
Testing with Angular SSR (Universal)
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([]);
});
Anti-Patterns
| 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 |
Related
- 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