E2E Testing Angular Apps with Playwright: Setup, Component Harnesses vs Playwright Selectors

E2E Testing Angular Apps with Playwright: Setup, Component Harnesses vs Playwright Selectors

Playwright is the recommended E2E testing framework for Angular applications in 2026, replacing the deprecated Protractor. It supports auto-waiting, multi-browser testing, network interception, and trace capture out of the box. This guide covers adding Playwright to an Angular project, writing effective E2E tests, and deciding when to use Angular CDK component harnesses vs Playwright's own locator API.

Key Takeaways

Playwright auto-waits for elements. You rarely need explicit waitForSelector() calls. page.getByRole('button') retries until the button appears, is visible, and is enabled.

Use getByRole and getByLabel over CSS selectors. Role-based locators match what screen readers see. They're more stable than .some-class selectors that change with refactoring.

Angular component harnesses work inside Playwright. You can use HarnessLoader to query Angular Material components by their semantic meaning (MatButtonHarness, MatInputHarness) rather than their internal DOM structure.

Page Object Model prevents test maintenance hell. When your app's structure changes, you want to update one file, not 20 test files. Build POM classes for each significant page or feature area.

ng serve + webServer in Playwright config starts your app automatically. No manual setup before running E2E tests — Playwright handles it.

Setting Up Playwright with Angular

Installation

npm install --save-dev @playwright/test
npx playwright install  # downloads browsers

playwright.config.ts

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

export default defineConfig({
  testDir: './e2e',
  timeout: 30000,
  retries: process.env.CI ? 2 : 0,
  reporter: process.env.CI ? 'github' : 'html',
  
  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: 'ng serve --configuration=test',
    url: 'http://localhost:4200',
    reuseExistingServer: !process.env.CI,
    timeout: 120000,
  },
});

The webServer block starts ng serve before running tests and stops it after. In CI, reuseExistingServer: false ensures a fresh build every run.

Writing E2E Tests

Basic Navigation and Assertions

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

test('home page loads with correct title', async ({ page }) => {
  await page.goto('/');
  
  await expect(page).toHaveTitle(/My Angular App/);
  await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
});

User Flow Tests

// e2e/auth.spec.ts
test.describe('Authentication', () => {
  test('user can log in with valid credentials', async ({ page }) => {
    await page.goto('/login');

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

    await expect(page).toHaveURL('/dashboard');
    await expect(page.getByTestId('user-greeting')).toContainText('Alice');
  });

  test('shows error for invalid credentials', async ({ page }) => {
    await page.goto('/login');

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

    await expect(page.getByRole('alert')).toBeVisible();
    await expect(page.getByRole('alert')).toContainText('Invalid credentials');
    await expect(page).toHaveURL('/login');  // stays on login page
  });
});

CRUD Flow Test

// e2e/products.spec.ts
test.describe('Product Management', () => {
  test.beforeEach(async ({ page }) => {
    // Authenticate before each test
    await page.goto('/login');
    await page.getByLabel('Email').fill('admin@example.com');
    await page.getByLabel('Password').fill('admin-password');
    await page.getByRole('button', { name: 'Sign In' }).click();
    await expect(page).toHaveURL('/dashboard');
  });

  test('creates a new product', async ({ page }) => {
    await page.goto('/products');
    
    await page.getByRole('button', { name: 'Add Product' }).click();
    
    await page.getByLabel('Product Name').fill('Test Widget');
    await page.getByLabel('Price').fill('19.99');
    await page.getByLabel('Category').selectOption('Electronics');
    
    await page.getByRole('button', { name: 'Save' }).click();
    
    await expect(page.getByRole('alert', { name: 'success' })).toBeVisible();
    await expect(page.getByText('Test Widget')).toBeVisible();
  });

  test('deletes a product', async ({ page }) => {
    await page.goto('/products');
    
    const productRow = page.getByRole('row').filter({ hasText: 'Test Widget' });
    await productRow.getByRole('button', { name: 'Delete' }).click();
    
    // Confirm dialog
    await page.getByRole('button', { name: 'Confirm Delete' }).click();
    
    await expect(page.getByText('Test Widget')).not.toBeVisible();
  });
});

Angular CDK Component Harnesses

Angular Material components have complex internal DOM. A mat-select dropdown is not a native <select> — it has overlays, panels, and custom ARIA. Clicking the native element doesn't open it the way a user would.

CDK Harnesses solve this by providing a semantic API for Angular Material components:

npm install --save-dev @angular/cdk
import { test, expect } from '@playwright/test';
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { MatSelectHarness } from '@angular/material/select/testing';
import { MatInputHarness } from '@angular/material/input/testing';

However, CDK harnesses are designed for TestBed tests, not Playwright. For Playwright, the recommended approach is using data-testid attributes or role-based selectors that work through the Angular Material overlay system.

Practical: Testing Angular Material Components in Playwright

// For mat-select, click the trigger and wait for the panel
test('filters products by category', async ({ page }) => {
  await page.goto('/products');

  // Angular Material select: click the trigger
  await page.getByLabel('Category Filter').click();
  
  // Panel appears in an overlay
  await page.getByRole('option', { name: 'Electronics' }).click();
  
  // Verify filter applied
  await expect(page.getByTestId('product-row')).toHaveCount(3);
  await expect(page.getByTestId('product-row').first()).toContainText('Electronics');
});

// For mat-datepicker
test('filters by date range', async ({ page }) => {
  await page.goto('/reports');

  await page.getByLabel('Start Date').fill('05/01/2026');
  await page.keyboard.press('Tab');  // close the date picker
  
  await page.getByLabel('End Date').fill('05/31/2026');
  await page.keyboard.press('Tab');
  
  await page.getByRole('button', { name: 'Apply' }).click();
  
  await expect(page.getByTestId('report-results')).toBeVisible();
});

Page Object Model

Maintain one file per page to centralize selectors and interactions:

// e2e/pages/products.page.ts
import { Page, Locator } from '@playwright/test';

export class ProductsPage {
  readonly page: Page;
  readonly addButton: Locator;
  readonly productRows: Locator;
  readonly successAlert: Locator;

  constructor(page: Page) {
    this.page = page;
    this.addButton = page.getByRole('button', { name: 'Add Product' });
    this.productRows = page.getByTestId('product-row');
    this.successAlert = page.getByRole('alert', { name: 'success' });
  }

  async navigate() {
    await this.page.goto('/products');
  }

  async createProduct(name: string, price: string) {
    await this.addButton.click();
    await this.page.getByLabel('Product Name').fill(name);
    await this.page.getByLabel('Price').fill(price);
    await this.page.getByRole('button', { name: 'Save' }).click();
  }

  async deleteProduct(name: string) {
    const row = this.productRows.filter({ hasText: name });
    await row.getByRole('button', { name: 'Delete' }).click();
    await this.page.getByRole('button', { name: 'Confirm Delete' }).click();
  }

  async getProductCount() {
    return this.productRows.count();
  }
}
// e2e/products.spec.ts
import { test, expect } from '@playwright/test';
import { ProductsPage } from './pages/products.page';

test('creates and deletes a product', async ({ page }) => {
  const productsPage = new ProductsPage(page);
  
  await productsPage.navigate();
  const initialCount = await productsPage.getProductCount();
  
  await productsPage.createProduct('Test Widget', '9.99');
  
  await expect(productsPage.successAlert).toBeVisible();
  await expect(productsPage.productRows).toHaveCount(initialCount + 1);
  
  await productsPage.deleteProduct('Test Widget');
  
  await expect(productsPage.productRows).toHaveCount(initialCount);
});

Network Interception

test('handles API errors gracefully', async ({ page }) => {
  await page.route('/api/products', route => {
    route.fulfill({
      status: 503,
      body: JSON.stringify({ error: 'Service unavailable' }),
    });
  });

  await page.goto('/products');

  await expect(page.getByTestId('error-banner')).toBeVisible();
  await expect(page.getByTestId('error-banner')).toContainText('Unable to load products');
});

test('shows cached data when API is slow', async ({ page }) => {
  await page.route('/api/products', async route => {
    await new Promise(r => setTimeout(r, 5000));  // simulate slow API
    route.continue();
  });

  await page.goto('/products');
  
  // Should show cached/skeleton state
  await expect(page.getByTestId('loading-skeleton')).toBeVisible();
});

CI Configuration

# .github/workflows/e2e.yml
- name: Install Playwright browsers
  run: npx playwright install --with-deps

- name: Run E2E tests
  run: npx playwright test --reporter=github
  env:
    CI: true

- name: Upload traces on failure
  if: failure()
  uses: actions/upload-artifact@v3
  with:
    name: playwright-traces
    path: playwright-report/

Running Playwright

npx playwright test                       <span class="hljs-comment"># all tests
npx playwright <span class="hljs-built_in">test e2e/auth.spec.ts      <span class="hljs-comment"># specific file
npx playwright <span class="hljs-built_in">test --headed              <span class="hljs-comment"># with browser visible
npx playwright <span class="hljs-built_in">test --debug               <span class="hljs-comment"># pause on each step
npx playwright show-report                <span class="hljs-comment"># view HTML report
npx playwright <span class="hljs-built_in">test --project=chromium    <span class="hljs-comment"># specific browser only

What E2E Tests Don't Replace

E2E tests verify user flows in a controlled test environment. They're slower than unit tests, require a running application, and can be flaky under load. Use them for critical paths (auth, checkout, core features) — not every edge case.

For production monitoring — the same flows running 24/7 against your live application — HelpMeTest is built for exactly that. Start free with 10 monitored tests.

Read more