A

Playwright - Cross-Browser E2E Testing

testingplaywrighte2eend-to-endbrowser-testingautomation

Playwright - Cross-Browser E2E Testing

Playwright is Microsoft's modern end-to-end testing framework that enables reliable cross-browser testing for web applications. It supports Chromium, Firefox, and WebKit with a single API, features auto-waiting that eliminates most flakiness, and includes powerful debugging tools like the trace viewer. If you need to test how your application works in a real browser environment, Playwright is the tool to reach for.

Why Playwright?

True cross-browser testing. Run the same tests on Chromium (Chrome, Edge), Firefox, and WebKit (Safari) without code changes. This is real browser engines, not simulations.

Auto-waiting built in. Playwright automatically waits for elements to be actionable before performing actions. No more sleep() calls or manual waits.

Trace viewer. When tests fail, Playwright can record a trace—a complete timeline of actions, network requests, screenshots, and DOM snapshots—that you can step through like a debugger.

Isolated browser contexts. Each test runs in a fresh browser context, providing true isolation without the overhead of launching new browser processes.

Network interception. Mock API responses, modify requests, and test offline scenarios natively.

Installation and Setup

Initialize a new Playwright project:

pnpm create playwright

This interactive setup:

  • Installs @playwright/test
  • Downloads browser binaries
  • Creates configuration and example tests
  • Optionally sets up GitHub Actions CI

For existing projects:

pnpm add -D @playwright/test

# Install browsers
pnpm exec playwright install

Project Structure

After initialization:

├── playwright.config.ts
├── tests/
│   └── example.spec.ts
├── tests-examples/
│   └── demo-todo-app.spec.ts
└── .github/
    └── workflows/
        └── playwright.yml

Configuration

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

export default defineConfig({
  // Directory containing test files
  testDir: './tests',

  // Run tests in parallel
  fullyParallel: true,

  // Fail build on CI if test.only is left in code
  forbidOnly: !!process.env.CI,

  // Retry failed tests (useful on CI)
  retries: process.env.CI ? 2 : 0,

  // Number of parallel workers
  workers: process.env.CI ? 1 : undefined,

  // Reporter configuration
  reporter: [
    ['html', { open: 'never' }],
    ['list'],
  ],

  // Shared settings for all projects
  use: {
    // Base URL for page.goto('/')
    baseURL: 'http://localhost:3000',

    // Capture trace on first retry of failed test
    trace: 'on-first-retry',

    // Capture screenshot on failure
    screenshot: 'only-on-failure',

    // Record video on failure
    video: 'retain-on-failure',
  },

  // Browser configurations
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
    },
    // Mobile viewports
    {
      name: 'Mobile Chrome',
      use: { ...devices['Pixel 5'] },
    },
    {
      name: 'Mobile Safari',
      use: { ...devices['iPhone 12'] },
    },
  ],

  // Start dev server before running tests
  webServer: {
    command: 'pnpm dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
});

Writing Tests

Basic Test Structure

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

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

test('navigation works', async ({ page }) => {
  await page.goto('/');
  await page.click('text=About');
  await expect(page).toHaveURL(/\/about/);
});

test.describe('user authentication', () => {
  test('login with valid credentials', async ({ page }) => {
    await page.goto('/login');
    await page.fill('#email', '[email protected]');
    await page.fill('#password', 'password123');
    await page.click('button[type="submit"]');
    await expect(page).toHaveURL('/dashboard');
  });

  test('shows error for invalid credentials', async ({ page }) => {
    await page.goto('/login');
    await page.fill('#email', '[email protected]');
    await page.fill('#password', 'wrong');
    await page.click('button[type="submit"]');
    await expect(page.locator('.error-message')).toBeVisible();
  });
});

Locators

Locators are the recommended way to find elements. They're auto-waiting and provide better debugging:

// By role (recommended - most resilient)
page.getByRole('button', { name: 'Submit' });
page.getByRole('textbox', { name: 'Email' });
page.getByRole('link', { name: 'Learn more' });
page.getByRole('heading', { name: 'Welcome' });

// By label (for form fields)
page.getByLabel('Email address');
page.getByLabel('Password');

// By placeholder
page.getByPlaceholder('Enter your email');

// By text content
page.getByText('Welcome back');
page.getByText(/welcome/i);  // Case-insensitive regex

// By test ID (when other options don't work)
page.getByTestId('submit-button');

// By CSS selector (fallback)
page.locator('.btn-primary');
page.locator('#login-form');

// By XPath (avoid if possible)
page.locator('xpath=//button[@type="submit"]');

// Combining locators
page.locator('form').getByRole('button', { name: 'Submit' });
page.getByRole('listitem').filter({ hasText: 'Product A' });

Actions

// Click
await page.click('button');
await page.getByRole('button').click();
await page.click('button', { button: 'right' });  // Right-click
await page.dblclick('button');  // Double-click

// Fill forms
await page.fill('#email', '[email protected]');
await page.getByLabel('Email').fill('[email protected]');

// Type with delay (for autocomplete testing)
await page.type('#search', 'playwright', { delay: 100 });

// Clear and fill
await page.getByLabel('Name').clear();
await page.getByLabel('Name').fill('New Name');

// Select dropdown
await page.selectOption('select#country', 'usa');
await page.selectOption('select#country', { label: 'United States' });
await page.selectOption('select#colors', ['red', 'blue']);  // Multi-select

// Checkbox and radio
await page.check('#agree-terms');
await page.uncheck('#newsletter');
await page.getByRole('radio', { name: 'Express shipping' }).check();

// File upload
await page.setInputFiles('input[type="file"]', 'path/to/file.pdf');
await page.setInputFiles('input[type="file"]', ['file1.pdf', 'file2.pdf']);

// Keyboard
await page.keyboard.press('Enter');
await page.keyboard.press('Control+A');
await page.keyboard.type('Hello World');

// Hover and focus
await page.hover('.dropdown-trigger');
await page.focus('#email');

Assertions

// Page assertions
await expect(page).toHaveTitle('Dashboard');
await expect(page).toHaveURL(/\/dashboard/);
await expect(page).toHaveURL('https://example.com/dashboard');

// Locator assertions
const button = page.getByRole('button', { name: 'Submit' });

await expect(button).toBeVisible();
await expect(button).toBeHidden();
await expect(button).toBeEnabled();
await expect(button).toBeDisabled();
await expect(button).toBeFocused();

await expect(button).toHaveText('Submit');
await expect(button).toContainText('Sub');
await expect(button).toHaveAttribute('type', 'submit');
await expect(button).toHaveClass(/btn-primary/);
await expect(button).toHaveCSS('background-color', 'rgb(0, 0, 255)');

// Input assertions
const input = page.getByLabel('Email');
await expect(input).toHaveValue('[email protected]');
await expect(input).toBeEmpty();

// List assertions
const items = page.getByRole('listitem');
await expect(items).toHaveCount(5);
await expect(items).toHaveText(['Item 1', 'Item 2', 'Item 3', 'Item 4', 'Item 5']);

// Checkbox/radio assertions
await expect(page.getByRole('checkbox')).toBeChecked();
await expect(page.getByRole('checkbox')).not.toBeChecked();

// Soft assertions (don't stop test on failure)
await expect.soft(button).toHaveText('Submit');
await expect.soft(button).toBeEnabled();
// Test continues even if above fail

Page Object Model

Organize tests with the Page Object pattern for better maintainability:

// 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.locator('[data-testid="error-message"]');
  }

  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 expectError(message: string) {
    await expect(this.errorMessage).toHaveText(message);
  }
}
// tests/login.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';

test.describe('Login', () => {
  let loginPage: LoginPage;

  test.beforeEach(async ({ page }) => {
    loginPage = new LoginPage(page);
    await loginPage.goto();
  });

  test('successful login redirects to dashboard', async ({ page }) => {
    await loginPage.login('[email protected]', 'password123');
    await expect(page).toHaveURL('/dashboard');
  });

  test('shows error for invalid credentials', async () => {
    await loginPage.login('[email protected]', 'wrong-password');
    await loginPage.expectError('Invalid email or password');
  });
});

Authentication Handling

Save authentication state to reuse across tests:

// playwright.config.ts
export default defineConfig({
  projects: [
    // Setup project - runs first, saves auth state
    {
      name: 'setup',
      testMatch: /.*\.setup\.ts/,
    },
    // Tests that need authentication
    {
      name: 'chromium',
      use: {
        ...devices['Desktop Chrome'],
        storageState: 'playwright/.auth/user.json',
      },
      dependencies: ['setup'],
    },
  ],
});
// tests/auth.setup.ts
import { test as setup, expect } from '@playwright/test';

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

setup('authenticate', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill('[email protected]');
  await page.getByLabel('Password').fill('password123');
  await page.getByRole('button', { name: 'Sign in' }).click();

  // Wait for redirect after login
  await page.waitForURL('/dashboard');

  // Save authentication state
  await page.context().storageState({ path: authFile });
});
// tests/dashboard.spec.ts
import { test, expect } from '@playwright/test';

// These tests run with saved auth state - already logged in
test('shows user profile', async ({ page }) => {
  await page.goto('/dashboard');
  await expect(page.getByText('Welcome back')).toBeVisible();
});

For multiple users or roles:

// tests/admin.setup.ts
setup('authenticate as admin', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill('[email protected]');
  await page.getByLabel('Password').fill('admin-password');
  await page.getByRole('button', { name: 'Sign in' }).click();
  await page.waitForURL('/admin');
  await page.context().storageState({ path: 'playwright/.auth/admin.json' });
});

// playwright.config.ts - add admin project
{
  name: 'admin tests',
  use: {
    storageState: 'playwright/.auth/admin.json',
  },
  testMatch: /.*\.admin\.spec\.ts/,
  dependencies: ['admin-setup'],
}

Visual Comparisons and Screenshots

// Full page screenshot comparison
test('homepage visual regression', async ({ page }) => {
  await page.goto('/');
  await expect(page).toHaveScreenshot('homepage.png');
});

// Element screenshot
test('button appearance', async ({ page }) => {
  await page.goto('/');
  const button = page.getByRole('button', { name: 'Submit' });
  await expect(button).toHaveScreenshot('submit-button.png');
});

// With options
await expect(page).toHaveScreenshot('homepage.png', {
  fullPage: true,              // Capture entire scrollable page
  maxDiffPixels: 100,          // Allow small differences
  maxDiffPixelRatio: 0.02,     // Or as percentage
  threshold: 0.2,              // Pixel comparison sensitivity
  animations: 'disabled',       // Stop animations before capture
  mask: [page.locator('.ads')], // Hide dynamic content
});

Update screenshots when intentional changes occur:

pnpm exec playwright test --update-snapshots

Network Mocking

Intercept and mock network requests:

test('shows loading state while fetching', async ({ page }) => {
  // Delay response to test loading state
  await page.route('**/api/users', async (route) => {
    await new Promise(resolve => setTimeout(resolve, 1000));
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify([{ id: 1, name: 'John' }]),
    });
  });

  await page.goto('/users');
  await expect(page.locator('.loading-spinner')).toBeVisible();
  await expect(page.getByText('John')).toBeVisible();
});

test('handles API errors gracefully', async ({ page }) => {
  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.getByText('Something went wrong')).toBeVisible();
});

test('modifies request headers', async ({ page }) => {
  await page.route('**/api/**', (route) => {
    route.continue({
      headers: {
        ...route.request().headers(),
        'X-Custom-Header': 'test-value',
      },
    });
  });

  await page.goto('/');
});

// Abort requests
await page.route('**/*.{png,jpg,jpeg}', route => route.abort());

// Mock from HAR file (record and replay)
await page.routeFromHAR('tests/fixtures/api.har', {
  url: '**/api/**',
  update: false,
});

CI Configuration

GitHub Actions

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

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

jobs:
  test:
    timeout-minutes: 60
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v2
        with:
          version: 8

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

      - name: Install dependencies
        run: pnpm install

      - name: Install Playwright browsers
        run: pnpm exec playwright install --with-deps

      - name: Run Playwright tests
        run: pnpm exec playwright test

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

Parallel Execution in CI

jobs:
  test:
    strategy:
      fail-fast: false
      matrix:
        shard: [1/4, 2/4, 3/4, 4/4]

    steps:
      # ... setup steps ...

      - name: Run tests
        run: pnpm exec playwright test --shard=${{ matrix.shard }}

Debugging

Headed Mode

Run tests with visible browser:

pnpm exec playwright test --headed

Debug Mode

Step through tests interactively:

pnpm exec playwright test --debug

Trace Viewer

The trace viewer is Playwright's killer debugging feature. Enable it in config:

use: {
  trace: 'on-first-retry',  // or 'on', 'retain-on-failure'
}

Or run with trace:

pnpm exec playwright test --trace on

View the trace:

pnpm exec playwright show-trace trace.zip

The trace viewer shows:

  • Timeline of all actions
  • DOM snapshots at each step
  • Network requests and responses
  • Console logs
  • Screenshots before/after each action

VSCode Extension

Install the Playwright Test for VS Code extension for:

  • Running tests from the editor
  • Setting breakpoints in test code
  • Live debugging with browser inspection
  • Test generation by recording actions

UI Mode

Interactive test runner with watch mode:

pnpm exec playwright test --ui

Pause and Inspect

Add pause points in tests:

test('debug this', async ({ page }) => {
  await page.goto('/');
  await page.pause();  // Opens inspector, pauses execution
  await page.click('button');
});

Gotchas and Tips

Strict Mode

By default, Playwright expects locators to match exactly one element:

// Throws if multiple elements match
await page.click('button');

// To click all matching elements
const buttons = page.locator('button');
for (const button of await buttons.all()) {
  await button.click();
}

// Or use .first(), .last(), .nth()
await page.locator('button').first().click();
await page.locator('li').nth(2).click();

Handling Iframes

// Get frame by name or URL
const frame = page.frame({ name: 'payment-iframe' });
const frame = page.frameLocator('iframe[name="payment"]');

// Interact with elements inside
await frame.locator('input#card-number').fill('4242424242424242');

Multiple Pages/Tabs

test('handles popup', async ({ page, context }) => {
  // Listen for new page before clicking
  const pagePromise = context.waitForEvent('page');
  await page.click('a[target="_blank"]');
  const newPage = await pagePromise;

  // Wait for popup to load
  await newPage.waitForLoadState();

  // Interact with popup
  await expect(newPage).toHaveTitle('New Page');
  await newPage.close();
});

Downloads

test('downloads file', async ({ page }) => {
  const downloadPromise = page.waitForEvent('download');
  await page.click('a#download-button');
  const download = await downloadPromise;

  // Save to disk
  await download.saveAs('downloaded-file.pdf');

  // Or get path to temp file
  const path = await download.path();
});

Mobile Emulation

Test mobile viewports and features:

// In config
{
  name: 'Mobile Safari',
  use: {
    ...devices['iPhone 13'],
    // Additional mobile settings
    hasTouch: true,
    isMobile: true,
  },
}

// In test - geolocation
test('uses location', async ({ page, context }) => {
  await context.grantPermissions(['geolocation']);
  await context.setGeolocation({ latitude: 37.7749, longitude: -122.4194 });
  await page.goto('/map');
});

Test Isolation Issues

If tests interfere with each other:

// Force serial execution for specific tests
test.describe.configure({ mode: 'serial' });

test.describe('sequential tests', () => {
  test('first', async ({ page }) => {});
  test('second - depends on first', async ({ page }) => {});
});

See Also

Last updated: March 23, 2026