# Testing Angular Apps with Playwright > **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](configuration.md), [core/locators.md](locators.md) ## Quick Reference ```bash # 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** ```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** ```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`: ```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** ```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** ```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** ```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** ```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 = {} 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** ```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** ```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** ```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** ```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** ```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** ```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** ```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** ```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** ```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: 1. **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. 2. **Zoneless Angular (experimental)**: Angular 17+ supports zoneless change detection. Tests work identically with Playwright because Playwright waits for DOM changes, not Zone.js ticks. 3. **Long-running async operations**: If your app has `setInterval` or 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: ```typescript // CDK overlays render into
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: ```typescript // 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: ```typescript 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](locators.md) -- locator strategies for Angular Material and CDK components - [core/assertions-and-waiting.md](assertions-and-waiting.md) -- auto-waiting assertions that replace Protractor's waitForAngular - [core/forms-and-validation.md](forms-and-validation.md) -- form testing patterns for reactive and template-driven forms - [core/accessibility.md](accessibility.md) -- accessibility testing for Angular Material components - [core/authentication.md](authentication.md) -- authentication with Angular route guards - [migration/from-selenium.md](../migration/from-selenium.md) -- migration patterns applicable to Protractor (Protractor is built on Selenium) - [core/test-architecture.md](test-architecture.md) -- when to use E2E vs unit tests with Angular TestBed - [ci/ci-github-actions.md](../ci/ci-github-actions.md) -- CI setup with Angular build caching