Playwright E2E Testing: Complete Guide from Setup to CI
Playwright E2E testing guide: browser setup, page object model, network mocking, API testing, visual regression, CI GitHub Actions config, and debugging with trace viewer.
End-to-end testing is where most teams feel the pain. Flaky tests, slow CI runs, cryptic failures β these are the reasons developers avoid E2E coverage. Playwright, released by Microsoft in 2020, was built specifically to fix these problems. In 2026 itβs the clear leader in browser automation, beating Cypress in flexibility, speed, and cross-browser support.
This guide walks from zero to production-grade E2E testing, including CI configuration and debugging techniques.
What Sets Playwright Apart
Before writing any code, itβs worth understanding why Playwright behaves differently from older browser automation tools.
Auto-wait everywhere. Playwright doesnβt need cy.wait(1000) or manual waits. Every action β click, fill, expect β automatically waits for the element to be visible, enabled, and stable. This eliminates the most common source of flaky tests.
Multi-browser, multi-context. A single test file can open Chrome, Firefox, and WebKit in parallel. You can also run multiple browser contexts (isolated sessions) within one test β useful for testing real-time features with two simultaneous users.
Codegen. playwright codegen https://myapp.com opens a browser, records your clicks and inputs, and outputs TypeScript test code. Itβs a great way to bootstrap tests fast.
Out-of-process. Playwright controls browsers via WebSocket, not JavaScript injection. This means it can test cross-origin iframes, service workers, and scenarios that break Cypressβs same-origin model.
Installation and First Test
npm init playwright@latest
This scaffolds a playwright.config.ts, an example test, and sets up browsers. Choose TypeScript when prompted.
Your first test:
// tests/home.spec.ts
import { test, expect } from '@playwright/test';
test('homepage has correct title', async ({ page }) => {
await page.goto('https://myapp.com');
await expect(page).toHaveTitle(/My App/);
});
test('user can search for tools', async ({ page }) => {
await page.goto('https://myapp.com/tools');
await page.getByPlaceholder('Search tools').fill('webpack');
await expect(page.getByTestId('tool-card')).toHaveCount(3);
});
Run it: npx playwright test
Locator Strategies
The single most important skill in Playwright is picking the right locator. Fragile locators (CSS classes, XPath) create brittle tests. Playwrightβs preferred hierarchy:
- Role + name β
page.getByRole('button', { name: 'Submit' }) - Label β
page.getByLabel('Email address') - Placeholder β
page.getByPlaceholder('Search...') - Text β
page.getByText('Sign in') - Test ID β
page.getByTestId('submit-btn')(adddata-testidto your HTML) - CSS / XPath β last resort only
// Good β role-based, resilient to CSS changes
await page.getByRole('button', { name: /save/i }).click();
// Good β label-based, links to accessibility
await page.getByLabel('Password').fill('secret');
// Avoid β tied to implementation details
await page.locator('.btn-primary.submit-action').click();
Configure your data-testid attribute in playwright.config.ts:
use: {
testIdAttribute: 'data-testid',
}
Page Object Model
For any non-trivial test suite, the Page Object Model (POM) prevents duplication and centralizes selectors. Each page or component gets its own class.
// tests/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.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 loginAndExpectError(email: string, password: string, message: string) {
await this.login(email, password);
await expect(this.errorMessage).toContainText(message);
}
}
// tests/auth.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';
test.describe('Authentication', () => {
test('valid credentials redirect to dashboard', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('[email protected]', 'correctpassword');
await expect(page).toHaveURL('/dashboard');
});
test('invalid credentials show error', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.loginAndExpectError(
'[email protected]',
'wrongpassword',
'Invalid credentials'
);
});
});
Network Request Mocking
Playwright can intercept and mock network requests with page.route(). This is invaluable for testing error states, slow responses, and edge cases that are hard to reproduce with real APIs.
test('shows error banner when API fails', async ({ page }) => {
// Mock the API to return a 500 error
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.getByRole('alert')).toContainText('Failed to load users');
});
test('handles slow API gracefully', async ({ page }) => {
await page.route('**/api/data', async route => {
await new Promise(resolve => setTimeout(resolve, 3000)); // 3s delay
await route.continue();
});
await page.goto('/dashboard');
await expect(page.getByTestId('loading-skeleton')).toBeVisible();
});
API Testing with the request Fixture
Playwright includes an HTTP client for API testing, useful for setting up test state or asserting backend responses directly.
import { test, expect } from '@playwright/test';
test('API creates user and returns 201', async ({ request }) => {
const response = await request.post('/api/users', {
data: {
name: 'Test User',
email: '[email protected]',
},
});
expect(response.status()).toBe(201);
const body = await response.json();
expect(body).toMatchObject({ name: 'Test User' });
});
test('API rejects duplicate email with 409', async ({ request }) => {
// First create
await request.post('/api/users', {
data: { name: 'Alice', email: '[email protected]' },
});
// Duplicate attempt
const response = await request.post('/api/users', {
data: { name: 'Alice 2', email: '[email protected]' },
});
expect(response.status()).toBe(409);
});
Visual Regression Testing
toHaveScreenshot() captures a screenshot and compares it against a stored baseline. On first run it saves the baseline; subsequent runs fail if pixels differ beyond a threshold.
test('homepage matches visual snapshot', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveScreenshot('homepage.png', {
maxDiffPixels: 100,
});
});
test('button hover state matches snapshot', async ({ page }) => {
await page.goto('/components');
const button = page.getByRole('button', { name: 'Primary Action' });
await button.hover();
await expect(button).toHaveScreenshot('button-hover.png');
});
Update snapshots when intentional changes are made: npx playwright test --update-snapshots
GitHub Actions CI Configuration
The key to fast CI is sharding β distributing tests across parallel workers.
# .github/workflows/playwright.yml
name: Playwright Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
name: Playwright Tests (Shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shardIndex: [1, 2, 3, 4]
shardTotal: [4]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Run Playwright tests
run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
env:
BASE_URL: ${{ secrets.STAGING_URL }}
- name: Upload test results
uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report-${{ matrix.shardIndex }}
path: playwright-report/
retention-days: 7
With 4 shards, a 200-test suite that takes 8 minutes sequentially completes in about 2 minutes.
Debugging with Trace Viewer
When a test fails in CI, the trace viewer lets you replay the exact browser session.
Enable traces in playwright.config.ts:
use: {
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'on-first-retry',
},
Open a trace locally after downloading from CI:
npx playwright show-trace trace.zip
The trace viewer shows every action, DOM state, network request, and console log at each step β far more useful than a stack trace alone.
For local debugging: npx playwright test --debug opens a headed browser with the Playwright Inspector, letting you step through your test one action at a time.
Playwright vs Cypress
| Feature | Playwright | Cypress |
|---|---|---|
| Browser support | Chrome, Firefox, WebKit, Edge | Chrome, Firefox, Edge (no WebKit) |
| Multi-tab testing | Yes | Limited |
| Cross-origin iframes | Yes | No |
| Node.js in tests | Full access | Limited (via tasks) |
| Auto-wait | Yes | Yes |
| Component testing | Yes (experimental) | Yes (mature) |
| Network mocking | route() API | cy.intercept() |
| Speed (E2E) | Faster | Slower on large suites |
| Dashboard / analytics | Playwright Cloud | Cypress Cloud (paid) |
| Open source | Yes (MIT) | Yes (MIT) |
The main reason to choose Cypress over Playwright is its component testing story, which is more mature. For full E2E, Playwright wins on speed and flexibility.
Conclusion
Playwright is the right choice for E2E testing in 2026. Auto-wait alone eliminates most flakiness. The Page Object Model keeps tests maintainable. Network mocking makes edge cases testable without brittle test data. And the GitHub Actions shard matrix makes CI fast enough that teams actually run E2E tests on every PR.
Start with a small number of high-value flows β login, checkout, critical user paths β then expand coverage incrementally. A focused suite of 50 reliable tests beats 500 flaky ones every time.
50 CLI Commands That Save Hours Every Week
The exact Git shortcuts, file search tricks, Docker commands, and productivity hacks that senior developers use daily. Compiled from 5+ years of shipping production code.
- Git mastery (10 cmds)
- File & search wizardry
- Docker & DevOps tricks
- Process & network tools
- Productivity boosters
- macOS & Linux install guide
No spam. 1 email/week max. Unsubscribe anytime.
Found this guide useful? Check out our free developer tools.
Affiliate disclosure: Some links below are affiliate links β we may earn a small commission at no extra cost to you. Learn more.
Recommended Tools & Resources
DigitalOcean
$200 credit for new users. Simple, affordable cloud hosting for developers.
GitHub Student Pack
Free access to 100+ developer tools. Perfect for students and new devs.
Vercel
Deploy frontend apps instantly. Free tier is generous for side projects.
DevPlaybook Products
Boilerplates, scripts & AI toolkits to 10x your dev workflow.