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
- Playwright Documentation
- Testing Overview - Testing philosophy and strategies
- Playwright vs. Cypress - Choosing your E2E framework
- Cypress - Alternative E2E framework
- Docker Deployment guide - Containerizing apps including E2E tests