Playwright notes & quizzes
Playwright is a testing framework that lets you write automated browser tests in TypeScript. These notes cover the official documentation topic by topic — short sections, runnable TypeScript examples, and an optional quiz after each topic to check your understanding.
All of this (and much more) already lives on playwright.dev — I'm not replacing it. I wanted a version on my own site with end-of-topic quizzes, less noise, and a tighter reading flow. Official Playwright docs.
Learning tracks
Browse by module
- Introduction to Playwright
What Playwright is, why it’s used for end-to-end testing, and how the test runner ships out of the box.
- Writing your first tests
test() blocks, locators, web-first expect assertions, and the page fixture.
- Running and debugging tests
CLI flags, UI mode, debugging with --debug, headed runs, filtering by name/file/tag.
- Accessibility testing
axe-core integrated with Playwright runs automated WCAG checks in 3 lines of code. I add it to every page-level test — it catches missing labels, contrast issues, and duplicate IDs before they reach production.
- Auto-waiting
Tests that fail only on CI are almost always a timing issue. Playwright's auto-waiting solves most of them without a single sleep() — it checks that an element is ready before every action.
- API testing
Playwright can make HTTP requests directly from the test — no browser needed. Useful for setting up test data, calling REST APIs, and checking server-side state.
- Snapshot testing
ARIA snapshots capture the accessibility tree of a page as YAML and compare it on re-run. Unlike HTML snapshots, they survive CSS/class refactors — they only break when the meaningful structure changes.
- Authentication
Login once, run all tests already authenticated. Playwright saves browser state to a file and reuses it — no login flow repeated for every test.
- Best Practices
A collection of rules I keep coming back to when reviewing Playwright test suites — things that make tests survive refactors, run reliably on CI, and stay readable months later.
- Isolation
Every test in Playwright starts with a clean slate: its own cookies, localStorage and session — completely separate from every other test. That's browser contexts at work. The practical consequence: I never need to clean up state between tests, and I can run any test in any order without worrying about leftover state from another test.
- Browsers
Playwright bundles its own browser builds — they're separate from anything you have installed. After every Playwright version bump I always run 'npx playwright install' again, otherwise tests run with mismatched binaries. The flag I use most often: '--with-deps' for CI runners that need OS-level libraries too.
- Chrome extensions
How to load and test Chromium extensions with Playwright using a persistent context and extension-specific fixtures.
- Clock
"Auto-logout after 30 minutes of inactivity" — how do you test that without actually waiting 30 minutes? You fake the clock. Playwright can freeze, fast-forward, or manually tick browser time.
- Debugging Tests
When a test fails and you can't tell why, these are the tools to reach for — starting with the simplest and going deeper.
- Dialogs
Native browser dialogs — alert, confirm, prompt — are invisible to locators. You handle them through page events, not clicks. Get this wrong and the test hangs forever.
- Downloads
If your app has an Export CSV button, you need to test that the file actually downloads and contains the right data. Playwright intercepts downloads before they hit the filesystem.
- Emulation
When a client shows you a bug that only happens on mobile — this is how you reproduce it without picking up a phone. Playwright can fake any device, locale, timezone, geolocation, or color scheme.
- Evaluating JavaScript
Your test code runs in Node.js. The page runs in the browser. They're separate processes — variables don't cross that boundary automatically. page.evaluate() is the bridge: pass a function, execute it in the browser, get the result back in Node.
- Events
Two patterns: waitForEvent (set up the promise BEFORE triggering the action, then await after) and page.on (listen to all occurrences continuously). The waitForEvent pattern is critical — get the order wrong and you miss the event.
- Extensibility
Playwright lets you register custom selector engines via playwright.selectors.register(). The engine defines query() and queryAll() functions that run in the browser to find elements. I've only needed this once — for an internal component library that used custom data attributes that didn't fit standard locator strategies. In most cases, getByTestId with a custom testIdAttribute in config is enough.
- Frames
When a page embeds an iframe — a payment widget, a chat, a map — Playwright can't interact with elements inside it directly through page locators. I use page.frameLocator() to get a locator scoped to the iframe's DOM, then use standard locators inside it. The most common real-world case: testing a Stripe or PayPal embedded payment form.
- Handles
Handles are references to JavaScript objects (JSHandle) or DOM elements (ElementHandle) that live in the browser. I almost never use ElementHandle directly anymore — Locators replaced them and are much better. The one case I still reach for JSHandle: when I need to hold a reference to a browser-side object across multiple evaluate() calls without serializing it each time.
- Actions
fill, click, check, selectOption — these are the actions you use in 90% of tests. Each one waits for the element to be ready before acting.
- Library
There are two Playwright packages: 'playwright' (the library) and '@playwright/test' (the test runner). Unless you're writing a script or a tool that isn't a test suite, always use @playwright/test. It gives you web-first assertions, fixtures, retries, reporters, and automatic cleanup. The library requires you to manage all that yourself.
- Locators
The question I ask first when reading someone's Playwright tests: are they using getByRole or CSS selectors? The answer tells me how brittle the test suite is. Locators are how you find elements — picking the right one makes tests survive refactors.
- Mock APIs
Three strategies: return fake JSON directly, fetch the real response and patch it, or record the whole session to a HAR file and replay. Each has its place. I use fake JSON for happy-path isolation, patching when I need 90% real data, and HAR when the interaction is too complex to hand-craft.
- Mock browser APIs
For browser APIs that Playwright doesn't have a dedicated method for — Battery, cookieEnabled, matchMedia — you inject a mock object before the page loads using page.addInitScript(). The script runs in the browser context, before any page JS, so the app never knows it's talking to a fake.
- Navigations
page.goto() waits for the page to load. For anything beyond that — buttons that redirect, URL changes after form submit — there are waitForURL and load state options.
- Network
Playwright lets you intercept, mock, modify and block any HTTP request the browser makes — without a proxy or third-party tool.
- Service Workers
How Playwright handles service workers, how to disable them for predictable tests, and how to inspect service-worker-owned network traffic.
- Other locators
When getByRole, getByLabel, getByText don't cut it — usually with legacy apps or unusual DOM structures — I reach for CSS pseudo-classes, XPath, layout-based selectors, or nth= indexing. These are escape hatches, not first choices. The most useful ones in practice: :has() to get a parent by child, :visible to filter hidden duplicates, and nth= to pick one from a list.
- Pages
One page = one browser tab. You get one automatically in every test. The tricky part is when an action opens a NEW tab or popup — you need to capture that page object before interacting with it.
- Page object models
When 10 tests all interact with the orders page, any selector change breaks all 10. A page object wraps those locators in one class — fix the selector once, tests are fixed. That's the whole point.
- Screenshots
I use screenshots for two things: debugging (save a screenshot when something looks wrong) and visual regression (compare against a golden image). Two completely different use cases, two different APIs.
- Migrating from Testing Library
If you know React Testing Library, the mental model carries over almost directly — getByRole, getByLabel, getByText, getByTestId all exist in Playwright too. The main differences: no more getBy/findBy/queryBy split (Playwright locators auto-wait), render() becomes mount(), screen becomes page or the component locator, and waitFor is usually replaced by a Playwright assertion.
- Touch events (legacy)
When an app handles legacy Touch Events — not Pointer Events — for swipe, pinch, and tap gestures, I dispatch them manually with locator.dispatchEvent('touchstart'/'touchmove'/'touchend'). The key gotcha: dispatchEvent doesn't set Event.isTrusted. If the app gates behavior on that property to detect automation, you'll need to disable that check during tests.
- Trace viewer
The Trace Viewer is how I debug CI failures without reproducing locally. A trace is a complete recording of a test run: DOM snapshots at every action, all network requests, console logs, screenshots. I configure trace: 'on-first-retry' so traces only exist when a test actually fails.
- Trace viewer
Before I discovered traces, I was manually adding page.screenshot() calls everywhere to debug failures. Now I just open the trace — it records every action, network request, console error, and a DOM snapshot you can interact with. Time travel through your test.
- Videos
Videos record everything that happened in the browser during a test — every click, navigation, and visual state change. I use retain-on-failure so videos only keep around when a test actually breaks. Combined with traces, they make debugging CI failures possible without reproducing locally.
- Continuous Integration
The pattern that works for me in every CI provider: install deps, install Playwright with --with-deps (that flag is the one everyone forgets), run tests with workers: 1 for stability, upload the report with if: !cancelled() so you actually get the artifact when tests fail. The rest is boilerplate.
- Setting up CI
The first time I set up Playwright on GitHub Actions I forgot --with-deps and the browsers wouldn't launch. The YAML below is the minimal working setup: checkout → install → playwright install --with-deps → test → upload artifact. That's it.
- Docker
The official Playwright Docker image already has all three browsers and every system dependency installed. Using it in CI means I skip the 'npx playwright install --with-deps' step entirely — I just npm ci and run tests. The two flags I always add: --ipc=host (Chromium crashes without it) and --init (zombie process prevention).
- Test generator
The full codegen reference: CLI flags for viewport/device/locale emulation, saving and loading auth state for sessions, and recording at cursor from VS Code. I use this when I need more control than the basic 'npx playwright codegen URL' — for example, recording a flow that requires being logged in, or testing on a specific device.
- Generating tests
Codegen is the fastest way to get locators when I don't know the element structure yet. I run 'npx playwright codegen http://localhost:3000/orders', click around, and Playwright generates test code with the best locators automatically — getByRole first, then text, then test id. It also records assertions for visibility, text, and values.
- Coding agents
playwright-cli is a token-efficient browser automation CLI built for coding agents like Claude Code. Instead of loading full tool schemas and accessibility trees into the model context, it exposes concise commands: open, click, type, screenshot, snapshot. Each command outputs minimal state.
- Playwright MCP
Playwright MCP lets AI assistants control a browser through the Model Context Protocol. Instead of processing screenshots, the model reads a structured accessibility tree — which elements exist, their roles, their text. Works with VS Code, Claude, Cursor, and any MCP client. No vision model required.
- VS Code
The VS Code extension is how I run tests while writing them. Click the play button next to a test, watch it execute in a browser window, and see errors inline without leaving the editor. The three features I use daily: Show Browser (see what's happening live), Pick Locator (find selectors by clicking), and Show Trace Viewer (debug failures).
- Agents
Playwright ships three AI agents — planner, generator, healer — that work together to build and maintain a test suite automatically. I run npx playwright init-agents --loop=claude to wire them up to my AI tool, point planner at the app, and it writes a Markdown plan, generator converts it to real Playwright tests, and healer fixes anything that fails.
- Annotations
Four built-in annotations control how tests run: skip (don't run), fixme (known failure, don't run), fail (expect failure, do run), slow (triple the timeout). I use test.skip(condition) constantly — it's how I handle browser-specific bugs without deleting the test.
- Assertions
The most important thing to understand: locator assertions auto-retry. expect(locator).toBeVisible() polls until the element appears — you don't write any waiting loops. Value assertions like expect(someString).toBe('x') are instant and can be flaky on async UIs.
- Command line
The flags I use every day: --grep to run a specific test by name, --last-failed to re-run only what broke, --project=firefox to test one browser, --debug to open Inspector and step through. On CI I always add --forbid-only so test.only() left in by accident fails the build.
- Components (experimental)
Component testing mounts a React/Vue/Svelte component directly in a real browser and runs Playwright assertions against it — no JSDOM, real clicks, real layout. The key limitation: you can't pass live Node.js objects to the component, only plain data. I use it for testing complex UI components in isolation before wiring them into full e2e flows.
- Configuration
playwright.config.ts is the single place that controls browsers, base URL, parallelism, retries, artifacts, and timeouts. I keep one config for all environments — CI vs local is handled by process.env.CI conditions inside the same file.
- Fixtures
Fixtures are the better alternative to beforeEach/afterEach. They're composable, on-demand, and automatically cleaned up. Once you understand them, you won't go back to setup hooks.
- Global setup and teardown
There are two ways to run code once before all tests: project dependencies (recommended) and globalSetup in config. I always use project dependencies — it shows in the HTML report, records traces, and supports fixtures. The main use case: log in once, save storageState, every test starts already authenticated.
- Parallelism
By default: test files run in parallel across workers, tests within one file run sequentially. That's almost always what you want. I set workers: 2 on CI for predictability, and use workerIndex to isolate test data between parallel workers.
- Parameterize tests
Instead of copying the same test 5 times for different inputs, run it against a data array. Two levels: test-level (forEach over cases) and project-level (different use options per project run). The forEach approach is 80% of what you need.
- Projects
A project is a named configuration that tests run with. I use projects for three things: running the same tests across browsers (chromium/firefox/webkit), running the same tests against staging vs production, and setting up a 'setup' project that logs in once before all other tests run. The setup project with dependencies is the pattern I use most.
- Reporters
My CI config always uses two reporters simultaneously: 'dot' for terminal output (quiet, one char per test) and 'blob' when sharding for later merging. Locally I use 'html' so failures open automatically in the browser with traces attached. The 'github' reporter adds inline annotations to PR diffs — worth adding if the team reviews failures directly in GitHub.
- Retries
Retries exist for flaky tests — ones that sometimes pass and sometimes fail without code changes. My rule: fix the root cause first, add retries second. Retries mask real problems if overused. On CI I set retries: 2. Locally, retries: 0 — if it fails, I want to know immediately.
- Sharding
When I hit 800+ e2e tests, parallel workers on one machine stopped being enough. Sharding splits your test suite across multiple CI machines so they run simultaneously. The setup is two lines of YAML — the reporting part takes a bit more work to wire up properly.
- Visual comparisons
Visual snapshot testing: first run generates the reference, every run after compares pixel-by-pixel. I use it for catching accidental CSS regressions — a layout that looks fine in code but breaks visually. The main challenge is flakiness from dynamic content like timestamps and ads — mask those with stylePath or mask option.
- Timeouts
Three independent timeout layers: test timeout (30s — how long the whole test can run), expect timeout (5s — how long an assertion retries), and action timeout (none by default — per-click/fill/goto). The mistake I see most often: raising retries when the real problem is the 5-second expect timeout hitting a slow API.
- TypeScript
Playwright transpiles TypeScript automatically — no build step needed. The gotcha: it doesn't type-check. You can have type errors and Playwright will still run the tests. I always add a separate tsc --noEmit step in CI to catch this.
- UI Mode
UI Mode is how I debug failing tests locally. It's a visual runner with time travel — I hover over any action in the timeline and see exactly what the DOM looked like at that moment. Watch mode auto-reruns tests when I save the file. It's faster than running tests from the terminal and re-reading logs.
- Configuration (use)
The use: {} block in playwright.config.ts is where I set defaults for every test: baseURL so I write page.goto('/orders') instead of the full URL, storageState for auth, trace and screenshot modes for CI. I can override any of these per-project, per-file, or inside a describe block.
- Web server
The webServer option in playwright.config.ts starts your dev server before any test runs and kills it after. The setting I always set: reuseExistingServer: !process.env.CI — locally it reuses my already-running 'npm run dev' so tests start instantly, on CI it always starts fresh.