Skip to content

Latest commit

 

History

History
1086 lines (833 loc) · 39.2 KB

File metadata and controls

1086 lines (833 loc) · 39.2 KB

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, 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:

  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:

// 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