Angular E2E Testing with Playwright (Replacing Protractor)

Angular E2E Testing with Playwright (Replacing Protractor)

Protractor reached end-of-life in 2023. If you're still running it, you're on borrowed time — unmaintained dependencies, no Chromium updates, and no support for modern Angular features. Playwright is the replacement worth betting on: faster, more reliable, and actively maintained by Microsoft. This guide covers the full migration path plus Angular-specific patterns you won't find in generic Playwright docs.

Why Playwright Over Cypress for Angular

Both work. Playwright has a few advantages that matter at scale:

  • Multi-tab and multi-browser: Test OAuth flows, popup windows, and cross-tab communication natively
  • True parallelism: Tests run across multiple workers without extra configuration
  • Network interception: Intercept and mock API responses at the browser level — no Angular-specific plumbing needed
  • Auto-wait: Playwright waits for elements to be actionable by default, reducing flake caused by Angular's async rendering

The tradeoff is a steeper learning curve and less Angular-specific tooling. You'll write slightly more code than you would with Cypress Angular Component Testing, but the test runner itself is more capable.

Installing and Configuring Playwright

npm init playwright@latest

This scaffolds playwright.config.ts, a tests/ directory, and a tests-examples/ folder you can delete. Minimal Angular-aware config:

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  forbidOnly: !!process.env['CI'],
  retries: process.env['CI'] ? 2 : 0,
  workers: process.env['CI'] ? 1 : undefined,
  reporter: [
    ['html'],
    ['junit', { outputFile: 'test-results/junit.xml' }],
  ],
  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'] },
    },
  ],
  webServer: {
    command: 'ng serve',
    url: 'http://localhost:4200',
    reuseExistingServer: !process.env['CI'],
    timeout: 120 * 1000,
  },
});

The webServer block starts ng serve before tests run and shuts it down after. On CI, it starts fresh every time. Locally, it reuses an already-running dev server.

Migrating from Protractor

Protractor used Selenium WebDriver and Angular-specific waiting mechanisms (browser.waitForAngular()). Playwright handles Angular's async automatically — there's no equivalent needed.

Protractor (old):

// protractor
it('should display the title', async () => {
  await browser.get('/');
  const title = element(by.css('h1'));
  expect(await title.getText()).toEqual('My Angular App');
});

it('should navigate to about page', async () => {
  await element(by.linkText('About')).click();
  await browser.waitForAngular();
  expect(await browser.getCurrentUrl()).toContain('/about');
});

Playwright (new):

// playwright
import { test, expect } from '@playwright/test';

test('displays the title', async ({ page }) => {
  await page.goto('/');
  await expect(page.locator('h1')).toHaveText('My Angular App');
});

test('navigates to about page', async ({ page }) => {
  await page.goto('/');
  await page.getByRole('link', { name: 'About' }).click();
  await expect(page).toHaveURL(/\/about/);
});

Key differences:

  • element(by.css(...))page.locator('...') or page.getByRole(...)
  • by.linkText(...)page.getByRole('link', { name: '...' })
  • No waitForAngular() needed — Playwright waits for network idle and element stability automatically
  • browser.getCurrentUrl()page.url() or expect(page).toHaveURL(...)

Angular-Specific Playwright Patterns

Waiting for Change Detection

Angular's change detection runs asynchronously. After clicking a button that triggers data loading, wait for the expected outcome rather than for a fixed time:

test('loads products after clicking the refresh button', async ({ page }) => {
  await page.goto('/products');

  // Wait for initial load
  await expect(page.locator('[data-testid="product-list"]')).toBeVisible();

  // Click refresh
  await page.getByRole('button', { name: 'Refresh' }).click();

  // Wait for loading indicator to disappear
  await expect(page.locator('[data-testid="loading-spinner"]')).toBeHidden();

  // Assert the result
  await expect(page.locator('[data-testid="product-item"]')).toHaveCount(5);
});

Never use page.waitForTimeout(2000). It's a smell that indicates you're not properly waiting for application state. Use toBeVisible(), toBeHidden(), toHaveText(), or waitForResponse() instead.

Testing Angular Router

test('navigates using router links', async ({ page }) => {
  await page.goto('/');

  await page.getByRole('link', { name: 'Products' }).click();
  await expect(page).toHaveURL('/products');
  await expect(page.locator('h1')).toHaveText('Products');
});

test('handles browser back/forward navigation', async ({ page }) => {
  await page.goto('/products');
  await page.locator('[data-testid="product-1"]').click();
  await expect(page).toHaveURL('/products/1');

  await page.goBack();
  await expect(page).toHaveURL('/products');

  await page.goForward();
  await expect(page).toHaveURL('/products/1');
});

test('redirects unauthenticated users to login', async ({ page }) => {
  // Ensure no auth token in storage
  await page.goto('/dashboard');
  await expect(page).toHaveURL('/login');
  await expect(page.locator('[data-testid="login-form"]')).toBeVisible();
});

test('preserves returnUrl after login redirect', async ({ page }) => {
  await page.goto('/account/settings');
  await expect(page).toHaveURL(/\/login\?returnUrl/);

  await page.getByLabel('Email').fill('user@example.com');
  await page.getByLabel('Password').fill('password123');
  await page.getByRole('button', { name: 'Sign In' }).click();

  await expect(page).toHaveURL('/account/settings');
});

Testing Lazy-Loaded Modules

Lazy-loaded routes load JavaScript bundles on demand. Playwright handles this automatically, but you need to wait for the navigation to complete:

test('lazy-loaded admin module loads correctly', async ({ page }) => {
  await page.goto('/');

  // Track network activity to detect lazy chunk loading
  const adminChunkPromise = page.waitForResponse(
    (response) => response.url().includes('admin') && response.status() === 200
  );

  await page.getByRole('link', { name: 'Admin' }).click();

  await adminChunkPromise;
  await expect(page).toHaveURL('/admin');
  await expect(page.locator('[data-testid="admin-dashboard"]')).toBeVisible();
});

test('lazy module shows loading indicator during bundle fetch', async ({ page }) => {
  // Throttle network to slow down chunk loading
  await page.route('**/*.js', async (route) => {
    await new Promise((resolve) => setTimeout(resolve, 500));
    await route.continue();
  });

  await page.goto('/');
  await page.getByRole('link', { name: 'Admin' }).click();

  // Loading state should be visible briefly
  await expect(page.locator('[data-testid="route-loading"]')).toBeVisible();
  await expect(page.locator('[data-testid="admin-dashboard"]')).toBeVisible();
});

Testing Angular Material Components

Angular Material uses Shadow DOM in some components and custom aria attributes throughout. Use role-based selectors to stay resilient:

// mat-select
test('selects an option from mat-select', async ({ page }) => {
  await page.goto('/filter');

  // Open the select
  await page.locator('mat-select[data-testid="category-select"]').click();

  // The overlay panel appears in the document body, not inside the component
  await page.locator('mat-option', { hasText: 'Electronics' }).click();

  // Verify selection
  await expect(
    page.locator('mat-select[data-testid="category-select"] .mat-select-value-text')
  ).toHaveText('Electronics');
});

// mat-dialog
test('opens and submits a Material dialog', async ({ page }) => {
  await page.goto('/users');
  await page.getByRole('button', { name: 'Add User' }).click();

  // Dialog is rendered in an overlay — query the body
  const dialog = page.locator('mat-dialog-container');
  await expect(dialog).toBeVisible();

  await dialog.getByLabel('Name').fill('Bob Smith');
  await dialog.getByLabel('Email').fill('bob@example.com');
  await dialog.getByRole('button', { name: 'Save' }).click();

  await expect(dialog).toBeHidden();
  await expect(page.locator('[data-testid="user-bob-smith"]')).toBeVisible();
});

// mat-table with pagination
test('paginates through mat-table results', async ({ page }) => {
  await page.goto('/products');

  // Verify initial page
  await expect(page.locator('mat-row')).toHaveCount(10);

  // Navigate to next page
  await page.locator('button[aria-label="Next page"]').click();

  // Wait for table to update
  await expect(page.locator('mat-row').first()).not.toHaveText('Product 1');
  await expect(page.locator('mat-paginator .mat-paginator-range-label')).toHaveText(
    '11 – 20 of 47'
  );
});

// mat-autocomplete
test('searches with mat-autocomplete', async ({ page }) => {
  await page.goto('/search');

  await page.getByRole('combobox', { name: 'Search products' }).fill('widget');

  // Wait for autocomplete panel
  await expect(page.locator('mat-autocomplete')).toBeVisible();
  await expect(page.locator('mat-option')).toHaveCount(3);

  await page.locator('mat-option').first().click();
  await expect(page.locator('[data-testid="search-results"]')).toBeVisible();
});

Material overlays (dialogs, selects, autocomplete panels, menus) render outside the component in document.body. Don't try to scope your locators to the triggering element — query the overlay directly.

Mocking API Responses

Playwright's network interception is essential for testing loading states, error scenarios, and edge cases without a fully working backend:

test('shows error state when API returns 500', async ({ page }) => {
  await page.route('/api/products', (route) => {
    route.fulfill({
      status: 500,
      contentType: 'application/json',
      body: JSON.stringify({ error: 'Internal server error' }),
    });
  });

  await page.goto('/products');
  await expect(page.locator('[data-testid="error-message"]')).toBeVisible();
  await expect(page.locator('[data-testid="error-message"]')).toHaveText(
    'Failed to load products'
  );
});

test('shows empty state when API returns no products', async ({ page }) => {
  await page.route('/api/products', (route) => {
    route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify([]),
    });
  });

  await page.goto('/products');
  await expect(page.locator('[data-testid="empty-state"]')).toBeVisible();
});

Authentication State Management

Managing auth state across tests is the most common source of test flake. Create auth state once and reuse it:

// e2e/auth.setup.ts
import { test as setup } from '@playwright/test';
import path from 'path';

const authFile = path.join(__dirname, '../.playwright/user.json');

setup('authenticate', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill(process.env['TEST_USER_EMAIL']!);
  await page.getByLabel('Password').fill(process.env['TEST_USER_PASSWORD']!);
  await page.getByRole('button', { name: 'Sign In' }).click();
  await expect(page).toHaveURL('/dashboard');

  // Save auth state (cookies + localStorage)
  await page.context().storageState({ path: authFile });
});

// playwright.config.ts — reference the setup project
export default defineConfig({
  projects: [
    { name: 'setup', testMatch: /.*\.setup\.ts/ },
    {
      name: 'authenticated',
      use: {
        storageState: '.playwright/user.json',
      },
      dependencies: ['setup'],
    },
    {
      name: 'unauthenticated',
      // no storageState — tests run as anonymous user
    },
  ],
});

// e2e/dashboard.spec.ts
import { test, expect } from '@playwright/test';

// This test uses the 'authenticated' project — auth state is injected automatically
test('authenticated user can access dashboard', async ({ page }) => {
  await page.goto('/dashboard');
  await expect(page).toHaveURL('/dashboard'); // no redirect to login
  await expect(page.locator('[data-testid="user-greeting"]')).toBeVisible();
});

CI Setup

# .github/workflows/e2e.yml
name: E2E Tests

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Install Playwright browsers
        run: npx playwright install --with-deps chromium

      - name: Build Angular app
        run: npm run build -- --configuration=test

      - name: Run E2E tests
        run: npx playwright test
        env:
          TEST_USER_EMAIL: ${{ secrets.TEST_USER_EMAIL }}
          TEST_USER_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }}

      - name: Upload test results
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 30

Run against the built production bundle (ng build) on CI rather than ng serve. This catches issues with build optimization — tree-shaking removing something still needed at runtime, AOT compilation errors — that wouldn't appear in development mode.

Test Organization

Structure tests around user flows, not component boundaries:

e2e/
  auth.setup.ts          # auth state setup (runs once)
  auth/
    login.spec.ts         # login flow
    password-reset.spec.ts
  products/
    browse.spec.ts        # listing, filtering, pagination
    product-detail.spec.ts
    checkout.spec.ts      # complete purchase flow
  admin/
    user-management.spec.ts
    reports.spec.ts

Each spec file tests a complete user scenario. Avoid one-assertion-per-test — E2E tests are expensive to run, so make each one verify a meaningful workflow from start to finish.


Playwright gives Angular teams a solid foundation for E2E testing. But writing and maintaining Playwright tests takes time, and they can still be blocked by infrastructure — running browsers in CI, managing auth state, handling flake in parallel runs.

HelpMeTest is the alternative when you want E2E coverage without the maintenance overhead. Write your test scenarios in plain English, and HelpMeTest runs them continuously against your deployed app — no Playwright config, no browser management, no flaky CI jobs to debug.

Read more

Start now free