Production-ready guide covering end-to-end testing strategy with playwright with implementation patterns, code examples, and anti-patterns for enterprise engineering teams.
End-to-end testing with Playwright ensures that a full user journey — from initial page load to final state persistence — behaves as expected across real browsers, network conditions, and user interactions. This strategy matters most during feature releases, when multiple services integrate through a shared UI, and when changes to the frontend may break downstream workflows without obvious symptoms. It is the final gate before production, where the system is tested as a whole, not just its components in isolation.
Define test scope using user scenarios as test entry points. Each test should represent one complete workflow: sign-up, checkout, file upload with preview, or real-time collaboration. A test begins with a clear expectation: *“After a user uploads a CSV file, the system displays a summary table with row counts, column types, and validation errors.”*
Use test files per feature in tests/feature-name.spec.ts, with describe() blocks for each scenario. Avoid monolithic e2e.spec.ts files. Use test.use() to configure browser context, environment, and initial state:
// tests/user-profile.spec.ts
import { test, expect } from '@playwright/test';
test.use({
storageState: 'state/user-signed-in.json',
baseURL: 'https://app.example.com',
viewport: { width: 1280, height: 720 },
});
test.describe('User profile editing', () => {
test('updates email and saves changes', async ({ page }) => {
await page.goto('/profile');
await page.fill('input#email', '[email protected]');
await page.click('button:has-text("Save")');
await expect(page.locator('text=Profile updated successfully')).toBeVisible();
});
test('uploads and previews avatar', async ({ page }) => {
await page.goto('/profile');
await page.setInputFiles('input[type="file"]', 'fixtures/avatar.jpg');
await expect(page.locator('img.avatar-preview')).toHaveAttribute('src', /avatar.jpg/);
});
});
Use playwright.config.ts to centralize settings across test runs. This file is the single source of truth for how tests are executed.
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: 'tests',
timeout: 30_000,
expect: {
timeout: 10_000,
},
fullyParallel: true,
/* Fail test on first assertion failure */
forbidOnly: true,
/* Run tests in parallel with 4 workers */
workers: 4,
/* Reporter to use */
reporter: [
['list'],
['html', { outputFolder: 'test-results/html' }],
['json', { outputFolder: 'test-results/json' }],
],
/* Global setup */
globalSetup: 'tests/global-setup.ts',
/* Global teardown */
globalTeardown: 'tests/global-teardown.ts',
/* Use the same browser context across tests */
use: {
baseURL: 'https://app.example.com',
trace: 'on-first-retry',
video: 'on',
screenshot: 'only-on-failure',
storageState: 'state/authenticated.json',
viewport: { width: 1280, height: 720 },
},
});
The trace: 'on-first-retry' option automatically records a video and trace file when a test fails, and re-runs the test with the trace attached. This is invaluable for diagnosing intermittent failures.
Global setup runs once before all tests, ideal for provisioning test data, seeding databases, and starting background services.
// tests/global-setup.ts
import { chromium } from '@playwright/test';
import { execSync } from 'child_process';
export default async function globalSetup() {
// Seed database with test data
execSync('npm run db:seed -- --env=test', { stdio: 'inherit' });
// Generate a reusable auth state
const browser = await chromium.launch();
const context = await browser.newContext();
const page = await context.newPage();
await page.goto('/login');
await page.fill('input#email', '[email protected]');
await page.fill('input#password', 'test123');
await page.click('button:has-text("Sign In")');
await page.waitForURL('/dashboard');
await context.storageState('state/authenticated.json');
await browser.close();
}
The storageState output is used by test.use() and can be regenerated via npx playwright test --update-snapshot.
Use page.waitForLoadState() and page.waitForFunction() to handle dynamic content. Never assume the page is fully loaded after page.goto().
await page.goto('/analytics');
await page.waitForLoadState('networkidle'); // Wait until no network activity for 500ms
await page.waitForFunction(() => document.querySelector('canvas.chart') !== null);
For interactions with slow or client-side rendered components, use page.waitForSelector() with explicit polling and timeout:
await page.waitForSelector('li:has-text("Pending Orders")', {
timeout: 15_000,
state: 'visible',
});
The state: 'visible' option ensures the element is both in the DOM and rendered on screen (not just in the DOM tree). The state: 'attached' option waits only for DOM insertion.
When a test fails, Playwright captures a full trace and video. To analyze, use the npx playwright show-trace command:
npx playwright show-trace ./test-results/12345/trace.zip
This opens a browser-based viewer with:
Use trace: 'on' in test.use() to enable trace collection for all tests. For production-like debugging, set trace: 'retain-on-failure' to keep traces only when a test fails.
Simulate real-world conditions using test.use() with networkConditions and device options:
test.use({
device: 'iPhone 13',
networkConditions: 'slow-3g',
});
test('loads homepage on mobile with slow network', async ({ page }) => {
await page.goto('/');
await expect(page.locator('h1')).toHaveText('Welcome to Our App');
});
Playwright provides built-in network conditions: fast-3g, slow-3g, offline, and custom for fine-grained control.
For custom network conditions, define a JSON file network-conditions.json:
{
"downloadThroughput": 50000,
"uploadThroughput": 20000,
"latency": 150
}
Then use it in playwright.config.ts:
use: {
networkConditions: 'network-conditions.json',
}
Use test.use() within test.each() to run the same test logic with different inputs.
test.each([
{ email: '[email protected]', expected: 'User Dashboard' },
{ email: '[email protected]', expected: 'Admin Panel' },
{ email: '[email protected]', expected: 'Public Homepage' },
])('loads correct dashboard for $email', async ({ page }, { email, expected }) => {
await page.goto('/login');
await page.fill('input#email', email);
await page.click('button:has-text("Sign In")');
await expect(page).toHaveTitle(expected);
});
This pattern avoids boilerplate and allows test authors to define test cases in a table-like format, readable and maintainable.
In CI, run tests in parallel with --workers=4 and --parallel flags:
npx playwright test --workers=4 --parallel --reporter=github
Use --update-snapshot to keep expectations up to date across test runs:
npx playwright test --update-snapshot --retries=2
This command re-runs failed tests, updates snapshots, and runs them in parallel.
Auth state is managed via storageState — a JSON file containing cookies, local storage, and session data. Generate it using context.storageState(path) and reuse it across test suites.
To ensure test stability, always log out and reset state before a test suite:
test.beforeEach(async ({ page }) => {
await page.goto('/logout');
await page.waitForLoadState('networkidle');
});
Use page.addInitScript() to inject JavaScript before the page loads, such as setting mock window.location or enabling debug flags:
test.use({
viewport: { width: 1440, height: 900 },
storageState: 'state/authenticated.json',
// Pre-load script for debugging
addInitScript: `
window.debug = true;
console.log('Test environment initialized');
`,
});
page.click() fails silently if element is not visible. Always use await page.waitForSelector() before critical actions. page.waitForSelector() uses a 5-second default timeout, but page.waitForFunction() uses 30 seconds. Misconfiguring timeouts leads to flaky tests. page.fill() does not trigger input or change events unless explicitly configured. Use page.fill(selector, value, { force: true }) to ensure events are fired. context.storageState() includes all cookies, localStorage, and sessionStorage, but does not persist service workers or IndexedDB. Use context.addInitScript() to write to IndexedDB before saving state. trace: 'on-first-retry' records only the first retry, not the full sequence of retries. Use trace: 'on' for full debugging. playwright test does not automatically generate test-results directory unless reporter is configured. Missing result files in CI pipelines are common. test.use() overrides playwright.config.ts settings, but test.use() in describe() blocks is scoped to that block. Use test.use() in beforeEach() to apply changes to multiple tests.Start a test run with:
npx playwright test
To run a single test file:
npx playwright test tests/user-profile.spec.ts
To debug a single test with interactive mode:
npx playwright test --debug
This opens a browser with the Playwright Inspector, allowing step-by-step execution, breakpoint setting, and live inspection of page state.
| Command | Purpose |
|---|---|
npx playwright test | Run all tests with default config |
npx playwright test --workers=4 | Run tests in parallel |
npx playwright test --update-snapshot | Update all snapshots on failure |
npx playwright show-trace trace.zip | Open trace viewer in browser |
npx playwright test --retries=2 | Retry failed tests up to two times |
npx playwright test --project=mobile | Run tests with project-specific config |
npx playwright test --reporter=html | Generate HTML report |
This strategy ensures that Playwright-driven end-to-end tests are robust, maintainable, and deeply integrated into the development workflow. They are not just a test suite, but a living validation of the entire system.
This page was rewritten on 18 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.
We use cookies for analytics (Google Analytics) and advertising (Google AdSense) to improve your experience and support free content. Privacy Policy