Skip to main content
← All Articles Β· Β· 9 min read

Playwright E2E Testing: Complete Guide from Setup to CI

Playwright E2E testing guide: browser setup, page object model, network mocking, API testing, visual regression, CI GitHub Actions config, and debugging with trace viewer.

PlaywrightE2E testingbrowser testingTypeScriptCIpage object model

End-to-end testing is where most teams feel the pain. Flaky tests, slow CI runs, cryptic failures β€” these are the reasons developers avoid E2E coverage. Playwright, released by Microsoft in 2020, was built specifically to fix these problems. In 2026 it’s the clear leader in browser automation, beating Cypress in flexibility, speed, and cross-browser support.

This guide walks from zero to production-grade E2E testing, including CI configuration and debugging techniques.

What Sets Playwright Apart

Before writing any code, it’s worth understanding why Playwright behaves differently from older browser automation tools.

Auto-wait everywhere. Playwright doesn’t need cy.wait(1000) or manual waits. Every action β€” click, fill, expect β€” automatically waits for the element to be visible, enabled, and stable. This eliminates the most common source of flaky tests.

Multi-browser, multi-context. A single test file can open Chrome, Firefox, and WebKit in parallel. You can also run multiple browser contexts (isolated sessions) within one test β€” useful for testing real-time features with two simultaneous users.

Codegen. playwright codegen https://myapp.com opens a browser, records your clicks and inputs, and outputs TypeScript test code. It’s a great way to bootstrap tests fast.

Out-of-process. Playwright controls browsers via WebSocket, not JavaScript injection. This means it can test cross-origin iframes, service workers, and scenarios that break Cypress’s same-origin model.

Installation and First Test

npm init playwright@latest

This scaffolds a playwright.config.ts, an example test, and sets up browsers. Choose TypeScript when prompted.

Your first test:

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

test('homepage has correct title', async ({ page }) => {
  await page.goto('https://myapp.com');
  await expect(page).toHaveTitle(/My App/);
});

test('user can search for tools', async ({ page }) => {
  await page.goto('https://myapp.com/tools');
  await page.getByPlaceholder('Search tools').fill('webpack');
  await expect(page.getByTestId('tool-card')).toHaveCount(3);
});

Run it: npx playwright test

Locator Strategies

The single most important skill in Playwright is picking the right locator. Fragile locators (CSS classes, XPath) create brittle tests. Playwright’s preferred hierarchy:

  1. Role + name β€” page.getByRole('button', { name: 'Submit' })
  2. Label β€” page.getByLabel('Email address')
  3. Placeholder β€” page.getByPlaceholder('Search...')
  4. Text β€” page.getByText('Sign in')
  5. Test ID β€” page.getByTestId('submit-btn') (add data-testid to your HTML)
  6. CSS / XPath β€” last resort only
// Good β€” role-based, resilient to CSS changes
await page.getByRole('button', { name: /save/i }).click();

// Good β€” label-based, links to accessibility
await page.getByLabel('Password').fill('secret');

// Avoid β€” tied to implementation details
await page.locator('.btn-primary.submit-action').click();

Configure your data-testid attribute in playwright.config.ts:

use: {
  testIdAttribute: 'data-testid',
}

Page Object Model

For any non-trivial test suite, the Page Object Model (POM) prevents duplication and centralizes selectors. Each page or component gets its own class.

// tests/pages/LoginPage.ts
import { type Page, type Locator } from '@playwright/test';

export class LoginPage {
  readonly page: Page;
  readonly emailInput: Locator;
  readonly passwordInput: Locator;
  readonly submitButton: Locator;
  readonly errorMessage: Locator;

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

  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();
  }

  async loginAndExpectError(email: string, password: string, message: string) {
    await this.login(email, password);
    await expect(this.errorMessage).toContainText(message);
  }
}
// tests/auth.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';

test.describe('Authentication', () => {
  test('valid credentials redirect to dashboard', async ({ page }) => {
    const loginPage = new LoginPage(page);
    await loginPage.goto();
    await loginPage.login('[email protected]', 'correctpassword');
    await expect(page).toHaveURL('/dashboard');
  });

  test('invalid credentials show error', async ({ page }) => {
    const loginPage = new LoginPage(page);
    await loginPage.goto();
    await loginPage.loginAndExpectError(
      '[email protected]',
      'wrongpassword',
      'Invalid credentials'
    );
  });
});

Network Request Mocking

Playwright can intercept and mock network requests with page.route(). This is invaluable for testing error states, slow responses, and edge cases that are hard to reproduce with real APIs.

test('shows error banner when API fails', async ({ page }) => {
  // Mock the API to return a 500 error
  await page.route('**/api/users', route => {
    route.fulfill({
      status: 500,
      contentType: 'application/json',
      body: JSON.stringify({ error: 'Internal Server Error' }),
    });
  });

  await page.goto('/users');
  await expect(page.getByRole('alert')).toContainText('Failed to load users');
});

test('handles slow API gracefully', async ({ page }) => {
  await page.route('**/api/data', async route => {
    await new Promise(resolve => setTimeout(resolve, 3000)); // 3s delay
    await route.continue();
  });

  await page.goto('/dashboard');
  await expect(page.getByTestId('loading-skeleton')).toBeVisible();
});

API Testing with the request Fixture

Playwright includes an HTTP client for API testing, useful for setting up test state or asserting backend responses directly.

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

test('API creates user and returns 201', async ({ request }) => {
  const response = await request.post('/api/users', {
    data: {
      name: 'Test User',
      email: '[email protected]',
    },
  });

  expect(response.status()).toBe(201);
  const body = await response.json();
  expect(body).toMatchObject({ name: 'Test User' });
});

test('API rejects duplicate email with 409', async ({ request }) => {
  // First create
  await request.post('/api/users', {
    data: { name: 'Alice', email: '[email protected]' },
  });

  // Duplicate attempt
  const response = await request.post('/api/users', {
    data: { name: 'Alice 2', email: '[email protected]' },
  });

  expect(response.status()).toBe(409);
});

Visual Regression Testing

toHaveScreenshot() captures a screenshot and compares it against a stored baseline. On first run it saves the baseline; subsequent runs fail if pixels differ beyond a threshold.

test('homepage matches visual snapshot', async ({ page }) => {
  await page.goto('/');
  await expect(page).toHaveScreenshot('homepage.png', {
    maxDiffPixels: 100,
  });
});

test('button hover state matches snapshot', async ({ page }) => {
  await page.goto('/components');
  const button = page.getByRole('button', { name: 'Primary Action' });
  await button.hover();
  await expect(button).toHaveScreenshot('button-hover.png');
});

Update snapshots when intentional changes are made: npx playwright test --update-snapshots

GitHub Actions CI Configuration

The key to fast CI is sharding β€” distributing tests across parallel workers.

# .github/workflows/playwright.yml
name: Playwright Tests

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

jobs:
  test:
    name: Playwright Tests (Shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        shardIndex: [1, 2, 3, 4]
        shardTotal: [4]

    steps:
      - uses: actions/checkout@v4

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

      - name: Install dependencies
        run: npm ci

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

      - name: Run Playwright tests
        run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
        env:
          BASE_URL: ${{ secrets.STAGING_URL }}

      - name: Upload test results
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: playwright-report-${{ matrix.shardIndex }}
          path: playwright-report/
          retention-days: 7

With 4 shards, a 200-test suite that takes 8 minutes sequentially completes in about 2 minutes.

Debugging with Trace Viewer

When a test fails in CI, the trace viewer lets you replay the exact browser session.

Enable traces in playwright.config.ts:

use: {
  trace: 'on-first-retry',
  screenshot: 'only-on-failure',
  video: 'on-first-retry',
},

Open a trace locally after downloading from CI:

npx playwright show-trace trace.zip

The trace viewer shows every action, DOM state, network request, and console log at each step β€” far more useful than a stack trace alone.

For local debugging: npx playwright test --debug opens a headed browser with the Playwright Inspector, letting you step through your test one action at a time.

Playwright vs Cypress

FeaturePlaywrightCypress
Browser supportChrome, Firefox, WebKit, EdgeChrome, Firefox, Edge (no WebKit)
Multi-tab testingYesLimited
Cross-origin iframesYesNo
Node.js in testsFull accessLimited (via tasks)
Auto-waitYesYes
Component testingYes (experimental)Yes (mature)
Network mockingroute() APIcy.intercept()
Speed (E2E)FasterSlower on large suites
Dashboard / analyticsPlaywright CloudCypress Cloud (paid)
Open sourceYes (MIT)Yes (MIT)

The main reason to choose Cypress over Playwright is its component testing story, which is more mature. For full E2E, Playwright wins on speed and flexibility.

Conclusion

Playwright is the right choice for E2E testing in 2026. Auto-wait alone eliminates most flakiness. The Page Object Model keeps tests maintainable. Network mocking makes edge cases testable without brittle test data. And the GitHub Actions shard matrix makes CI fast enough that teams actually run E2E tests on every PR.

Start with a small number of high-value flows β€” login, checkout, critical user paths β€” then expand coverage incrementally. A focused suite of 50 reliable tests beats 500 flaky ones every time.

Free Download

50 CLI Commands That Save Hours Every Week

The exact Git shortcuts, file search tricks, Docker commands, and productivity hacks that senior developers use daily. Compiled from 5+ years of shipping production code.

  • Git mastery (10 cmds)
  • File & search wizardry
  • Docker & DevOps tricks
  • Process & network tools
  • Productivity boosters
  • macOS & Linux install guide

No spam. 1 email/week max. Unsubscribe anytime.

Found this guide useful? Check out our free developer tools.