Playwright End-to-End Testing

AI Evaluation & Safety intermediate Building with AI ~25 min read

Overview

End-to-end (E2E) testing verifies that an entire application works correctly from the user’s perspective — clicking buttons, filling forms, navigating between pages, and validating that the right data appears on screen. Unlike unit tests (which verify individual functions) or integration tests (which verify component interactions), E2E tests exercise the full stack: browser, frontend, API layer, and database. When an E2E test passes, you have high confidence that the feature actually works for a real user.

Playwright is Microsoft’s open-source browser automation framework that drives Chromium, Firefox, and WebKit with a single API. It was built by the team that originally created Puppeteer, and addresses many of Puppeteer’s limitations: multi-browser support, auto-waiting for elements, network interception, and a built-in test runner with parallel execution, tracing, and visual comparison. Playwright runs browsers in headless mode by default (no visible UI), making it ideal for CI pipelines.

This topic covers Playwright’s architecture, its auto-waiting mechanism, how to write reliable E2E tests that don’t flake, network mocking for controlled test environments, and patterns for integrating Playwright into a CI/CD pipeline. A typical web application uses Playwright to verify critical user flows — authentication, dashboard rendering, data table interactions — before every deployment.

Core Concepts

Browser Contexts for Isolation

Playwright introduces the concept of a BrowserContext — a lightweight, isolated browser session equivalent to an incognito window. Each context has its own cookies, localStorage, and session state, completely isolated from other contexts. This means tests can run in parallel within the same browser instance without interfering with each other: one test can be logged in as an admin while another test is logged in as a regular user, in the same browser process. Creating a context is fast (milliseconds), unlike launching a new browser process (seconds), which is why Playwright tests are significantly faster than Selenium-style approaches that launch a fresh browser per test.

Auto-Waiting

The most common cause of flaky E2E tests is timing: the test tries to click a button before it’s rendered, or reads text before an API response populates it. Playwright solves this with auto-waiting — every action (click, fill, check) automatically waits for the element to be visible, enabled, stable (not animating), and capable of receiving events before performing the action. Assertions like expect(locator).toHaveText('...') auto-retry until the condition is met or the timeout expires. This eliminates the need for explicit sleep() calls or manual waitFor patterns that plague other testing frameworks.

Locator API

Locators are Playwright’s primary way to find elements on a page. Unlike raw CSS selectors or XPath, locators are lazy and auto-waiting: page.getByRole('button', { name: 'Submit' }) doesn’t query the DOM immediately — it creates a reference that resolves at action time. This means the element doesn’t need to exist when you create the locator, only when you interact with it. The getByRole, getByText, getByLabel, and getByTestId methods encourage accessible selectors that survive UI refactors better than brittle CSS class selectors.

Trace Viewer

When a test fails, Playwright can generate a trace file — a complete recording of every action, network request, DOM snapshot, and console log from the test run. The Trace Viewer is a web-based tool that replays the trace step by step, showing exactly what the page looked like at each point. This is transformative for debugging CI failures: instead of staring at a screenshot of a failure, you can scrub through the entire test execution, inspect network responses, and see the DOM state at any moment. Traces are enabled per-test or on-first-retry, keeping storage manageable.

Multi-Browser Testing

Playwright supports Chromium, Firefox, and WebKit (Safari’s engine) on all platforms. A single test file can be configured to run across all three browsers, catching browser-specific rendering bugs and API inconsistencies before they reach users. WebKit support is particularly valuable because Safari is notoriously difficult to test — Playwright ships its own WebKit builds that closely match Safari’s behavior without requiring a macOS environment.

How It Works

Playwright communicates with browsers using the Chrome DevTools Protocol (CDP) for Chromium and equivalent protocols for Firefox and WebKit. It launches browser processes, opens contexts, and sends commands (navigate, click, type) over a WebSocket connection. The test runner orchestrates parallel test execution across workers, each with its own browser context.

Project Configuration

// 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 ? 4 : undefined,
  reporter: [
    ['html', { open: 'never' }],
    ['junit', { outputFile: 'test-results/junit.xml' }],
  ],
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
    },
    {
      name: 'mobile-chrome',
      use: { ...devices['Pixel 5'] },
    },
  ],
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
});

Writing a Basic E2E Test

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

test.describe('Dashboard', () => {
  test.beforeEach(async ({ page }) => {
    // Navigate to the login page and authenticate.
    await page.goto('/login');
    await page.getByLabel('Email').fill('admin@example.com');
    await page.getByLabel('Password').fill('testpassword');
    await page.getByRole('button', { name: 'Sign In' }).click();

    // Wait for redirect to dashboard.
    await expect(page).toHaveURL('/dashboard');
  });

  test('displays project list with correct counts', async ({ page }) => {
    // The project list should render with data from the API.
    const projectList = page.getByTestId('project-list');
    await expect(projectList).toBeVisible();

    // Verify at least one project card appears.
    const projectCards = projectList.getByTestId('project-card');
    await expect(projectCards).toHaveCount(3);

    // Verify the first project shows the correct name.
    await expect(projectCards.first()).toContainText('Acme Production');
  });

  test('filters projects by status', async ({ page }) => {
    // Click the "Archived" filter tab.
    await page.getByRole('tab', { name: 'Archived' }).click();

    // Verify the project list updates.
    const projectCards = page.getByTestId('project-card');
    await expect(projectCards).toHaveCount(1);
    await expect(projectCards.first()).toContainText('Legacy API');
  });

  test('navigates to project detail on click', async ({ page }) => {
    await page.getByTestId('project-card').first().click();
    await expect(page).toHaveURL(/\/projects\/[a-f0-9-]+/);
    await expect(page.getByRole('heading', { level: 1 })).toContainText('Acme Production');
  });
});

Network Mocking for Deterministic Tests

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

test('displays error state when API fails', async ({ page }) => {
  // Intercept the projects API and return a 500 error.
  await page.route('**/api/v1/projects', (route) => {
    route.fulfill({
      status: 500,
      contentType: 'application/json',
      body: JSON.stringify({ error: 'Internal Server Error' }),
    });
  });

  await page.goto('/dashboard');

  // Verify the error state is displayed.
  await expect(page.getByTestId('error-banner')).toBeVisible();
  await expect(page.getByTestId('error-banner')).toContainText(
    'Failed to load projects'
  );
  await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible();
});

test('shows loading skeleton while data is fetching', async ({ page }) => {
  // Delay the API response by 2 seconds.
  await page.route('**/api/v1/projects', async (route) => {
    await new Promise((resolve) => setTimeout(resolve, 2000));
    route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({ projects: [] }),
    });
  });

  await page.goto('/dashboard');

  // Skeleton should be visible during loading.
  await expect(page.getByTestId('loading-skeleton')).toBeVisible();

  // After data loads, skeleton should disappear.
  await expect(page.getByTestId('loading-skeleton')).not.toBeVisible({
    timeout: 5000,
  });
});

Authentication State Reuse

// e2e/auth.setup.ts — runs once before all tests
import { test as setup, expect } from '@playwright/test';

const authFile = 'e2e/.auth/user.json';

setup('authenticate', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill('admin@example.com');
  await page.getByLabel('Password').fill('testpassword');
  await page.getByRole('button', { name: 'Sign In' }).click();
  await expect(page).toHaveURL('/dashboard');

  // Save the authenticated browser state to a file.
  // All subsequent tests reuse this state — no login per test.
  await page.context().storageState({ path: authFile });
});

// playwright.config.ts addition:
// {
//   name: 'setup',
//   testMatch: /.*\.setup\.ts/,
// },
// {
//   name: 'chromium',
//   use: {
//     ...devices['Desktop Chrome'],
//     storageState: 'e2e/.auth/user.json',
//   },
//   dependencies: ['setup'],
// },

Practical Applications

A typical web application uses Playwright to verify critical user flows before every deployment. The test suite covers authentication (login, logout, session expiry), dashboard data rendering, project CRUD operations, and data table interactions (sorting, filtering, pagination). Tests run in CI against a Docker Compose environment that spins up the full stack — frontend, API server, PostgreSQL, and Redis — ensuring the tests exercise real backend behavior rather than mocks.

Playwright’s network interception is used selectively: most tests hit the real API for accuracy, but specific error-handling tests mock API responses to reliably trigger error states, rate limit pages, and timeout behaviors that are difficult to reproduce against a live backend. This hybrid approach balances test reliability (mocked error paths) with test accuracy (real happy paths).

Visual regression testing is configured for the application’s design system: Playwright captures screenshots of component pages and compares them against baseline images. When a CSS change causes unintended visual differences, the PR check fails with a diff image showing exactly what changed. This catches subtle regressions — margin shifts, font size changes, color drift — that functional tests would miss entirely.

Implementation Patterns

Page Object Model

For large test suites, the Page Object Model (POM) pattern encapsulates page interactions behind a class interface. Instead of repeating page.getByLabel('Email').fill(...) in every test, a LoginPage class exposes login(email, password). This centralizes selector changes — when the UI renames a label, you update one class instead of fifty tests.

// e2e/pages/login-page.ts
import { type Page, type Locator, expect } from '@playwright/test';

export class LoginPage {
  private readonly emailInput: Locator;
  private readonly passwordInput: Locator;
  private readonly submitButton: Locator;

  constructor(private page: Page) {
    this.emailInput = page.getByLabel('Email');
    this.passwordInput = page.getByLabel('Password');
    this.submitButton = page.getByRole('button', { name: 'Sign In' });
  }

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

  async login(email: string, password: string) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
    await expect(this.page).toHaveURL('/dashboard');
  }
}

Test Fixtures for Common Setup

Playwright’s fixture system allows you to define reusable setup/teardown logic that tests declare as dependencies. A seededDatabase fixture can reset the test database to a known state before each test, while an authenticatedPage fixture provides a page already logged in as a specific user.

Parallel Test Sharding in CI

For large test suites, Playwright supports sharding across multiple CI machines. Each shard runs a subset of tests, and results are merged afterward. This turns a 20-minute test suite into a 5-minute run across four machines. The --shard=1/4 flag tells Playwright which portion of tests to run.

# CI matrix configuration (GitHub Actions)
strategy:
  matrix:
    shard: [1, 2, 3, 4]
steps:
  - run: npx playwright test --shard=${{ matrix.shard }}/4

Common Pitfalls

Using sleep() Instead of Auto-Wait

The single biggest source of flaky Playwright tests is adding await page.waitForTimeout(1000) instead of using proper auto-waiting assertions. Sleep-based waits are inherently racy — they either wait too long (slow tests) or too little (flaky tests). Playwright’s expect(locator).toBeVisible() and expect(locator).toHaveText() auto-retry until the condition is met, eliminating timing issues. If you find yourself reaching for waitForTimeout, you’re working against the framework.

Brittle Selectors

Tests that rely on CSS class names (.btn-primary-xl-2) or deeply nested selectors (div > div > span:nth-child(3)) break whenever the UI is refactored. Prefer accessible selectors: getByRole, getByLabel, getByText. For elements without semantic roles, use data-testid attributes — these signal “this element matters for testing” and are stable across visual redesigns. Never rely on auto-generated class names from CSS-in-JS libraries.

Not Isolating Test State

Tests that depend on each other (test B assumes test A created a record) create ordering dependencies and cascade failures. Each test should set up its own state — either through API calls in a beforeEach hook or through database seeding. Playwright’s browser context isolation handles client-side state, but server-side state (database records, file uploads) must be managed explicitly.

Ignoring CI Environment Differences

Tests that pass locally but fail in CI often suffer from: slower CPU causing timeout failures, missing fonts causing screenshot mismatches, different screen resolutions affecting responsive layouts, or missing environment variables. Set explicit viewport sizes in configuration, use generous but bounded timeouts, and run the same browser versions locally and in CI via npx playwright install.

Further Reading

Further Reading