Back to Sanity

Angular Testing with Playwright

.agents/skills/playwright-best-practices/frameworks/angular.md

5.24.019.0 KB
Original Source

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

typescript
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

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

Package Scripts

json
{
  "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.

typescript
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.

typescript
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('[email protected]')
    await page.getByRole('button', {name: 'Remove email 1'}).click()

    await expect(emailInputs).toHaveCount(1)
    await expect(emailInputs.first()).toHaveValue('[email protected]')
  })

  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('[email protected]')
    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.

typescript
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

typescript
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

typescript
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.

typescript
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

typescript
// 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,
},
typescript
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

ProtractorPlaywright
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.ExpectedConditionsawait expect(locator).toBeVisible()

Build Configurations

ScenarioCommandNotes
Local devnpx ng serveFast rebuild, source maps
CI productionnpx ng build && npx http-server dist/app/browser -p 4200 -sTests production bundle
CI SSRnpx ng build --ssr && node dist/app/server/server.mjsTests server-side rendering
StagingNo webServerPoint 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:

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

Anti-Patterns

Anti-PatternProblemSolution
page.locator('[_ngcontent-xyz]')Scoped style attributes change every buildUse getByRole, getByLabel, getByTestId
page.locator('[ng-reflect-model]')Only exists in dev modeTest rendered value: expect(input).toHaveValue()
page.locator('app-my-component')Component selectors are implementation detailsTarget rendered content with semantic locators
page.locator('.mat-mdc-button')Material classes change between versionspage.getByRole('button', { name: '...' })
page.evaluate(() => window.ng)Not available in production buildsTest through the DOM
await page.waitForTimeout(500)Zone.js timing variesUse auto-retrying assertions
browser.waitForAngular()Does not exist in PlaywrightRemove entirely
ng serve in CISlower, includes debug codeUse ng build && http-server