Start hereQuick start · 6 you’ll reach for daily
Target versions · paceVersions
@playwright/test ≥ 1.45
playwright (py) ≥ 1.45
node ≥ 18
Playwright ships a new minor every ~6 weeks — browsers bundled and pinned, no Selenium-style version drift.
Test isolation runs each test in a fresh BrowserContext (cookies / storage / cache cleared).
Web-first assertions auto-retry until expect.timeout — never wrap them in setTimeout.
UI Mode (--ui) and the Trace Viewer cover ~90% of debugging. This sheet pins to 1.45+.
Install · run · recordSetup
# Node / TypeScript — the canonical path
npm init playwright@latest
# → installs @playwright/test + Chromium, Firefox, WebKit binaries
# → scaffolds tests/, playwright.config.ts, .github/workflows/playwright.yml
npx playwright test # all browsers, all projects
npx playwright test --ui # interactive Trace + watch mode
npx playwright test login.spec.ts --headed # see the browser
npx playwright test -g "checkout" # filter by title (regex)
npx playwright test --workers=4 # parallel processes
npx playwright codegen example.com # record a test from clicks
# Python — pytest plugin
pip install pytest-playwright
playwright install # download browser binaries
pytest --browser chromium --headed
# Reports
npx playwright show-report # open last HTML report
npx playwright show-trace trace.zip # open a captured trace
Where things liveCommon imports
| import { test, expect } from '@playwright/test' | Test runner entry. Preferred over the bare playwright library. |
| import { defineConfig, devices } from '@playwright/test' | Config + device descriptors (iPhone, Pixel, Desktop Chrome...). |
| import { chromium, firefox, webkit } from 'playwright' | Library-mode launchers — for scripts / scrapers, not tests. |
| import { request } from '@playwright/test' | API-only request context. Skip the browser entirely. |
| from playwright.sync_api import sync_playwright, expect | Python sync API. |
| from playwright.async_api import async_playwright | Python async API. |
Auto-wait · strict-by-defaultLocators
Recommended (a11y-first)
| page.getByRole('button', { name: 'Pay' }) | By ARIA role + accessible name. Preferred. |
| page.getByLabel('Email') | Form input by its <label>. |
| page.getByPlaceholder('you@x.com') | Input by placeholder. |
| page.getByText('Sign in', { exact: true }) | Visible text. |
| page.getByAltText('Logo') | Image by alt. |
| page.getByTitle('Settings') | Element by title attribute. |
| page.getByTestId('checkout-cta') | By data-testid. Configure attribute via testIdAttribute. |
Fallback / refinement
| page.locator('css=.product:visible') | CSS — explicit engine prefix. |
| page.locator('text=Pay now') | Built-in text engine. |
| page.locator('xpath=//button[@aria-pressed]') | XPath — last resort. |
| loc.filter({ hasText: 'Pro' }) | Narrow a list by inner text. |
| loc.filter({ has: page.getByRole('link') }) | Narrow by descendant matching another locator. |
| loc.nth(0) / loc.first() / loc.last() | Pick from a multi-match. |
| loc.or(loc2) / loc.and(loc2) | Boolean combine (1.30+). |
| page.frameLocator('iframe[name=stripe]').getByLabel('Card number') | Cross into an iframe. |
click · fill · type · hoverActions
| loc.click({ button: 'right', clickCount: 2 }) | Auto-waits for actionability, then clicks. |
| loc.dblclick() / loc.tap() | Double-click / mobile tap. |
| loc.fill('hello') | Clear + type. Preferred over type() for forms. |
| loc.pressSequentially('hello', { delay: 50 }) | Per-key dispatch (when you need keydown handlers to fire). |
| loc.press('Enter') / page.keyboard.press('Shift+Tab') | Single key / modifier combo. |
| loc.check() / loc.uncheck() / loc.setChecked(true) | Checkboxes + radios. |
| loc.selectOption('us') / selectOption(['a','b']) | Native <select>. |
| loc.setInputFiles('./photo.png') | File upload (real or virtual file). |
| loc.hover() / loc.focus() / loc.blur() | Pointer + focus states. |
| loc.dragTo(target) | High-level drag-and-drop. |
| page.goto('/path', { waitUntil: 'domcontentloaded' }) | Navigate. waitUntil: load | domcontentloaded | networkidle | commit. |
| page.evaluate(([sel]) => document.querySelector(sel).textContent, ['#x']) | Run JS in page context. |
Web-first · auto-retryAssertions
| await expect(loc).toBeVisible() | Retries until the element is visible or timeout. Preferred over isVisible(). |
| toBeHidden() / toBeAttached() / toBeEnabled() / toBeDisabled() | State assertions. |
| toHaveText('Welcome', { useInnerText: true }) | Exact text match (string or regex). Array compares the list. |
| toContainText('Welcome') | Substring match. |
| toHaveValue('foo') | Input value. |
| toHaveAttribute('disabled', '') / toHaveClass(/active/) | Attribute / class assertions. |
| toHaveCount(3) | List length. |
| toHaveURL('/checkout') / toHaveTitle('Pay') | Page-level assertions. |
| toHaveScreenshot('home.png', { maxDiffPixelRatio: 0.02 }) | Visual snapshot. Auto-updates on first run. |
| expect.soft(loc).toBeVisible() | Soft assertion — test keeps going, fails at end. |
| expect.poll(async () => await api.get(), { timeout: 5000 }).toEqual(...) | Poll any async function until it matches. |
page.waitForTimeout(). Web-first assertions are the wait — arbitrary sleeps cause flake.
playwright.config.tsConfig
| defineConfig({ testDir, projects, use, webServer }) | Top-level config shape. |
| use.baseURL | Prefix for relative page.goto('/x'). Preferred over hard-coded URLs. |
| use.trace: 'on' | 'on-first-retry' | 'retain-on-failure' | Capture network + DOM steps for the Trace Viewer. |
| use.screenshot: 'only-on-failure' | PNG attached to the report. |
| use.video: 'retain-on-failure' | WebM video. |
| use.actionTimeout / navigationTimeout | Per-action vs per-navigation budgets. |
| use.storageState: './state.json' | Pre-seed cookies / localStorage. Use to skip login per test. |
| projects: [{ name, use: devices['iPhone 15'] }] | Run the same suite across browsers / devices. |
| webServer: { command, url, reuseExistingServer } | Spin up your app before tests, tear down after. |
| retries: 2 / fullyParallel: true | CI defaults. |
| reporter: 'html' | 'list' | 'github' | 'json' | Output format. Multiple allowed. |
// playwright.config.ts — the knobs you'll touch most
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? '50%' : undefined,
use: {
baseURL: process.env.BASE_URL ?? 'http://localhost:3000',
headless: true,
trace: 'on-first-retry', // capture network/DOM only on retries
video: 'retain-on-failure',
screenshot: 'only-on-failure',
actionTimeout: 10_000,
navigationTimeout: 30_000,
locale: 'en-US',
timezoneId: 'Europe/London',
viewport: { width: 1280, height: 800 },
},
// Multi-browser fanout — each becomes its own report group
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
{ name: 'mobile-safari', use: { ...devices['iPhone 15'] } },
],
// Boot the app before tests
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
test.extend · auth stateFixtures
| { page, browser, context, request } | Built-in test fixtures — injected by destructuring. |
| test.extend<{ admin: Page }>({ admin: async ({ browser }, use) => ... }) | Define a custom fixture. Preferred over helper functions. |
| test.beforeAll / afterAll / beforeEach / afterEach | Lifecycle hooks. All hooks share state at the suite level. |
| test.use({ storageState: 'auth.json' }) | Reuse a logged-in session. |
| setup project + dependencies | Run a special "login" project once, then dependent projects reuse the state file. |
| test.step('login', async () => { ... }) | Group actions — nicer trace viewer + report tree. |
| test.skip() / test.fixme() / test.fail() | Mark tests for runners + report grouping. |
| test.describe.parallel.serial | Force serial order within a describe block. |
Intercept · mock · observeNetwork & mocking
| page.route('**/api/x', route => route.fulfill({ body })) | Stub a response. |
| route.continue({ headers, postData }) | Forward with modifications. |
| route.abort('blockedbyclient') | Kill the request. |
| page.unroute(pattern) | Drop a previously installed route. |
| page.routeFromHAR('flow.har', { update: false }) | Replay recorded traffic. |
| page.waitForResponse(/.*\/checkout/) | Deterministic wait for a specific response. |
| page.waitForRequest(...) | Same for requests — useful in fire-and-forget flows. |
| request.newContext({ baseURL, extraHTTPHeaders }) | API-only context — no browser, just HTTP. |
| api.post('/login', { data: {...} }) | Seed state via API before opening the page. |
// Intercept + stub a JSON API
await page.route('**/api/users/*', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ id: 42, name: 'Ana', plan: 'pro' }),
});
});
// Modify an in-flight request (e.g. inject a header)
await page.route('**/api/**', async route => {
const headers = { ...route.request().headers(), 'x-trace-id': 'abc' };
await route.continue({ headers });
});
// Block a 3rd-party tracker entirely
await page.route('**/analytics.js', route => route.abort());
// Listen, don't intercept — read response bodies
page.on('response', async resp => {
if (resp.url().includes('/api/orders'))
console.log(resp.status(), await resp.json());
});
// Wait for a specific response (deterministic — beats waitForTimeout)
const [resp] = await Promise.all([
page.waitForResponse(r => r.url().endsWith('/checkout') && r.status() === 200),
page.click('button:has-text("Pay")'),
]);
expect((await resp.json()).orderId).toBeTruthy();
UI Mode · trace viewerTraces & debugging
| npx playwright test --ui | Interactive watch + time-travel debugger. Preferred dev workflow. |
| npx playwright test --debug | Step through with the Playwright Inspector. Pauses at start. |
| await page.pause() | Inline breakpoint that opens the inspector. |
| PWDEBUG=1 npx playwright test | Env-var equivalent. |
| npx playwright codegen example.com | Record clicks into a test file. |
| npx playwright show-trace trace.zip | Inspect a CI failure trace locally. |
| trace: 'on-first-retry' | Cheap default — traces only when a test re-runs. |
| page.screenshot({ path, fullPage: true }) | Ad-hoc screenshot. |
| await page.video()?.path() | Video file path for the current test (when video is on). |
Workers · shards · shardingParallelism & CI
| npx playwright test --workers=4 | Parallel worker processes. Default = half the CPUs. |
| npx playwright test --shard=2/4 | Split the suite across 4 CI machines. |
| fullyParallel: true | Run tests within a file in parallel too. |
| test.describe.configure({ mode: 'serial' }) | Force serial order in one describe (when tests share state). |
| retries: process.env.CI ? 2 : 0 | Retry flaky tests on CI only. |
| reporter: [['html', { open: 'never' }], ['github']] | HTML artifact + inline GitHub annotations. |
| testInfo.attachments.push({ name, body, contentType }) | Attach custom artifacts to the report. |
| npx playwright test --grep-invert @flaky | Tag-style exclusion (uses test titles). |
Checkout flowEnd-to-end · Login · cart · pay
A complete TypeScript test — reuses a saved auth state, drives the UI, deterministically waits for the checkout API.
// Full login → cart → checkout flow with auto-wait + storage state reuse.
import { test, expect } from '@playwright/test';
// Reuse a logged-in session across tests
test.use({ storageState: 'storage/user.json' });
test('checkout flow', async ({ page }) => {
await page.goto('/shop'); // baseURL prepends
// Add to cart
await page.getByRole('button', { name: 'Add Mug' }).click();
await expect(page.getByTestId('cart-count')).toHaveText('1');
// Open cart drawer + go to checkout
await page.getByRole('navigation').getByRole('link', { name: 'Cart' }).click();
await page.getByRole('button', { name: /checkout/i }).click();
// Fill the form by labels (a11y-friendly)
await page.getByLabel('Card number').fill('4242 4242 4242 4242');
await page.getByLabel('Expiry').fill('12/29');
await page.getByLabel('CVC').fill('123');
// Wait for the success response — deterministic
const [resp] = await Promise.all([
page.waitForResponse(r => r.url().endsWith('/checkout') && r.ok()),
page.getByRole('button', { name: 'Pay' }).click(),
]);
expect((await resp.json()).status).toBe('paid');
// Web-first assertion — auto-retries until visible or test times out
await expect(page.getByText('Thank you for your order')).toBeVisible();
});
Best practiceGood to know
getByRole('button', { name: 'Pay' }) matches what assistive tech sees. CSS selectors break on every redesign; role + name survive.
storageState to skip login.
Run a one-time setup project that logs in and saves cookies / localStorage to a JSON file. Every other project loads that state — no per-test login, no flake on auth boundary.
console.log.
trace: 'on-first-retry' captures the full DOM, network, console, and time-travel snapshots on flake. The viewer beats reading logs and is free in CI.
Common trapsWatch out for
waitForTimeout() calls.
Fixed sleeps are the #1 source of flake. Replace them with expect(loc).toBeVisible(), waitForResponse, or expect.poll.
undefined; assertions on them must be awaited.
expect(loc).toBeVisible() without await resolves to undefined and your test always "passes". The ESLint plugin eslint-plugin-playwright catches this.
frameLocator, not page.locator.
Stripe Elements, payment SDKs, embedded widgets — all live in iframes. page.frameLocator('iframe[name=...]').getByLabel(...) crosses the boundary; the bare locator silently times out.