Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Comprehensive Playwright testing guide covering E2E, component, API, visual, accessibility, and security tests.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
frameworks/angular.md
1# Angular Testing with Playwright23## Table of Contents451. [Configuration](#configuration)62. [Locator Strategies](#locator-strategies)73. [Reactive Forms](#reactive-forms)84. [Angular Material Components](#angular-material-components)95. [Router Navigation](#router-navigation)106. [Lazy-Loaded Modules](#lazy-loaded-modules)117. [Signals and Observables](#signals-and-observables)128. [Zone.js and Change Detection](#zonejs-and-change-detection)139. [SSR Testing](#ssr-testing)1410. [Protractor Migration Reference](#protractor-migration-reference)1511. [Build Configurations](#build-configurations)1612. [CDK Overlay Container](#cdk-overlay-container)1713. [Anti-Patterns](#anti-patterns)1814. [Related](#related)1920> **When to use**: Testing Angular applications with reactive forms, Angular Material components, Router navigation, lazy-loaded modules, signals, observables, and Zone.js change detection.21> **Prerequisites**: [core/configuration.md](../core/configuration.md), [core/locators.md](../core/locators.md)2223## Configuration2425### Playwright Config2627```typescript28import { defineConfig, devices } from '@playwright/test';2930export default defineConfig({31testDir: './e2e',32testMatch: '**/*.spec.ts',33fullyParallel: true,34forbidOnly: !!process.env.CI,35retries: process.env.CI ? 2 : 0,36workers: process.env.CI ? '50%' : undefined,3738use: {39baseURL: 'http://localhost:4200',40trace: 'on-first-retry',41screenshot: 'only-on-failure',42},4344projects: [45{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },46{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },47{ name: 'mobile', use: { ...devices['iPhone 14'] } },48],4950webServer: {51command: process.env.CI52? 'npx ng build && npx http-server dist/my-app/browser -p 4200 -s'53: 'npx ng serve',54url: 'http://localhost:4200',55reuseExistingServer: !process.env.CI,56timeout: 120_000,57},58});59```6061### Project Structure6263```text64my-angular-app/65src/66e2e/67tests/68dashboard.spec.ts69login.spec.ts70fixtures/71auth.fixture.ts72playwright.config.ts73angular.json74```7576### Package Scripts7778```json79{80"scripts": {81"e2e": "playwright test",82"e2e:headed": "playwright test --headed",83"e2e:debug": "playwright test --debug",84"e2e:report": "playwright show-report"85}86}87```8889## Locator Strategies9091Angular generates internal attributes (`_ngcontent-*`, `_nghost-*`, `ng-reflect-*`) that change every build. Always use semantic locators.9293```typescript94test('use semantic locators for Angular apps', async ({ page }) => {95await page.goto('/projects');9697// Role-based locators work with Angular Material and native HTML98await page.getByRole('button', { name: 'New project' }).click();99await expect(page.getByRole('heading', { name: 'Create Project' })).toBeVisible();100101// Label-based for form fields102await page.getByLabel('Project title').fill('Alpha');103104// Test IDs for complex components without semantic roles105const chart = page.getByTestId('metrics-chart');106await expect(chart).toBeVisible();107108// Scope locators within component boundaries109const projectTable = page.getByRole('table', { name: 'Projects' });110const activeRow = projectTable.getByRole('row').filter({111has: page.getByRole('cell', { name: 'Active' }),112});113await activeRow.getByRole('button', { name: 'Edit' }).click();114});115```116117## Reactive Forms118119Playwright interacts with the rendered DOM, so reactive forms (`FormGroup`, `FormControl`, `FormArray`) are transparent.120121```typescript122test.describe('form validation', () => {123test.beforeEach(async ({ page }) => {124await page.goto('/signup');125});126127test('displays validation errors on blur', async ({ page }) => {128const emailField = page.getByLabel('Email');129await emailField.click();130await emailField.blur();131await expect(page.getByText('Email is required')).toBeVisible();132133await emailField.fill('invalid');134await emailField.blur();135await expect(page.getByText('Invalid email format')).toBeVisible();136});137138test('validates password confirmation', async ({ page }) => {139await page.getByLabel('Password', { exact: true }).fill('Secret123!');140await page.getByLabel('Confirm password').fill('Mismatch');141await page.getByLabel('Confirm password').blur();142143await expect(page.getByText('Passwords must match')).toBeVisible();144145await page.getByLabel('Confirm password').fill('Secret123!');146await expect(page.getByText('Passwords must match')).toBeHidden();147});148149test('handles FormArray add/remove', async ({ page }) => {150await page.goto('/contacts/edit');151152await page.getByRole('button', { name: 'Add email' }).click();153const emailInputs = page.getByLabel(/Email address/);154await expect(emailInputs).toHaveCount(2);155156await emailInputs.nth(1).fill('[email protected]');157await page.getByRole('button', { name: 'Remove email 1' }).click();158159await expect(emailInputs).toHaveCount(1);160await expect(emailInputs.first()).toHaveValue('[email protected]');161});162163test('disables submit until form is valid', async ({ page }) => {164const submitBtn = page.getByRole('button', { name: 'Register' });165await expect(submitBtn).toBeDisabled();166167await page.getByLabel('Name').fill('Alice');168await page.getByLabel('Email').fill('[email protected]');169await page.getByLabel('Password', { exact: true }).fill('Secret123!');170await page.getByLabel('Confirm password').fill('Secret123!');171await page.getByLabel('Accept terms').check();172173await expect(submitBtn).toBeEnabled();174});175176test('shows async validator loading state', async ({ page }) => {177await page.route('**/api/username-check*', async (route) => {178await new Promise((r) => setTimeout(r, 800));179await route.fulfill({ json: { available: true } });180});181182await page.getByLabel('Username').fill('alice');183await page.getByLabel('Username').blur();184185await expect(page.getByTestId('username-loading')).toBeVisible();186await expect(page.getByTestId('username-loading')).toBeHidden();187await expect(page.getByText('Username available')).toBeVisible();188});189});190```191192## Angular Material Components193194Angular Material uses proper ARIA attributes. Use role-based locators instead of CSS classes like `.mat-mdc-button`.195196```typescript197test.describe('Material components', () => {198test('mat-select dropdown', async ({ page }) => {199await page.goto('/preferences');200201await page.getByRole('combobox', { name: 'Language' }).click();202await page.getByRole('option', { name: 'Spanish' }).click();203204await expect(page.getByRole('combobox', { name: 'Language' })).toContainText('Spanish');205});206207test('mat-autocomplete suggestions', async ({ page }) => {208await page.goto('/members/add');209210const roleField = page.getByRole('combobox', { name: 'Role' });211await roleField.fill('dev');212213await expect(page.getByRole('option', { name: 'Developer' })).toBeVisible();214await expect(page.getByRole('option', { name: 'DevOps' })).toBeVisible();215216await page.getByRole('option', { name: 'Developer' }).click();217await expect(roleField).toHaveValue('Developer');218});219220test('mat-dialog interaction', async ({ page }) => {221await page.goto('/items');222223await page.getByRole('button', { name: 'Remove item' }).first().click();224225const dialog = page.getByRole('dialog');226await expect(dialog).toBeVisible();227await expect(dialog.getByText('Confirm deletion?')).toBeVisible();228229await dialog.getByRole('button', { name: 'Cancel' }).click();230await expect(dialog).toBeHidden();231});232233test('mat-table sorting', async ({ page }) => {234await page.goto('/members');235236await page.getByRole('columnheader', { name: 'Name' }).click();237const header = page.getByRole('columnheader', { name: 'Name' });238await expect(header).toHaveAttribute('aria-sort', 'ascending');239240await page.getByRole('columnheader', { name: 'Name' }).click();241await expect(header).toHaveAttribute('aria-sort', 'descending');242});243244test('mat-paginator navigation', async ({ page }) => {245await page.goto('/members');246247await expect(page.getByText('1 - 10 of 100')).toBeVisible();248249await page.getByRole('button', { name: 'Next page' }).click();250await expect(page.getByText('11 - 20 of 100')).toBeVisible();251252await page.getByRole('combobox', { name: 'Items per page' }).click();253await page.getByRole('option', { name: '50' }).click();254await expect(page.getByText('1 - 50 of 100')).toBeVisible();255});256257test('mat-snack-bar notification', async ({ page }) => {258await page.goto('/preferences');259260await page.getByRole('button', { name: 'Save' }).click();261await expect(page.getByText('Changes saved')).toBeVisible();262263await page.getByRole('button', { name: 'Close' }).click();264await expect(page.getByText('Changes saved')).toBeHidden();265});266267test('mat-stepper wizard', async ({ page }) => {268await page.goto('/wizard');269270await expect(page.getByText('Step 1 of 3')).toBeVisible();271await page.getByLabel('Name').fill('Bob');272await page.getByRole('button', { name: 'Next' }).click();273274await expect(page.getByText('Step 2 of 3')).toBeVisible();275await page.getByLabel('Organization').fill('Acme');276await page.getByRole('button', { name: 'Next' }).click();277278await expect(page.getByText('Step 3 of 3')).toBeVisible();279await expect(page.getByText('Bob')).toBeVisible();280await expect(page.getByText('Acme')).toBeVisible();281});282});283```284285## Router Navigation286287```typescript288test.describe('Angular Router', () => {289test('lazy-loaded module loads on navigation', async ({ page }) => {290await page.goto('/');291292await page.getByRole('link', { name: 'Reports' }).click();293await page.waitForURL('/reports');294295await expect(page.getByRole('heading', { name: 'Reports Dashboard' })).toBeVisible();296});297298test('route guard redirects unauthorized users', async ({ page }) => {299await page.goto('/admin/settings');300301await expect(page).toHaveURL(/\/login/);302await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible();303});304305test('resolver prefetches data', async ({ page }) => {306const resolverPromise = page.waitForResponse('**/api/items/*');307await page.goto('/items/42');308await resolverPromise;309310await expect(page.getByRole('heading', { level: 1 })).toContainText('Item');311});312313test('nested router-outlet renders children', async ({ page }) => {314await page.goto('/account/profile');315316await expect(page.getByRole('heading', { name: 'Account' })).toBeVisible();317await expect(page.getByRole('heading', { name: 'Profile', level: 2 })).toBeVisible();318319await page.getByRole('link', { name: 'Security' }).click();320await page.waitForURL('/account/security');321322await expect(page.getByRole('heading', { name: 'Account' })).toBeVisible();323await expect(page.getByRole('heading', { name: 'Security', level: 2 })).toBeVisible();324});325326test('query parameters drive filters', async ({ page }) => {327await page.goto('/products?type=hardware&page=3');328329await expect(page.getByRole('heading', { name: 'Hardware' })).toBeVisible();330await expect(page.getByText('Page 3')).toBeVisible();331});332333test('browser back navigates history', async ({ page }) => {334await page.goto('/');335await page.getByRole('link', { name: 'Products' }).click();336await page.waitForURL('/products');337await page.getByRole('link', { name: 'About' }).click();338await page.waitForURL('/about');339340await page.goBack();341await expect(page).toHaveURL(/\/products/);342343await page.goBack();344await expect(page).toHaveURL(/\/$/);345});346});347```348349## Lazy-Loaded Modules350351```typescript352test('lazy module loads without chunk errors', async ({ page }) => {353const consoleErrors: string[] = [];354page.on('console', (msg) => {355if (msg.type() === 'error') consoleErrors.push(msg.text());356});357358await page.goto('/');359360const chunkRequest = page.waitForResponse((r) =>361r.url().includes('.js') && r.status() === 200362);363await page.getByRole('link', { name: 'Analytics' }).click();364await chunkRequest;365366await page.waitForURL('/analytics');367await expect(page.getByRole('heading', { name: 'Analytics' })).toBeVisible();368369const chunkErrors = consoleErrors.filter(370(e) => e.includes('ChunkLoadError') || e.includes('Loading chunk')371);372expect(chunkErrors).toEqual([]);373});374```375376## Signals and Observables377378Playwright cannot subscribe to observables or read signals directly. Test through the rendered output.379380```typescript381test.describe('signals through UI', () => {382test('signal-based counter updates DOM', async ({ page }) => {383await page.goto('/counter');384385await expect(page.getByTestId('value')).toHaveText('0');386387await page.getByRole('button', { name: 'Increment' }).click();388await expect(page.getByTestId('value')).toHaveText('1');389390await page.getByRole('button', { name: 'Reset' }).click();391await expect(page.getByTestId('value')).toHaveText('0');392});393394test('computed signal updates derived values', async ({ page }) => {395await page.goto('/cart');396await expect(page.getByTestId('total')).toHaveText('$0.00');397398await page.goto('/catalog');399await page.getByRole('listitem')400.filter({ hasText: '$19.99' })401.getByRole('button', { name: 'Add' })402.click();403404await page.getByRole('link', { name: 'Cart' }).click();405await expect(page.getByTestId('total')).toHaveText('$19.99');406});407});408409test.describe('observables through UI', () => {410test('debounced search batches API calls', async ({ page }) => {411await page.goto('/search');412413const apiCalls: string[] = [];414await page.route('**/api/search*', async (route) => {415apiCalls.push(route.request().url());416await route.continue();417});418419await page.getByRole('textbox', { name: 'Search' }).pressSequentially('playwright', {420delay: 50,421});422423await expect(page.getByRole('listitem')).toHaveCount(5);424expect(apiCalls.length).toBeLessThanOrEqual(2);425});426427test('switchMap cancels stale requests', async ({ page }) => {428await page.goto('/search');429430await page.getByRole('textbox', { name: 'Search' }).fill('initial');431await page.getByRole('textbox', { name: 'Search' }).fill('final');432433await expect(page.getByRole('listitem').first()).toContainText(/final/i);434});435});436```437438## Zone.js and Change Detection439440Angular uses Zone.js for change detection. Playwright does not depend on Zone.js and interacts with the DOM directly.441442- **Change detection timing**: After interactions, Angular schedules change detection via Zone.js. Playwright's auto-waiting handles this.443- **Zoneless Angular**: Angular 17+ supports zoneless change detection. Tests work identically since Playwright waits for DOM changes.444- **Long-running async**: `setInterval` or long-running observables keep Angular "not stable." This does not affect Playwright (unlike Protractor).445446## SSR Testing447448```typescript449// playwright.config.ts for SSR450webServer: {451command: process.env.CI452? 'npx ng build --ssr && node dist/my-app/server/server.mjs'453: 'npx ng serve --ssr',454url: 'http://localhost:4200',455reuseExistingServer: !process.env.CI,456timeout: 180_000,457},458```459460```typescript461test('no hydration errors', async ({ page }) => {462const errors: string[] = [];463page.on('console', (msg) => {464if (msg.type() === 'error' && msg.text().includes('hydration')) {465errors.push(msg.text());466}467});468469await page.goto('/');470await page.getByRole('button', { name: 'Get started' }).click();471472expect(errors).toEqual([]);473});474```475476## Protractor Migration Reference477478| Protractor | Playwright |479|---|---|480| `element(by.css('.btn'))` | `page.getByRole('button', { name: '...' })` |481| `element(by.id('login'))` | `page.getByTestId('login')` |482| `element(by.buttonText('Submit'))` | `page.getByRole('button', { name: 'Submit' })` |483| `element(by.model('user.name'))` | `page.getByLabel('Name')` |484| `element(by.binding('user.name'))` | `page.getByText(expectedValue)` |485| `element(by.repeater('item in items'))` | `page.getByRole('listitem')` |486| `browser.waitForAngular()` | Not needed — Playwright auto-waits |487| `browser.sleep(3000)` | `await expect(locator).toBeVisible()` |488| `browser.get('/path')` | `await page.goto('/path')` |489| `protractor.ExpectedConditions` | `await expect(locator).toBeVisible()` |490491## Build Configurations492493| Scenario | Command | Notes |494|---|---|---|495| Local dev | `npx ng serve` | Fast rebuild, source maps |496| CI production | `npx ng build && npx http-server dist/app/browser -p 4200 -s` | Tests production bundle |497| CI SSR | `npx ng build --ssr && node dist/app/server/server.mjs` | Tests server-side rendering |498| Staging | No `webServer` | Point `baseURL` to staging URL |499500The `-s` flag on `http-server` enables SPA fallback for Angular Router.501502## CDK Overlay Container503504Angular Material and CDK render overlays (dialogs, menus, selects) in a special container outside the component tree. Playwright sees these as regular DOM elements:505506```typescript507const dialog = page.getByRole('dialog');508const menu = page.getByRole('menu');509const listbox = page.getByRole('listbox');510```511512## Anti-Patterns513514| Anti-Pattern | Problem | Solution |515|---|---|---|516| `page.locator('[_ngcontent-xyz]')` | Scoped style attributes change every build | Use `getByRole`, `getByLabel`, `getByTestId` |517| `page.locator('[ng-reflect-model]')` | Only exists in dev mode | Test rendered value: `expect(input).toHaveValue()` |518| `page.locator('app-my-component')` | Component selectors are implementation details | Target rendered content with semantic locators |519| `page.locator('.mat-mdc-button')` | Material classes change between versions | `page.getByRole('button', { name: '...' })` |520| `page.evaluate(() => window.ng)` | Not available in production builds | Test through the DOM |521| `await page.waitForTimeout(500)` | Zone.js timing varies | Use auto-retrying assertions |522| `browser.waitForAngular()` | Does not exist in Playwright | Remove entirely |523| `ng serve` in CI | Slower, includes debug code | Use `ng build && http-server` |524525## Related526527- [core/locators.md](../core/locators.md) — locator strategies for Angular Material528- [core/assertions-waiting.md](../core/assertions-waiting.md) — auto-waiting assertions529- [core/forms-validation.md](../testing-patterns/forms-validation.md) — form testing patterns530- [architecture/test-architecture.md](../architecture/test-architecture.md) — E2E vs unit tests with TestBed531