cheat sheet
Playwright
Cross-browser end-to-end test runner from Microsoft with auto-waiting, role-based selectors, trace viewer, and codegen.
Playwright — End-to-end browser testing across Chromium, Firefox, WebKit
What it is
Playwright is an end-to-end browser test framework maintained by Microsoft. It drives real browser engines — Chromium, WebKit (Safari), and Firefox — through their respective DevTools / debugging protocols and ships with a Node test runner, a trace viewer for time-travel debugging, a code generator that records a session into test source, and first-class auto-waiting that eliminates the sleep() calls older E2E frameworks needed. It is the modern alternative to Selenium (slower, flakier, no auto-wait) and Cypress (single-browser at the time of writing for the open-source tier, no native WebKit). For component-level UI tests you'd still reach for Vitest + Testing Library; Playwright is the right tool whenever the test needs a real browser process, real network, and real DOM.
Install
The recommended install path is npm init playwright@latest, which interactively scaffolds a test directory, the config file, browser binaries, and an example test. Browser binaries are not npm packages — they live in ~/.cache/ms-playwright/ and are downloaded by the install command.
# Interactive scaffold (recommended)
npm init playwright@latest
# Manual install
npm install -D @playwright/test
npx playwright install # download all 3 browsers
npx playwright install chromium # only one
npx playwright install --with-deps # also install Linux system libs (CI)
# pnpm / yarn / bun
pnpm create playwright
yarn create playwright
bun create playwright
Output (npm init):
Getting started with writing end-to-end tests with Playwright:
Initializing project in '.'
✔ Do you want to use TypeScript or JavaScript? · TypeScript
✔ Where to put your end-to-end tests? · tests
✔ Add a GitHub Actions workflow? · true
✔ Install Playwright browsers? · true
Installing Playwright Test...
Downloading Chromium 130.0.6723.31 (playwright build v1140)...
Downloading Firefox 132.0 (playwright build v1466)...
Downloading Webkit 18.4 (playwright build v2123)...
Writing playwright.config.ts.
Writing tests/example.spec.ts.
Writing .github/workflows/playwright.yml.
Writing package.json.
✔ Success! Created a Playwright Test project at /home/alice/myproject
Inside that directory, you can run several commands:
npx playwright test
Runs the end-to-end tests.
npx playwright show-report
Opens HTML report of the last test run.
npx playwright codegen
Auto generate tests with Codegen.
Syntax
A Playwright test is a regular *.spec.ts (or .spec.js) file that imports { test, expect } from @playwright/test. Each test(name, fn) receives a fixture object — most commonly { page } — and expect is a Playwright-aware matcher that retries assertions until they pass or hit a timeout.
import { test, expect } from "@playwright/test";
test("name of test", async ({ page }) => {
await page.goto("https://example.com/");
await expect(page.getByRole("heading")).toBeVisible();
});
Output: (none — exits 0 on success)
Running tests
The playwright test command discovers all *.spec.{ts,js} files under tests/ (or testDir in config), runs them in parallel across worker processes, and writes an HTML report to playwright-report/.
# Run all tests, all browsers
npx playwright test
# Run a specific file
npx playwright test tests/login.spec.ts
# Run tests matching a name
npx playwright test --grep "checkout"
# Run only on chromium
npx playwright test --project=chromium
# Run headed (visible browser window)
npx playwright test --headed
# Debug — opens Playwright Inspector and pauses
npx playwright test --debug
# UI mode (interactive runner, recommended in dev)
npx playwright test --ui
# Update snapshots / golden files
npx playwright test --update-snapshots
# Open the HTML report from the last run
npx playwright show-report
Output (default reporter):
Running 6 tests using 3 workers
✓ [chromium] › tests/login.spec.ts:5:5 › user can log in (842ms)
✓ [firefox] › tests/login.spec.ts:5:5 › user can log in (1.1s)
✓ [webkit] › tests/login.spec.ts:5:5 › user can log in (920ms)
✓ [chromium] › tests/cart.spec.ts:8:5 › cart total updates (612ms)
✓ [firefox] › tests/cart.spec.ts:8:5 › cart total updates (740ms)
✓ [webkit] › tests/cart.spec.ts:8:5 › cart total updates (689ms)
6 passed (4.9s)
To open last HTML report run:
npx playwright show-report
playwright.config.ts — projects, devices, base URL
The config file controls every aspect of a run: where tests live, which browsers / devices to run them on (projects), retries, parallelism, reporters, screenshots / videos / traces, and the global baseURL so test code can use relative paths.
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"], ["list"]],
use: {
baseURL: "http://localhost:4321",
trace: "on-first-retry", // "off" | "on" | "retain-on-failure" | "on-first-retry"
screenshot: "only-on-failure",
video: "retain-on-failure",
actionTimeout: 10_000,
navigationTimeout: 30_000,
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
{ name: "webkit", use: { ...devices["Desktop Safari"] } },
{ name: "mobile", use: { ...devices["Pixel 7"] } },
{ name: "ipad", use: { ...devices["iPad Pro 11"] } },
],
// Boot your dev server before tests run
webServer: {
command: "npm run dev",
url: "http://localhost:4321",
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
});
Use
webServerinstead of starting your app in a separate terminal. Playwright will spawn the command, wait for the URL to respond, run the tests, and tear it down — even on Ctrl-C.reuseExistingServer: truemakes local iteration fast by skipping the boot when the server is already up.
Locators and role-based selectors
A locator is a lazy, retryable handle to an element. Playwright re-resolves the DOM on every action, so a locator from before navigation still works after. The strongly recommended selectors are role-based (getByRole) and accessible-name based (getByLabel, getByText) — they double as accessibility tests.
import { test, expect } from "@playwright/test";
test("locator strategies", async ({ page }) => {
await page.goto("/");
// Role-based (preferred)
await page.getByRole("button", { name: "Sign in" }).click();
await page.getByRole("textbox", { name: "Email" }).fill("alice@example.com");
await page.getByRole("link", { name: "Forgot password?" }).click();
// Label-based (for form inputs)
await page.getByLabel("Password").fill("hunter2");
// Placeholder
await page.getByPlaceholder("Search…").fill("playwright");
// Text content
await page.getByText("Welcome back, Alice").click();
// Test-id (last-resort selector, requires data-testid attribute)
await page.getByTestId("checkout-button").click();
// Chained scoping — find a button inside a specific list item
const item = page.getByRole("listitem").filter({ hasText: "Project Apollo" });
await item.getByRole("button", { name: "Delete" }).click();
// CSS / XPath escape hatches (avoid unless necessary)
await page.locator("css=.brittle-class").click();
await page.locator('xpath=//*[@id="legacy"]/button').click();
});
Output: (none — exits 0 on success)
Auto-waiting and assertions
Every Playwright action (click, fill, check) automatically waits for the element to be attached, visible, stable (not animating), and enabled before acting. Every expect(locator).toBe* matcher retries until the assertion passes or hits the assertion timeout (default 5 s). You almost never need waitForTimeout — its presence in a test usually signals a hidden race condition to fix instead.
test("auto-waiting", async ({ page }) => {
await page.goto("/dashboard");
// No need to wait for the heading to render — toBeVisible() retries
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
// Click waits for the button to become enabled
await page.getByRole("button", { name: "Refresh" }).click();
// Assertion retries until the badge text matches
await expect(page.getByTestId("notification-count")).toHaveText("3");
// Counting elements that may load asynchronously
await expect(page.getByRole("listitem")).toHaveCount(10);
// URL assertion — retries until navigation completes
await expect(page).toHaveURL(/.*\/dashboard\/projects/);
// Negative assertions also retry
await expect(page.getByText("Loading…")).toBeHidden();
});
Output: (none — exits 0 on success)
Fixtures — { page }, custom fixtures, and test.use
Fixtures are the dependency-injection system Playwright uses to give each test fresh, isolated state. Built-in fixtures include page (a fresh page in a fresh context), context (the browser context — cookies, storage), browser (shared across tests), and request (an API context for making HTTP calls without a page). You declare custom fixtures with test.extend(...).
// fixtures.ts
import { test as base, expect } from "@playwright/test";
type Fixtures = {
authedPage: import("@playwright/test").Page;
apiUser: { id: number; email: string };
};
export const test = base.extend<Fixtures>({
// A page pre-authenticated as Alice
authedPage: async ({ browser }, use) => {
const context = await browser.newContext({
storageState: "tests/.auth/alice.json",
});
const page = await context.newPage();
await use(page);
await context.close();
},
// A user created via the API before the test, deleted after
apiUser: async ({ request }, use) => {
const res = await request.post("/api/users", {
data: { email: "alice@example.com" },
});
const user = await res.json();
await use(user);
await request.delete(`/api/users/${user.id}`);
},
});
export { expect };
// tests/profile.spec.ts
import { test, expect } from "./fixtures";
test("profile loads", async ({ authedPage, apiUser }) => {
await authedPage.goto(`/users/${apiUser.id}`);
await expect(authedPage.getByRole("heading")).toHaveText(apiUser.email);
});
Output: (none — exits 0 on success)
Authentication — store and reuse session state
For apps where every test needs to be logged in, log in once in a setup project, dump cookies + localStorage to a JSON file, then load that state in every test's browser context. This avoids re-logging-in on every test (slow + risks rate-limiting the auth provider).
// playwright.config.ts (project addition)
{
projects: [
{
name: "setup",
testMatch: /.*\.setup\.ts/,
},
{
name: "chromium",
use: { ...devices["Desktop Chrome"], storageState: "tests/.auth/alice.json" },
dependencies: ["setup"],
},
],
}
// tests/auth.setup.ts
import { test as setup, expect } from "@playwright/test";
setup("authenticate as Alice", async ({ page }) => {
await page.goto("/login");
await page.getByLabel("Email").fill("alice@example.com");
await page.getByLabel("Password").fill(process.env.TEST_PASSWORD!);
await page.getByRole("button", { name: "Sign in" }).click();
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
// Dump cookies + localStorage to a file the test projects will load
await page.context().storageState({ path: "tests/.auth/alice.json" });
});
Output: (none — exits 0 on success)
Network mocking with page.route
page.route(urlPattern, handler) intercepts requests before they hit the network. Use it to stub API responses (faster, deterministic), simulate errors (4xx/5xx, slow networks, timeouts), or block third-party trackers. Patterns can be globs or RegExp.
test("handles 500 from API gracefully", async ({ page }) => {
// Stub a 500 from the API
await page.route("**/api/users", (route) =>
route.fulfill({ status: 500, body: "Internal Error" }),
);
await page.goto("/users");
await expect(page.getByText("Something went wrong")).toBeVisible();
});
test("returns canned JSON", async ({ page }) => {
await page.route("**/api/projects", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([
{ id: 1, name: "Apollo" },
{ id: 2, name: "Borealis" },
]),
});
});
await page.goto("/projects");
await expect(page.getByRole("listitem")).toHaveCount(2);
});
test("blocks third-party trackers", async ({ page }) => {
await page.route(/google-analytics|doubleclick|hotjar/, (r) => r.abort());
await page.goto("/");
// page loads faster + no analytics noise
});
Output: (none — exits 0 on success)
API testing without a browser
test.extend({ request }) gives every test an APIRequestContext that speaks HTTP. Use it for pure API tests (no browser needed, much faster) or for setup steps inside browser tests.
import { test, expect } from "@playwright/test";
test.describe("API", () => {
test("creates and reads a user", async ({ request }) => {
// POST
const create = await request.post("/api/users", {
data: { email: "alice@example.com", name: "Alice Dev" },
});
expect(create.ok()).toBeTruthy();
const user = await create.json();
expect(user.id).toBeGreaterThan(0);
// GET
const read = await request.get(`/api/users/${user.id}`);
expect(await read.json()).toMatchObject({ email: "alice@example.com" });
// DELETE
const del = await request.delete(`/api/users/${user.id}`);
expect(del.status()).toBe(204);
});
});
Output: (none — exits 0 on success)
Visual regression testing
toHaveScreenshot() captures a screenshot and diffs it against a stored baseline (per project/browser). On first run, the baseline is created; on subsequent runs the diff is computed pixel-by-pixel and the test fails if the difference exceeds the tolerance.
test("dashboard looks right", async ({ page }) => {
await page.goto("/dashboard");
await expect(page).toHaveScreenshot("dashboard.png", {
maxDiffPixels: 50,
mask: [page.getByTestId("timestamp")], // ignore noisy regions
animations: "disabled",
});
});
test("button in hover state", async ({ page }) => {
await page.goto("/components/buttons");
const btn = page.getByRole("button", { name: "Submit" });
await btn.hover();
await expect(btn).toHaveScreenshot("submit-hover.png");
});
Update baselines after intentional UI changes:
npx playwright test --update-snapshots
Output: (none — exits 0 on success)
Trace viewer and codegen
The trace viewer is Playwright's killer feature — a per-action timeline with DOM snapshots, network logs, console messages, and source-line linking, all from a single .zip file produced when a test fails. Codegen records a browsing session and emits idiomatic Playwright code in real time.
# Generate a test by recording a session
npx playwright codegen https://example.com
# Codegen with device emulation
npx playwright codegen --device="iPhone 14" https://example.com
# Codegen with auth state preloaded
npx playwright codegen --load-storage=tests/.auth/alice.json https://example.com
# Open a trace from a failed run
npx playwright show-trace test-results/login-chromium/trace.zip
# Open the HTML report (includes traces inline)
npx playwright show-report
Output: (none — exits 0 on success)
Set
trace: 'on-first-retry'in your config. Traces are large (5-50 MB) but invaluable on flaky failures. Combined withretries: 2in CI, you get a trace exactly when something fails non-deterministically, with zero overhead on green runs.
UI mode
npx playwright test --ui opens a desktop app that lists every test, lets you run any subset, and shows the full trace timeline inline as the test runs. It's the recommended local development workflow.
npx playwright test --ui
# Pick a single test file
npx playwright test --ui tests/login.spec.ts
Output:
──────────────────────────────────────
Playwright Test - UI Mode
──────────────────────────────────────
→ opens browser at file:///… with full test explorer
→ click any test to run + see live trace
→ red dot = breakpoint, can step through action by action
Test organisation
test.describe, test.beforeAll, test.beforeEach, test.afterEach, and test.afterAll mirror the API from Jest/Vitest. Use test.serial when tests in a describe block must run in order (e.g. the second test depends on state created by the first).
test.describe("checkout flow", () => {
test.use({ baseURL: "https://staging.example.com" });
let cartId: string;
test.beforeAll(async ({ request }) => {
const res = await request.post("/api/carts");
cartId = (await res.json()).id;
});
test.afterAll(async ({ request }) => {
await request.delete(`/api/carts/${cartId}`);
});
test.beforeEach(async ({ page }) => {
await page.goto(`/cart/${cartId}`);
});
test("adds an item", async ({ page }) => { /* ... */ });
test("removes an item", async ({ page }) => { /* ... */ });
test.describe.serial("multi-step", () => {
test("step 1", async () => { /* ... */ });
test("step 2 (depends on step 1)", async () => { /* ... */ });
});
});
Output: (none — exits 0 on success)
Comparison with Cypress and Selenium
| Aspect | Selenium | Cypress | Playwright |
|---|---|---|---|
| Engines | All real browsers | Chromium + Firefox + WebKit (cloud only) | Chromium + Firefox + WebKit |
| Architecture | Out-of-process WebDriver | In-page (runs inside the tab) | Out-of-process DevTools protocol |
| Auto-waiting | No (manual WebDriverWait) | Yes | Yes |
| Parallelism | Process-level (manual) | Single browser per run; parallel via Cypress Cloud | Built-in worker parallelism |
| Multi-tab / multi-origin | Limited | Limited / paid feature | First-class |
| Trace viewer | No | Time-travel debugger (in-tab) | Stand-alone trace viewer |
| Language | Many (Java, Python, JS…) | JS/TS only | TS/JS, Python, Java, .NET |
| iFrame handling | Manual | Limited | Native (frameLocator) |
| Mobile | Appium (separate stack) | No native | Built-in device emulation |
| Network mocking | No | Yes (cy.intercept) | Yes (page.route) |
| CI cost (parallelism) | Free | Free with --parallel (no cloud), paid for cloud | Free |
Common pitfalls
- Using CSS selectors when role-based ones exist —
page.locator(".btn-primary")breaks the moment a designer renames the class.page.getByRole("button", { name: "Save" })keeps working and asserts accessibility. waitForTimeoutin production tests — it papers over real races. Replace withawait expect(...).toBeVisible()(auto-retries) orawait page.waitForResponse(/api/users/)(deterministic).- Not setting
baseURL— hardcodinghttp://localhost:4321in everypage.gotomakes the suite unrunnable against staging. Setuse.baseURLonce and callpage.goto("/dashboard"). - Shared state between tests — every Playwright test gets a fresh
context(new cookies, new storage) by default. If you see tests passing alone but failing in a suite, you probably wrotetest.beforeAllwhere you neededtest.beforeEach. - Authenticating in every test — log in once in a setup project (
storageState) and reuse. Saves ~1 s per test. - CI flakiness without retries — flaky on CI but green locally usually means network/timing variance. Set
retries: 2andtrace: "on-first-retry"so flakes auto-recover and leave a trace to investigate. - Forgetting
npx playwright install --with-depsin CI Docker — Playwright bundles browsers but needs ~20 system libraries on bare Linux (Alpine, slim Debian). Use the officialmcr.microsoft.com/playwright:v1.x.x-jammyimage or pass--with-deps. - Mixing the
pagefixture with the standaloneplaywrightAPI —import { chromium } from "playwright"andimport { test } from "@playwright/test"are two different worlds. Inside a test, use the fixtures; only use the standalone API for scripts (scrapers, screenshot generators). - Asserting before the action —
expect(page.getByText("Saved")).toBeVisible()retries, butawait page.getByText("Saved").textContent()does not. When asserting state after an action, always useexpect(...), never the raw locator method. - Not pinning browser versions in CI —
npm install @playwright/test@1.49andnpx playwright installdownload the bundled browsers for that exact version. Don'tnpx playwright install@latestseparately; it'll mismatch and crash.
Real-world recipes
Smoke test: login → action → assert across three browsers
This is the canonical Playwright test — one file, runs on Chromium, Firefox, and WebKit in parallel, covers the critical happy path.
// tests/smoke.spec.ts
import { test, expect } from "@playwright/test";
test.describe("smoke", () => {
test("user signs in, creates a project, sees it on dashboard", async ({ page }) => {
await page.goto("/login");
await page.getByLabel("Email").fill("alice@example.com");
await page.getByLabel("Password").fill(process.env.TEST_PASSWORD!);
await page.getByRole("button", { name: "Sign in" }).click();
await expect(page).toHaveURL(/.*\/dashboard/);
await page.getByRole("button", { name: "New project" }).click();
await page.getByLabel("Project name").fill("Apollo");
await page.getByRole("button", { name: "Create" }).click();
await expect(page.getByRole("link", { name: "Apollo" })).toBeVisible();
});
});
npx playwright test smoke.spec.ts
Output:
Running 3 tests using 3 workers
✓ [chromium] › tests/smoke.spec.ts:5:7 › smoke › user signs in… (3.2s)
✓ [firefox] › tests/smoke.spec.ts:5:7 › smoke › user signs in… (3.9s)
✓ [webkit] › tests/smoke.spec.ts:5:7 › smoke › user signs in… (3.4s)
3 passed (11.1s)
CI: GitHub Actions matrix + sharded runs
Sharding splits the test suite across N machines for wall-time reduction. Combine with the matrix to also fan out by project.
# .github/workflows/playwright.yml
name: Playwright
on: [push, pull_request]
jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
container: mcr.microsoft.com/playwright:v1.49.0-jammy
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: 20 }
- run: npm ci
- run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
- if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-trace-${{ matrix.shardIndex }}
path: test-results/
retention-days: 7
This fans 4× across the four shards, runs in parallel, and uploads traces only on failure.
Test against a Cloudflare Pages preview
Boot Wrangler in CI, get the preview URL, and point Playwright at it.
# Build + deploy a preview
npx wrangler pages deploy ./dist --project-name=myapp --branch=ci > deploy.txt
PREVIEW_URL=$(grep -oP 'https://[^.]+\.myapp\.pages\.dev' deploy.txt | head -1)
echo "Testing against $PREVIEW_URL"
# Run Playwright against the preview
PLAYWRIGHT_BASE_URL="$PREVIEW_URL" npx playwright test
Output:
Testing against https://abc123.myapp.pages.dev
Running 24 tests using 4 workers
…
24 passed (1m 12s)
In the config, fall back to env:
use: {
baseURL: process.env.PLAYWRIGHT_BASE_URL ?? "http://localhost:4321",
}
Page Object Model for a complex app
For larger suites, group locators and actions per page in classes. Tests then read like English.
// tests/pages/LoginPage.ts
import { Page, expect } from "@playwright/test";
export class LoginPage {
constructor(public readonly page: Page) {}
async goto() {
await this.page.goto("/login");
}
async signIn(email: string, password: string) {
await this.page.getByLabel("Email").fill(email);
await this.page.getByLabel("Password").fill(password);
await this.page.getByRole("button", { name: "Sign in" }).click();
}
async expectSignedIn() {
await expect(this.page).toHaveURL(/.*\/dashboard/);
}
}
// tests/login.spec.ts
import { test } from "@playwright/test";
import { LoginPage } from "./pages/LoginPage";
test("user can sign in", async ({ page }) => {
const login = new LoginPage(page);
await login.goto();
await login.signIn("alice@example.com", "hunter2");
await login.expectSignedIn();
});
Generating a PDF from a page
Useful for testing print stylesheets or generating fixtures.
npx playwright codegen --target=javascript-async-await https://example.com
Output: (none — exits 0 on success)
import { chromium } from "playwright";
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto("https://example.com");
await page.pdf({
path: "out/example.pdf",
format: "Letter",
printBackground: true,
margin: { top: "1in", bottom: "1in" },
});
await browser.close();
node generate-pdf.mjs
ls -lh out/example.pdf
Output:
-rw-r--r-- 1 alice alice 128K May 25 10:00 out/example.pdf