Playwright Testing: The Modern Browser Automation Guide
Playwright Testing: The Modern Browser Automation Guide
End-to-end tests have a reputation problem. They're slow, flaky, hard to debug, and expensive to maintain. Teams write them because someone said they should, then quietly stop running them when the suite takes 45 minutes and fails randomly on CI. Playwright doesn't magically solve all of these problems, but it solves enough of them that E2E testing becomes practical rather than aspirational.

Playwright, built by Microsoft, controls Chromium, Firefox, and WebKit through a single API. It's fast (browser contexts share a single browser process), reliable (auto-waits for elements by default), and comes with tooling that makes debugging failures genuinely pleasant. This guide covers setup through production CI patterns, with emphasis on the practices that keep a test suite healthy as it grows.
Why Playwright Over Cypress or Selenium
This isn't a comprehensive comparison (there's already an article for that), but the practical reasons teams are migrating to Playwright are worth stating directly.
| Feature | Playwright | Cypress | Selenium |
|---|---|---|---|
| Cross-browser | Chromium, Firefox, WebKit | Chromium, Firefox (limited WebKit) | All browsers |
| Multi-tab/window | Yes | No | Yes |
| iframes | Native support | Limited | Yes (cumbersome) |
| Network interception | Built-in, powerful | Built-in | Requires proxy |
| Parallelism | Built-in, per-worker | Requires Cypress Cloud | Requires Grid |
| Language support | JS/TS, Python, Java, .NET | JavaScript/TypeScript only | All major languages |
| Auto-waiting | Yes (smart, configurable) | Yes (implicit) | No (manual waits) |
| Codegen | Built-in | No | No |
| Trace viewer | Built-in (excellent) | Dashboard (paid) | No |
| Test isolation | Browser contexts (fast) | Page reload (slow) | New session (slow) |
The single biggest advantage is test isolation speed. Playwright creates browser contexts (think: incognito windows) in milliseconds, while Cypress and Selenium need to reload the page or create new sessions. This makes each test genuinely independent without paying a performance penalty.
Getting Started
Installation
# Create a new project with Playwright
npm init playwright@latest
# Or add to an existing project
npm install -D @playwright/test
npx playwright install
The playwright install command downloads browser binaries. On CI, use npx playwright install --with-deps to also install system dependencies.
Project Structure
tests/
├── e2e/
│ ├── auth.setup.ts # Authentication setup
│ ├── checkout.spec.ts # Test files
│ ├── dashboard.spec.ts
│ └── user-settings.spec.ts
├── fixtures/
│ └── test-data.ts # Shared test data
└── page-objects/
├── checkout-page.ts # Page object models
├── dashboard-page.ts
└── login-page.ts
playwright.config.ts
Configuration
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e',
timeout: 30_000,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 4 : undefined,
// Run setup before tests
globalSetup: require.resolve('./tests/global-setup'),
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry', // Capture trace on failure
screenshot: 'only-on-failure',
video: 'on-first-retry',
},
projects: [
// Setup project for authentication
{
name: 'setup',
testMatch: /.*\.setup\.ts/,
},
// Desktop browsers
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
},
dependencies: ['setup'],
},
{
name: 'firefox',
use: {
...devices['Desktop Firefox'],
},
dependencies: ['setup'],
},
{
name: 'webkit',
use: {
...devices['Desktop Safari'],
},
dependencies: ['setup'],
},
// Mobile viewports
{
name: 'mobile-chrome',
use: {
...devices['Pixel 7'],
},
dependencies: ['setup'],
},
{
name: 'mobile-safari',
use: {
...devices['iPhone 14'],
},
dependencies: ['setup'],
},
],
// Start your dev server before running tests
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
});
Writing Tests: The Fundamentals
Basic Test Structure
import { test, expect } from '@playwright/test';
test.describe('User Dashboard', () => {
test('displays recent activity', async ({ page }) => {
await page.goto('/dashboard');
// Playwright auto-waits for elements to be visible and stable
await expect(page.getByRole('heading', { name: 'Recent Activity' }))
.toBeVisible();
// Check that activity items are loaded
const activityItems = page.getByTestId('activity-item');
await expect(activityItems).toHaveCount(10);
// Verify first item content
await expect(activityItems.first())
.toContainText('Updated project settings');
});
test('can filter activity by type', async ({ page }) => {
await page.goto('/dashboard');
// Click filter dropdown
await page.getByRole('combobox', { name: 'Filter by type' }).click();
await page.getByRole('option', { name: 'Deployments' }).click();
// Verify filtered results
const items = page.getByTestId('activity-item');
await expect(items).toHaveCount(3);
// Every visible item should be a deployment
for (const item of await items.all()) {
await expect(item.getByTestId('activity-type'))
.toHaveText('deployment');
}
});
});
Locator Strategy
Playwright's locator API is designed to find elements the way users find them -- by role, text, label, and test ID. This produces tests that are resilient to implementation changes.
// Preferred: semantic locators (match how users perceive the page)
page.getByRole('button', { name: 'Submit' });
page.getByRole('textbox', { name: 'Email' });
page.getByRole('link', { name: 'Sign up' });
page.getByLabel('Password');
page.getByPlaceholder('Search...');
page.getByText('Welcome back');
// Good: test IDs for complex components without clear semantic roles
page.getByTestId('pricing-card-pro');
page.getByTestId('chart-container');
// Avoid: CSS selectors (brittle, coupled to implementation)
page.locator('.btn-primary'); // Class name can change
page.locator('#submit-button'); // ID might not be unique
page.locator('div > span:nth-child(2)'); // Structure changes break this
The golden rule: if a user can find the element by looking at the page, your locator should reflect that. Use getByRole and getByText first, fall back to getByTestId for elements that don't have clear text or ARIA roles.
Authentication Patterns
Most tests need an authenticated user. Playwright's storage state feature lets you authenticate once and reuse the session across all tests.
// tests/e2e/auth.setup.ts
import { test as setup, expect } from '@playwright/test';
const authFile = 'tests/.auth/user.json';
setup('authenticate', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('[email protected]');
await page.getByLabel('Password').fill('test-password');
await page.getByRole('button', { name: 'Sign in' }).click();
// Wait for redirect after login
await page.waitForURL('/dashboard');
// Save authentication state (cookies, localStorage)
await page.context().storageState({ path: authFile });
});
// playwright.config.ts - use the saved auth state
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: 'tests/.auth/user.json',
},
dependencies: ['setup'],
},
Now every test starts with an authenticated session, and the login flow only runs once per test suite execution.
Network Interception
Playwright can intercept and modify network requests. This is essential for testing error states, loading states, and edge cases without changing your backend.
test('shows error message when API fails', async ({ page }) => {
// Mock the API to return an error
await page.route('**/api/orders', (route) => {
route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: 'Internal server error' }),
});
});
await page.goto('/orders');
await expect(page.getByRole('alert'))
.toContainText('Failed to load orders');
await expect(page.getByRole('button', { name: 'Retry' }))
.toBeVisible();
});
test('shows loading skeleton while data fetches', async ({ page }) => {
// Delay the API response to test loading state
await page.route('**/api/orders', async (route) => {
await new Promise(resolve => setTimeout(resolve, 2000));
route.continue();
});
await page.goto('/orders');
// Loading skeleton should appear immediately
await expect(page.getByTestId('loading-skeleton')).toBeVisible();
// Then real content appears
await expect(page.getByTestId('order-list')).toBeVisible({ timeout: 5000 });
await expect(page.getByTestId('loading-skeleton')).not.toBeVisible();
});
Page Object Model
As your test suite grows, you'll notice duplication -- the same selectors and interaction patterns repeated across test files. The Page Object Model (POM) pattern extracts these into reusable classes.
// tests/page-objects/checkout-page.ts
import { type Page, type Locator, expect } from '@playwright/test';
export class CheckoutPage {
readonly page: Page;
readonly cartItems: Locator;
readonly subtotal: Locator;
readonly promoCodeInput: Locator;
readonly applyPromoButton: Locator;
readonly placeOrderButton: Locator;
constructor(page: Page) {
this.page = page;
this.cartItems = page.getByTestId('cart-item');
this.subtotal = page.getByTestId('subtotal');
this.promoCodeInput = page.getByLabel('Promo code');
this.applyPromoButton = page.getByRole('button', { name: 'Apply' });
this.placeOrderButton = page.getByRole('button', { name: 'Place order' });
}
async goto() {
await this.page.goto('/checkout');
await expect(this.page.getByRole('heading', { name: 'Checkout' }))
.toBeVisible();
}
async applyPromoCode(code: string) {
await this.promoCodeInput.fill(code);
await this.applyPromoButton.click();
// Wait for price to update
await this.page.waitForResponse('**/api/promo/validate');
}
async getSubtotal(): Promise<string> {
return await this.subtotal.textContent() ?? '';
}
async placeOrder() {
await this.placeOrderButton.click();
await this.page.waitForURL('/order-confirmation/**');
}
}
// tests/e2e/checkout.spec.ts
import { test, expect } from '@playwright/test';
import { CheckoutPage } from '../page-objects/checkout-page';
test('applies promo code and shows discount', async ({ page }) => {
const checkout = new CheckoutPage(page);
await checkout.goto();
const originalSubtotal = await checkout.getSubtotal();
await checkout.applyPromoCode('SAVE20');
const discountedSubtotal = await checkout.getSubtotal();
expect(parseFloat(discountedSubtotal.replace('$', '')))
.toBeLessThan(parseFloat(originalSubtotal.replace('$', '')));
});
Debugging Failed Tests
Playwright's debugging tools are genuinely excellent. Here's how to use them.
Trace Viewer
When a test fails, Playwright can capture a trace -- a recording of every action, network request, console message, and DOM snapshot.
# Run tests with traces
npx playwright test --trace on
# Open the trace viewer for a specific test
npx playwright show-trace test-results/checkout-spec-ts/trace.zip
The trace viewer shows a timeline of every action your test performed, with:
- DOM snapshots before and after each action
- Network requests and responses
- Console logs
- Source code location for each step
This is far more useful than screenshots. You can step through the test execution and see exactly what the page looked like when a locator failed to find an element.
UI Mode
For developing and debugging tests interactively:
npx playwright test --ui
UI mode gives you a live browser window, a test explorer, and the ability to re-run individual tests with a click. It's the fastest way to develop new tests.
Headed Mode and Slow Motion
# Run with visible browser
npx playwright test --headed
# Slow down execution to watch what's happening
npx playwright test --headed --slowmo=500
Codegen
Playwright can generate test code by recording your browser interactions:
npx playwright codegen http://localhost:3000
This opens a browser and records every click, fill, and navigation as Playwright test code. The generated code isn't perfect -- you'll want to refine the locators and add assertions -- but it's an excellent starting point. It's especially useful for learning Playwright's API.
CI Integration
GitHub Actions
# .github/workflows/e2e.yml
name: E2E Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps
- name: Run E2E tests
run: npx playwright test
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 7
- name: Upload traces for failed tests
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-traces
path: test-results/
retention-days: 7
Sharding for Speed
Large test suites benefit from parallel sharding:
jobs:
e2e:
strategy:
matrix:
shard: [1/4, 2/4, 3/4, 4/4]
steps:
- name: Run E2E tests (shard ${{ matrix.shard }})
run: npx playwright test --shard=${{ matrix.shard }}
This splits your tests across 4 parallel runners. A 20-minute suite becomes ~5 minutes.
Patterns for Reliable Tests
Avoid Sleeping
// Bad: arbitrary wait
await page.waitForTimeout(2000);
// Good: wait for specific condition
await expect(page.getByRole('alert')).toBeVisible();
await page.waitForResponse('**/api/data');
await expect(page.getByTestId('spinner')).not.toBeVisible();
Test Isolation
Each test should be independent. Never rely on tests running in a specific order.
test.describe('Order management', () => {
// Set up test data before each test
test.beforeEach(async ({ page }) => {
// Seed test data via API
await page.request.post('/api/test/seed', {
data: { orders: 5 },
});
await page.goto('/orders');
});
test('can delete an order', async ({ page }) => {
// This test doesn't depend on other tests creating orders
const orderRow = page.getByTestId('order-row').first();
await orderRow.getByRole('button', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Confirm' }).click();
await expect(page.getByTestId('order-row')).toHaveCount(4);
});
});
Use Fixtures for Complex Setup
// tests/fixtures/test-data.ts
import { test as base } from '@playwright/test';
type TestFixtures = {
seededUser: { email: string; password: string };
};
export const test = base.extend<TestFixtures>({
seededUser: async ({ page }, use) => {
// Create a unique user for this test
const email = `test-${Date.now()}@example.com`;
const password = 'test-password-123';
await page.request.post('/api/test/create-user', {
data: { email, password },
});
// Provide the fixture to the test
await use({ email, password });
// Cleanup after the test
await page.request.delete('/api/test/users', {
data: { email },
});
},
});
export { expect } from '@playwright/test';
// tests/e2e/user-settings.spec.ts
import { test, expect } from '../fixtures/test-data';
test('user can update their display name', async ({ page, seededUser }) => {
await page.goto('/login');
await page.getByLabel('Email').fill(seededUser.email);
await page.getByLabel('Password').fill(seededUser.password);
await page.getByRole('button', { name: 'Sign in' }).click();
await page.goto('/settings');
await page.getByLabel('Display name').fill('New Name');
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByRole('alert')).toContainText('Settings saved');
});
Visual Comparison Testing
Playwright has built-in screenshot comparison for catching visual regressions:
test('landing page matches snapshot', async ({ page }) => {
await page.goto('/');
// Wait for all images and fonts to load
await page.waitForLoadState('networkidle');
// Full-page screenshot comparison
await expect(page).toHaveScreenshot('landing-page.png', {
fullPage: true,
maxDiffPixels: 100, // Allow small rendering differences
});
});
test('checkout form renders correctly', async ({ page }) => {
await page.goto('/checkout');
// Component-level screenshot
const form = page.getByTestId('checkout-form');
await expect(form).toHaveScreenshot('checkout-form.png');
});
On first run, Playwright saves reference screenshots. On subsequent runs, it compares new screenshots against the references and fails if they differ beyond the threshold.
Update reference screenshots when intentional changes are made:
npx playwright test --update-snapshots
Performance Considerations
A healthy Playwright test suite runs in under 5 minutes on CI. If yours is slower, here are the levers to pull:
- Parallel workers: Playwright runs tests in parallel by default. Ensure your tests are truly independent so parallelism works.
- Sharding: Split across multiple CI runners for linear speedup.
- Browser contexts over new pages: Creating a new context is ~10x faster than launching a new browser.
- Skip unnecessary browsers: In PR checks, run only Chromium. Run the full matrix on nightly or pre-release.
- API seeding over UI setup: Create test data via API calls, not by clicking through forms. Reserve UI interactions for the behavior you're actually testing.
The investment in a well-structured Playwright test suite pays dividends every time you refactor confidently, catch a regression before users do, or onboard a new developer who can run the entire system's tests locally in minutes.