Testing & Automation

Browser Automation Testing with Playwright in 2026 — Complete Guide

Published April 5, 2026 · 18 min read

Playwright has become the dominant browser automation framework for testing web applications. With native support for Chromium, Firefox, and WebKit, auto-waiting, parallel execution, and a powerful assertion library, it gives teams everything they need for reliable end-to-end tests. This guide covers setup, core patterns, visual regression testing, API mocking, CI/CD integration, and production monitoring.

Setting Up Playwright for Testing

Install Playwright and its test runner with a single command. The --with-deps flag installs browser binaries and system dependencies:

# Initialize a new Playwright test project
npm init playwright@latest

# Or add to an existing project
npm install -D @playwright/test
npx playwright install --with-deps

Configure your project in playwright.config.ts with projects for cross-browser testing:

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

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: [
    ['html'],
    ['json', { outputFile: 'results.json' }]
  ],
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'retain-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'] } },
    { name: 'mobile-safari', use: { ...devices['iPhone 13'] } },
  ],
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
});

Writing End-to-End Tests

Playwright's test runner provides built-in assertions that auto-wait for elements. Here's a complete authentication flow test:

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

test.describe('Authentication', () => {
  test('should register a new user', async ({ page }) => {
    await page.goto('/register');

    await page.getByLabel('Email').fill('test@example.com');
    await page.getByLabel('Password').fill('SecurePass123!');
    await page.getByLabel('Confirm Password').fill('SecurePass123!');
    await page.getByRole('button', { name: 'Create Account' }).click();

    // Auto-waits for navigation and element
    await expect(page).toHaveURL('/dashboard');
    await expect(page.getByText('Welcome')).toBeVisible();
  });

  test('should login with valid credentials', async ({ page }) => {
    await page.goto('/login');

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

    await expect(page).toHaveURL('/dashboard');
    await expect(page.getByTestId('user-avatar')).toBeVisible();
  });

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

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

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

test.describe('Dashboard', () => {
  test.beforeEach(async ({ page }) => {
    // Reuse authentication state
    await page.goto('/login');
    await page.getByLabel('Email').fill('test@example.com');
    await page.getByLabel('Password').fill('SecurePass123!');
    await page.getByRole('button', { name: 'Sign In' }).click();
    await expect(page).toHaveURL('/dashboard');
  });

  test('should display user stats', async ({ page }) => {
    await expect(page.getByTestId('api-calls')).toBeVisible();
    await expect(page.getByTestId('monthly-usage')).toContainText(/\d+/);
  });

  test('should navigate to settings', async ({ page }) => {
    await page.getByRole('link', { name: 'Settings' }).click();
    await expect(page).toHaveURL('/settings');
    await expect(page.getByText('Account Settings')).toBeVisible();
  });
});

Page Object Model

Organize tests with the Page Object Model pattern. This encapsulates page interactions and makes tests readable and maintainable:

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

export class LoginPage {
  readonly page: Page;
  readonly emailInput: Locator;
  readonly passwordInput: Locator;
  readonly submitButton: Locator;
  readonly errorAlert: 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.errorAlert = 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 expectError(message: string) {
    await expect(this.errorAlert).toContainText(message);
  }

  async expectLoginSuccess() {
    await expect(this.page).toHaveURL('/dashboard');
  }
}

// tests/login.spec.ts — using the page object
import { test } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';

test('login with page object', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login('user@example.com', 'password123');
  await loginPage.expectLoginSuccess();
});

Visual Regression Testing

Playwright includes built-in visual comparison testing. Screenshots are compared pixel-by-pixel against golden reference images:

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

test.describe('Visual Regression', () => {
  test('homepage renders correctly', async ({ page }) => {
    await page.goto('/');
    // Wait for all animations and fonts to load
    await page.waitForLoadState('networkidle');

    // Full page screenshot comparison
    await expect(page).toHaveScreenshot('homepage.png', {
      fullPage: true,
      maxDiffPixelRatio: 0.01, // Allow 1% pixel difference
    });
  });

  test('pricing cards render correctly', async ({ page }) => {
    await page.goto('/#pricing');

    const pricingSection = page.locator('#pricing');
    await expect(pricingSection).toHaveScreenshot('pricing.png', {
      maxDiffPixels: 100, // Allow 100 pixels difference
    });
  });

  test('responsive layout — mobile', async ({ page }) => {
    await page.setViewportSize({ width: 375, height: 812 });
    await page.goto('/');

    await expect(page).toHaveScreenshot('homepage-mobile.png', {
      fullPage: true,
    });
  });

  test('dark and light theme', async ({ page }) => {
    await page.goto('/');

    // Dark theme (default)
    await expect(page).toHaveScreenshot('theme-dark.png');

    // Switch to light theme
    await page.getByRole('button', { name: 'Toggle theme' }).click();
    await expect(page).toHaveScreenshot('theme-light.png');
  });
});

// Update snapshots: npx playwright test --update-snapshots

API Mocking and Network Interception

Playwright can intercept and mock API requests, making tests faster and more reliable by removing external dependencies:

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

test.describe('API Mocking', () => {
  test('display mocked user data', async ({ page }) => {
    // Intercept API call and return mock data
    await page.route('**/api/v1/user/profile', async (route) => {
      await route.fulfill({
        status: 200,
        contentType: 'application/json',
        body: JSON.stringify({
          name: 'Jane Doe',
          email: 'jane@example.com',
          plan: 'pro',
          apiCalls: 15420,
        }),
      });
    });

    await page.goto('/dashboard');
    await expect(page.getByText('Jane Doe')).toBeVisible();
    await expect(page.getByText('15,420')).toBeVisible();
  });

  test('handle API errors gracefully', async ({ page }) => {
    await page.route('**/api/v1/user/profile', async (route) => {
      await route.fulfill({
        status: 500,
        contentType: 'application/json',
        body: JSON.stringify({ error: 'Internal server error' }),
      });
    });

    await page.goto('/dashboard');
    await expect(page.getByText('Something went wrong')).toBeVisible();
    await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible();
  });

  test('mock with conditional responses', async ({ page }) => {
    let callCount = 0;

    await page.route('**/api/v1/screenshot', async (route) => {
      callCount++;
      if (callCount <= 3) {
        await route.fulfill({
          status: 200,
          body: JSON.stringify({ url: `https://cdn.example.com/shot-${callCount}.png` }),
        });
      } else {
        await route.fulfill({
          status: 429,
          body: JSON.stringify({ error: 'Rate limited' }),
        });
      }
    });

    await page.goto('/bulk-capture');
    // Test that rate limiting is handled
  });

  test('record and replay network traffic', async ({ page }) => {
    // Use HAR recording for complex scenarios
    await page.routeFromHAR('tests/fixtures/dashboard.har', {
      url: '**/api/**',
      update: false, // Set true to re-record
    });

    await page.goto('/dashboard');
    await expect(page.getByTestId('stats-panel')).toBeVisible();
  });
});

Authentication State Reuse

Avoid logging in before every test by saving and reusing authentication state. This dramatically speeds up test suites:

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

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

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

  // Save signed-in state
  await page.context().storageState({ path: authFile });
});

// playwright.config.ts — add setup project
export default defineConfig({
  projects: [
    { name: 'setup', testMatch: /.*\.setup\.ts/ },
    {
      name: 'chromium',
      use: {
        ...devices['Desktop Chrome'],
        storageState: '.auth/user.json',
      },
      dependencies: ['setup'],
    },
  ],
});

Parallel Execution and Sharding

Playwright runs tests in parallel by default. Configure workers and sharding for large test suites:

// playwright.config.ts
export default defineConfig({
  // Run tests in parallel within each file
  fullyParallel: true,

  // Control worker count
  workers: process.env.CI ? 4 : undefined, // 4 workers in CI

  // Shard across CI machines
  // Run: npx playwright test --shard=1/3
  //      npx playwright test --shard=2/3
  //      npx playwright test --shard=3/3
});

Use test fixtures for test isolation. Each test gets a fresh browser context:

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

// Custom fixture with pre-seeded data
const test = base.extend<{ seedData: void }>({
  seedData: async ({ page }, use) => {
    // Setup: seed test data via API
    await page.request.post('/api/test/seed', {
      data: { users: 5, screenshots: 20 },
    });

    await use(); // Test runs here

    // Teardown: clean up test data
    await page.request.delete('/api/test/cleanup');
  },
});

test('dashboard with seeded data', async ({ page, seedData }) => {
  await page.goto('/dashboard');
  await expect(page.getByTestId('total-screenshots')).toContainText('20');
});

CI/CD Integration

Run Playwright tests in GitHub Actions with caching and artifact collection:

# .github/workflows/playwright.yml
name: Playwright Tests
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        shard: [1/3, 2/3, 3/3]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm

      - run: npm ci

      - name: Install Playwright Browsers
        run: npx playwright install --with-deps

      - name: Run Tests
        run: npx playwright test --shard=${{ matrix.shard }}
        env:
          BASE_URL: ${{ secrets.STAGING_URL }}

      - uses: actions/upload-artifact@v4
        if: ${{ !cancelled() }}
        with:
          name: report-${{ strategy.job-index }}
          path: playwright-report/
          retention-days: 7

      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: traces-${{ strategy.job-index }}
          path: test-results/
          retention-days: 3

Testing Framework Comparison

FeaturePlaywrightCypressSelenium
Multi-browserChromium, Firefox, WebKitChromium, Firefox, WebKitAll major browsers
Parallel executionBuilt-inPaid (Cypress Cloud)Via Selenium Grid
Auto-waitingYes — actionability checksYes — retry-abilityNo — manual waits
API testingBuilt-in request APIcy.request()No
Visual testingBuilt-in screenshotsPlugin requiredPlugin required
Network mockingroute() + HAR replaycy.intercept()No built-in
TracingFull trace viewerTime-travel debugNo
Language supportJS, TS, Python, Java, C#JS, TS onlyAll major languages
Mobile emulation30+ device presetsViewport onlyVia Appium
iframesFirst-class supportLimitedswitchTo().frame()
LicenseApache 2.0MIT (runner) / Paid (cloud)Apache 2.0

Production Monitoring with Screenshots

Beyond testing, use browser automation for production monitoring. Capture screenshots of live pages to detect visual regressions, downtime, or content changes. Instead of managing your own Playwright infrastructure in production, use a screenshot API:

import SnapAPI from 'snapapi-js';

const snap = new SnapAPI('sk_live_your_key');

// Capture production screenshots for monitoring
async function monitorPages() {
  const pages = [
    { url: 'https://yourapp.com', name: 'homepage' },
    { url: 'https://yourapp.com/pricing', name: 'pricing' },
    { url: 'https://yourapp.com/dashboard', name: 'dashboard' },
  ];

  for (const page of pages) {
    const result = await snap.screenshot({
      url: page.url,
      full_page: true,
      format: 'png',
      block_ads: true,
      device: 'desktop',
    });

    console.log(`${page.name}: ${result.url}`);
    // Compare with previous screenshot, alert on diff
  }
}

// Also extract structured data for content monitoring
async function monitorContent() {
  const result = await snap.extract({
    url: 'https://yourapp.com/pricing',
    schema: {
      plans: [{
        name: 'string',
        price: 'string',
        features: ['string'],
      }],
    },
  });

  // Verify pricing hasn't changed unexpectedly
  console.log('Current pricing:', result.data.plans);
}

Skip Browser Infrastructure — Use SnapAPI

Screenshots, scraping, content extraction, PDF generation, and AI analysis through a single API. No Playwright servers to manage in production.

Start Free — 200 Captures/Month

Best Practices

After running Playwright in production across dozens of projects, these patterns consistently lead to reliable, maintainable test suites: