Files
FancyWordle/.agents/skills/playwright-best-practices/frameworks/angular.md
T
2026-05-16 13:57:02 -04:00

18 KiB

Angular Testing with Playwright

Table of Contents

  1. Configuration
  2. Locator Strategies
  3. Reactive Forms
  4. Angular Material Components
  5. Router Navigation
  6. Lazy-Loaded Modules
  7. Signals and Observables
  8. Zone.js and Change Detection
  9. SSR Testing
  10. Protractor Migration Reference
  11. Build Configurations
  12. CDK Overlay Container
  13. Anti-Patterns
  14. Related

When to use: Testing Angular applications with reactive forms, Angular Material components, Router navigation, lazy-loaded modules, signals, observables, and Zone.js change detection. Prerequisites: core/configuration.md, core/locators.md

Configuration

Playwright Config

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/my-app/browser -p 4200 -s'
      : 'npx ng serve',
    url: 'http://localhost:4200',
    reuseExistingServer: !process.env.CI,
    timeout: 120_000,
  },
});

Project Structure

my-angular-app/
  src/
  e2e/
    tests/
      dashboard.spec.ts
      login.spec.ts
    fixtures/
      auth.fixture.ts
  playwright.config.ts
  angular.json

Package Scripts

{
  "scripts": {
    "e2e": "playwright test",
    "e2e:headed": "playwright test --headed",
    "e2e:debug": "playwright test --debug",
    "e2e:report": "playwright show-report"
  }
}

Locator Strategies

Angular generates internal attributes (_ngcontent-*, _nghost-*, ng-reflect-*) that change every build. Always use semantic locators.

test('use semantic locators for Angular apps', async ({ page }) => {
  await page.goto('/projects');

  // Role-based locators work with Angular Material and native HTML
  await page.getByRole('button', { name: 'New project' }).click();
  await expect(page.getByRole('heading', { name: 'Create Project' })).toBeVisible();

  // Label-based for form fields
  await page.getByLabel('Project title').fill('Alpha');

  // Test IDs for complex components without semantic roles
  const chart = page.getByTestId('metrics-chart');
  await expect(chart).toBeVisible();

  // Scope locators within component boundaries
  const projectTable = page.getByRole('table', { name: 'Projects' });
  const activeRow = projectTable.getByRole('row').filter({
    has: page.getByRole('cell', { name: 'Active' }),
  });
  await activeRow.getByRole('button', { name: 'Edit' }).click();
});

Reactive Forms

Playwright interacts with the rendered DOM, so reactive forms (FormGroup, FormControl, FormArray) are transparent.

test.describe('form validation', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/signup');
  });

  test('displays validation errors on blur', async ({ page }) => {
    const emailField = page.getByLabel('Email');
    await emailField.click();
    await emailField.blur();
    await expect(page.getByText('Email is required')).toBeVisible();

    await emailField.fill('invalid');
    await emailField.blur();
    await expect(page.getByText('Invalid email format')).toBeVisible();
  });

  test('validates password confirmation', async ({ page }) => {
    await page.getByLabel('Password', { exact: true }).fill('Secret123!');
    await page.getByLabel('Confirm password').fill('Mismatch');
    await page.getByLabel('Confirm password').blur();

    await expect(page.getByText('Passwords must match')).toBeVisible();

    await page.getByLabel('Confirm password').fill('Secret123!');
    await expect(page.getByText('Passwords must match')).toBeHidden();
  });

  test('handles FormArray add/remove', async ({ page }) => {
    await page.goto('/contacts/edit');

    await page.getByRole('button', { name: 'Add email' }).click();
    const emailInputs = page.getByLabel(/Email address/);
    await expect(emailInputs).toHaveCount(2);

    await emailInputs.nth(1).fill('backup@example.com');
    await page.getByRole('button', { name: 'Remove email 1' }).click();

    await expect(emailInputs).toHaveCount(1);
    await expect(emailInputs.first()).toHaveValue('backup@example.com');
  });

  test('disables submit until form is valid', async ({ page }) => {
    const submitBtn = page.getByRole('button', { name: 'Register' });
    await expect(submitBtn).toBeDisabled();

    await page.getByLabel('Name').fill('Alice');
    await page.getByLabel('Email').fill('alice@test.com');
    await page.getByLabel('Password', { exact: true }).fill('Secret123!');
    await page.getByLabel('Confirm password').fill('Secret123!');
    await page.getByLabel('Accept terms').check();

    await expect(submitBtn).toBeEnabled();
  });

  test('shows async validator loading state', async ({ page }) => {
    await page.route('**/api/username-check*', async (route) => {
      await new Promise((r) => setTimeout(r, 800));
      await route.fulfill({ json: { available: true } });
    });

    await page.getByLabel('Username').fill('alice');
    await page.getByLabel('Username').blur();

    await expect(page.getByTestId('username-loading')).toBeVisible();
    await expect(page.getByTestId('username-loading')).toBeHidden();
    await expect(page.getByText('Username available')).toBeVisible();
  });
});

Angular Material Components

Angular Material uses proper ARIA attributes. Use role-based locators instead of CSS classes like .mat-mdc-button.

test.describe('Material components', () => {
  test('mat-select dropdown', async ({ page }) => {
    await page.goto('/preferences');

    await page.getByRole('combobox', { name: 'Language' }).click();
    await page.getByRole('option', { name: 'Spanish' }).click();

    await expect(page.getByRole('combobox', { name: 'Language' })).toContainText('Spanish');
  });

  test('mat-autocomplete suggestions', async ({ page }) => {
    await page.goto('/members/add');

    const roleField = page.getByRole('combobox', { name: 'Role' });
    await roleField.fill('dev');

    await expect(page.getByRole('option', { name: 'Developer' })).toBeVisible();
    await expect(page.getByRole('option', { name: 'DevOps' })).toBeVisible();

    await page.getByRole('option', { name: 'Developer' }).click();
    await expect(roleField).toHaveValue('Developer');
  });

  test('mat-dialog interaction', async ({ page }) => {
    await page.goto('/items');

    await page.getByRole('button', { name: 'Remove item' }).first().click();

    const dialog = page.getByRole('dialog');
    await expect(dialog).toBeVisible();
    await expect(dialog.getByText('Confirm deletion?')).toBeVisible();

    await dialog.getByRole('button', { name: 'Cancel' }).click();
    await expect(dialog).toBeHidden();
  });

  test('mat-table sorting', async ({ page }) => {
    await page.goto('/members');

    await page.getByRole('columnheader', { name: 'Name' }).click();
    const header = page.getByRole('columnheader', { name: 'Name' });
    await expect(header).toHaveAttribute('aria-sort', 'ascending');

    await page.getByRole('columnheader', { name: 'Name' }).click();
    await expect(header).toHaveAttribute('aria-sort', 'descending');
  });

  test('mat-paginator navigation', async ({ page }) => {
    await page.goto('/members');

    await expect(page.getByText('1 - 10 of 100')).toBeVisible();

    await page.getByRole('button', { name: 'Next page' }).click();
    await expect(page.getByText('11 - 20 of 100')).toBeVisible();

    await page.getByRole('combobox', { name: 'Items per page' }).click();
    await page.getByRole('option', { name: '50' }).click();
    await expect(page.getByText('1 - 50 of 100')).toBeVisible();
  });

  test('mat-snack-bar notification', async ({ page }) => {
    await page.goto('/preferences');

    await page.getByRole('button', { name: 'Save' }).click();
    await expect(page.getByText('Changes saved')).toBeVisible();

    await page.getByRole('button', { name: 'Close' }).click();
    await expect(page.getByText('Changes saved')).toBeHidden();
  });

  test('mat-stepper wizard', async ({ page }) => {
    await page.goto('/wizard');

    await expect(page.getByText('Step 1 of 3')).toBeVisible();
    await page.getByLabel('Name').fill('Bob');
    await page.getByRole('button', { name: 'Next' }).click();

    await expect(page.getByText('Step 2 of 3')).toBeVisible();
    await page.getByLabel('Organization').fill('Acme');
    await page.getByRole('button', { name: 'Next' }).click();

    await expect(page.getByText('Step 3 of 3')).toBeVisible();
    await expect(page.getByText('Bob')).toBeVisible();
    await expect(page.getByText('Acme')).toBeVisible();
  });
});

Router Navigation

test.describe('Angular Router', () => {
  test('lazy-loaded module loads on navigation', async ({ page }) => {
    await page.goto('/');

    await page.getByRole('link', { name: 'Reports' }).click();
    await page.waitForURL('/reports');

    await expect(page.getByRole('heading', { name: 'Reports Dashboard' })).toBeVisible();
  });

  test('route guard redirects unauthorized users', async ({ page }) => {
    await page.goto('/admin/settings');

    await expect(page).toHaveURL(/\/login/);
    await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible();
  });

  test('resolver prefetches data', async ({ page }) => {
    const resolverPromise = page.waitForResponse('**/api/items/*');
    await page.goto('/items/42');
    await resolverPromise;

    await expect(page.getByRole('heading', { level: 1 })).toContainText('Item');
  });

  test('nested router-outlet renders children', async ({ page }) => {
    await page.goto('/account/profile');

    await expect(page.getByRole('heading', { name: 'Account' })).toBeVisible();
    await expect(page.getByRole('heading', { name: 'Profile', level: 2 })).toBeVisible();

    await page.getByRole('link', { name: 'Security' }).click();
    await page.waitForURL('/account/security');

    await expect(page.getByRole('heading', { name: 'Account' })).toBeVisible();
    await expect(page.getByRole('heading', { name: 'Security', level: 2 })).toBeVisible();
  });

  test('query parameters drive filters', async ({ page }) => {
    await page.goto('/products?type=hardware&page=3');

    await expect(page.getByRole('heading', { name: 'Hardware' })).toBeVisible();
    await expect(page.getByText('Page 3')).toBeVisible();
  });

  test('browser back navigates 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(/\/$/);
  });
});

Lazy-Loaded Modules

test('lazy module loads without chunk errors', async ({ page }) => {
  const consoleErrors: string[] = [];
  page.on('console', (msg) => {
    if (msg.type() === 'error') consoleErrors.push(msg.text());
  });

  await page.goto('/');

  const chunkRequest = page.waitForResponse((r) =>
    r.url().includes('.js') && r.status() === 200
  );
  await page.getByRole('link', { name: 'Analytics' }).click();
  await chunkRequest;

  await page.waitForURL('/analytics');
  await expect(page.getByRole('heading', { name: 'Analytics' })).toBeVisible();

  const chunkErrors = consoleErrors.filter(
    (e) => e.includes('ChunkLoadError') || e.includes('Loading chunk')
  );
  expect(chunkErrors).toEqual([]);
});

Signals and Observables

Playwright cannot subscribe to observables or read signals directly. Test through the rendered output.

test.describe('signals through UI', () => {
  test('signal-based counter updates DOM', async ({ page }) => {
    await page.goto('/counter');

    await expect(page.getByTestId('value')).toHaveText('0');

    await page.getByRole('button', { name: 'Increment' }).click();
    await expect(page.getByTestId('value')).toHaveText('1');

    await page.getByRole('button', { name: 'Reset' }).click();
    await expect(page.getByTestId('value')).toHaveText('0');
  });

  test('computed signal updates derived values', async ({ page }) => {
    await page.goto('/cart');
    await expect(page.getByTestId('total')).toHaveText('$0.00');

    await page.goto('/catalog');
    await page.getByRole('listitem')
      .filter({ hasText: '$19.99' })
      .getByRole('button', { name: 'Add' })
      .click();

    await page.getByRole('link', { name: 'Cart' }).click();
    await expect(page.getByTestId('total')).toHaveText('$19.99');
  });
});

test.describe('observables through UI', () => {
  test('debounced search batches API calls', async ({ page }) => {
    await page.goto('/search');

    const apiCalls: string[] = [];
    await page.route('**/api/search*', async (route) => {
      apiCalls.push(route.request().url());
      await route.continue();
    });

    await page.getByRole('textbox', { name: 'Search' }).pressSequentially('playwright', {
      delay: 50,
    });

    await expect(page.getByRole('listitem')).toHaveCount(5);
    expect(apiCalls.length).toBeLessThanOrEqual(2);
  });

  test('switchMap cancels stale requests', async ({ page }) => {
    await page.goto('/search');

    await page.getByRole('textbox', { name: 'Search' }).fill('initial');
    await page.getByRole('textbox', { name: 'Search' }).fill('final');

    await expect(page.getByRole('listitem').first()).toContainText(/final/i);
  });
});

Zone.js and Change Detection

Angular uses Zone.js for change detection. Playwright does not depend on Zone.js and interacts with the DOM directly.

  • Change detection timing: After interactions, Angular schedules change detection via Zone.js. Playwright's auto-waiting handles this.
  • Zoneless Angular: Angular 17+ supports zoneless change detection. Tests work identically since Playwright waits for DOM changes.
  • Long-running async: setInterval or long-running observables keep Angular "not stable." This does not affect Playwright (unlike Protractor).

SSR Testing

// playwright.config.ts for SSR
webServer: {
  command: process.env.CI
    ? 'npx ng build --ssr && node dist/my-app/server/server.mjs'
    : 'npx ng serve --ssr',
  url: 'http://localhost:4200',
  reuseExistingServer: !process.env.CI,
  timeout: 180_000,
},
test('no hydration errors', 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([]);
});

Protractor Migration Reference

Protractor Playwright
element(by.css('.btn')) page.getByRole('button', { name: '...' })
element(by.id('login')) page.getByTestId('login')
element(by.buttonText('Submit')) page.getByRole('button', { name: 'Submit' })
element(by.model('user.name')) page.getByLabel('Name')
element(by.binding('user.name')) page.getByText(expectedValue)
element(by.repeater('item in items')) page.getByRole('listitem')
browser.waitForAngular() Not needed — Playwright auto-waits
browser.sleep(3000) await expect(locator).toBeVisible()
browser.get('/path') await page.goto('/path')
protractor.ExpectedConditions await expect(locator).toBeVisible()

Build Configurations

Scenario Command Notes
Local dev npx ng serve Fast rebuild, source maps
CI production npx ng build && npx http-server dist/app/browser -p 4200 -s Tests production bundle
CI SSR npx ng build --ssr && node dist/app/server/server.mjs Tests server-side rendering
Staging No webServer Point baseURL to staging URL

The -s flag on http-server enables SPA fallback for Angular Router.

CDK Overlay Container

Angular Material and CDK render overlays (dialogs, menus, selects) in a special container outside the component tree. Playwright sees these as regular DOM elements:

const dialog = page.getByRole('dialog');
const menu = page.getByRole('menu');
const listbox = page.getByRole('listbox');

Anti-Patterns

Anti-Pattern Problem Solution
page.locator('[_ngcontent-xyz]') Scoped style attributes change every build Use getByRole, getByLabel, getByTestId
page.locator('[ng-reflect-model]') Only exists in dev mode Test rendered value: expect(input).toHaveValue()
page.locator('app-my-component') Component selectors are implementation details Target rendered content with semantic locators
page.locator('.mat-mdc-button') Material classes change between versions page.getByRole('button', { name: '...' })
page.evaluate(() => window.ng) Not available in production builds Test through the DOM
await page.waitForTimeout(500) Zone.js timing varies Use auto-retrying assertions
browser.waitForAngular() Does not exist in Playwright Remove entirely
ng serve in CI Slower, includes debug code Use ng build && http-server