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.

bash
# 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):

text
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.

typescript
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/.

bash
# 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):

text
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.

typescript
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 webServer instead 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: true makes 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.

typescript
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.

typescript
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(...).

typescript
// 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 };
typescript
// 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).

typescript
// playwright.config.ts (project addition)
{
  projects: [
    {
      name: "setup",
      testMatch: /.*\.setup\.ts/,
    },
    {
      name: "chromium",
      use: { ...devices["Desktop Chrome"], storageState: "tests/.auth/alice.json" },
      dependencies: ["setup"],
    },
  ],
}
typescript
// 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.

typescript
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.

typescript
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.

typescript
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:

bash
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.

bash
# 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 with retries: 2 in 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.

bash
npx playwright test --ui

# Pick a single test file
npx playwright test --ui tests/login.spec.ts

Output:

text
  ──────────────────────────────────────
  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).

typescript
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

AspectSeleniumCypressPlaywright
EnginesAll real browsersChromium + Firefox + WebKit (cloud only)Chromium + Firefox + WebKit
ArchitectureOut-of-process WebDriverIn-page (runs inside the tab)Out-of-process DevTools protocol
Auto-waitingNo (manual WebDriverWait)YesYes
ParallelismProcess-level (manual)Single browser per run; parallel via Cypress CloudBuilt-in worker parallelism
Multi-tab / multi-originLimitedLimited / paid featureFirst-class
Trace viewerNoTime-travel debugger (in-tab)Stand-alone trace viewer
LanguageMany (Java, Python, JS…)JS/TS onlyTS/JS, Python, Java, .NET
iFrame handlingManualLimitedNative (frameLocator)
MobileAppium (separate stack)No nativeBuilt-in device emulation
Network mockingNoYes (cy.intercept)Yes (page.route)
CI cost (parallelism)FreeFree with --parallel (no cloud), paid for cloudFree

Common pitfalls

  1. Using CSS selectors when role-based ones existpage.locator(".btn-primary") breaks the moment a designer renames the class. page.getByRole("button", { name: "Save" }) keeps working and asserts accessibility.
  2. waitForTimeout in production tests — it papers over real races. Replace with await expect(...).toBeVisible() (auto-retries) or await page.waitForResponse(/api/users/) (deterministic).
  3. Not setting baseURL — hardcoding http://localhost:4321 in every page.goto makes the suite unrunnable against staging. Set use.baseURL once and call page.goto("/dashboard").
  4. 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 wrote test.beforeAll where you needed test.beforeEach.
  5. Authenticating in every test — log in once in a setup project (storageState) and reuse. Saves ~1 s per test.
  6. CI flakiness without retries — flaky on CI but green locally usually means network/timing variance. Set retries: 2 and trace: "on-first-retry" so flakes auto-recover and leave a trace to investigate.
  7. Forgetting npx playwright install --with-deps in CI Docker — Playwright bundles browsers but needs ~20 system libraries on bare Linux (Alpine, slim Debian). Use the official mcr.microsoft.com/playwright:v1.x.x-jammy image or pass --with-deps.
  8. Mixing the page fixture with the standalone playwright APIimport { chromium } from "playwright" and import { test } from "@playwright/test" are two different worlds. Inside a test, use the fixtures; only use the standalone API for scripts (scrapers, screenshot generators).
  9. Asserting before the actionexpect(page.getByText("Saved")).toBeVisible() retries, but await page.getByText("Saved").textContent() does not. When asserting state after an action, always use expect(...), never the raw locator method.
  10. Not pinning browser versions in CInpm install @playwright/test@1.49 and npx playwright install download the bundled browsers for that exact version. Don't npx playwright install@latest separately; 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.

typescript
// 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();
  });
});
bash
npx playwright test smoke.spec.ts

Output:

text
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.

yaml
# .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.

bash
# 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:

text
Testing against https://abc123.myapp.pages.dev
Running 24 tests using 4 workers
  …
  24 passed (1m 12s)

In the config, fall back to env:

typescript
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.

typescript
// 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/);
  }
}
typescript
// 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.

bash
npx playwright codegen --target=javascript-async-await https://example.com

Output: (none — exits 0 on success)

typescript
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();
bash
node generate-pdf.mjs
ls -lh out/example.pdf

Output:

text
-rw-r--r-- 1 alice alice 128K May 25 10:00 out/example.pdf