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
| Feature | Playwright | Cypress | Selenium |
|---|---|---|---|
| Multi-browser | Chromium, Firefox, WebKit | Chromium, Firefox, WebKit | All major browsers |
| Parallel execution | Built-in | Paid (Cypress Cloud) | Via Selenium Grid |
| Auto-waiting | Yes — actionability checks | Yes — retry-ability | No — manual waits |
| API testing | Built-in request API | cy.request() | No |
| Visual testing | Built-in screenshots | Plugin required | Plugin required |
| Network mocking | route() + HAR replay | cy.intercept() | No built-in |
| Tracing | Full trace viewer | Time-travel debug | No |
| Language support | JS, TS, Python, Java, C# | JS, TS only | All major languages |
| Mobile emulation | 30+ device presets | Viewport only | Via Appium |
| iframes | First-class support | Limited | switchTo().frame() |
| License | Apache 2.0 | MIT (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/MonthBest Practices
After running Playwright in production across dozens of projects, these patterns consistently lead to reliable, maintainable test suites:
- Use locators, not selectors. Prefer
getByRole(),getByLabel(),getByTestId()over CSS selectors. They survive refactors and match how users interact with the page. - Never use hard waits. Replace
page.waitForTimeout()with explicit assertions likeexpect(locator).toBeVisible(). Playwright's auto-waiting handles timing. - Isolate test data. Each test should create its own data and clean up after. Use fixtures for setup/teardown to prevent flaky tests from shared state.
- Keep tests focused. One test should verify one behavior. Long tests with many assertions are harder to debug when they fail.
- Use trace viewer for debugging. Enable
trace: 'on-first-retry'to get full DOM snapshots, network logs, and console output for failing tests. - Mock external services. Don't let third-party API flakiness cause test failures. Use
page.route()to mock everything outside your app boundary. - Run in CI with retries. Set
retries: 2in CI to handle transient failures from timing issues or resource constraints.