How a Playwright test actually runs, from test file to runner to client SDK, over one persistent connection into the Node.js driver, out through CDP to the browser, renderer, and DOM. Play the interactive map, then go deep on every layer: fixtures, auto-waiting, contexts, network interception, repo structure, CI, and flake control.
Pick a command and press Play: the map lights up layer by layer as the command travels through the stack, with a one-line explanation at every hop. Then read the full blueprint below.
Chromium is drawn; for Firefox and WebKit only the protocol box changes (Juggler / WebKit remote), the rest of the journey is identical: that is the point of the driver layer.
Every Playwright test you will ever write funnels through lines that look like await page.click(). On screen it is one statement. Under the hood it is a message that crosses five process boundaries, gets translated between protocols twice, and ends its life as a synthetic mouse event hit-testing its way to a DOM node inside a sandboxed renderer. Most testers never look below the API surface, and it costs them: every confusing timeout and every "target closed" error becomes easy to reason about once you can place it on the map. So before this blueprint touches folder structure or fixtures, we walk the wire. This is the Playwright end to end architecture traced through a single click.
// checkout.spec.ts - one test, annotated with the hops it triggers import { test, expect } from '@playwright/test'; test('student places an order', async ({ page }) => { // This function body runs inside a WORKER process, not the one // you started. "page" is a client-side proxy: a GUID plus a // channel, zero browser logic inside it. await page.goto('https://app.thetestingacademy.com/playwright/'); // client -> driver: { guid, method: "goto", params }. The driver // issues the engine-level navigation and holds your promise until // the "load" lifecycle event streams back over the same connection. await page.getByRole('button', { name: 'Place order' }).click(); // The locator was only a description until this line. One message // carries selector + action; the driver resolves the node fresh, // runs actionability checks (visible, stable, enabled, not covered), // then dispatches a raw mouse event at real coordinates. await expect(page.getByTestId('order-status')).toHaveText('Confirmed'); // Web-first assertion: the driver re-queries the DOM until the text // matches or the timeout hits. No sleep() anywhere in your code. });
Start with the command you type: npx playwright test. The process it starts is the runner, and the runner never executes a single line of your test. It reads playwright.config.ts, scans the test directory, builds the final test list after applying projects, filters, and shards, then spawns worker processes: plain Node.js child processes, one per parallel slot. Workers are the machines that actually run test functions, one test at a time, with fresh fixtures for each. The runner orchestrates over IPC: it assigns files, collects results, replaces any worker that crashes or fails a test, and feeds reporters. Two consequences follow. Parallelism means more processes, not threads, so tests cannot accidentally share in-memory state across files. And a "Test timeout of 30000ms exceeded" is the worker enforcing its budget, which is a different failure than an element that never appeared.
Inside a worker, the objects your test holds (browser, context, page, locators) come from the client half of playwright-core, and they are nearly hollow. Each wraps a GUID and a reference to the connection. Call page.click() and the client does exactly one thing: it serializes your intent into a JSON-RPC style message, { guid, method, params }, writes it to the channel, and returns a promise that resolves when a response with a matching id arrives. This explains two things mid-level testers usually accept on faith. Every Playwright call is async because every call is a remote procedure call; nothing executes locally. And a locator such as page.getByRole('button', { name: 'Place order' }) is just a stored description; nothing crosses the wire until an action or assertion uses it, at which point the selector is resolved fresh against the live DOM. That per-action re-resolution is why stale element references are essentially absent from the Playwright architecture.
Between client and driver sits a single persistent, bidirectional connection carrying that JSON traffic. Where it physically lives depends on your setup. In JS and TypeScript with @playwright/test, client and driver share one Node process and the "wire" is an in-memory pipe. The Python, Java, and .NET bindings bundle a Node runtime, spawn the driver as a child process, and speak the identical protocol over stdio. And browserType.connect() swaps in a real WebSocket to a driver on another machine, which is how remote and containerized browsers are driven. The transport varies; the conversation never does: one connection, opened once, carrying commands down and responses plus a continuous stream of events up. That event stream is the load-bearing difference from the HTTP model Selenium standardized, and we will come back to it.
The far end of the connection is the Playwright driver: the server half of playwright-core, a Node.js server process. Its dispatcher map resolves your GUID to the real server-side page object, and here click stops being one operation. The driver runs the actionability loop: is the element attached, visible, stable (same bounding box across two consecutive animation frames), enabled, and actually the thing that would receive a pointer event at the click point? It scrolls the node into view, computes exact coordinates, performs a final hit-target check, and only then dispatches input, retrying the whole loop until the action timeout. This is why disciplined Playwright code contains no sleeps: waiting lives in the driver, adjacent to the browser, at protocol speed, identical for every language binding. The driver is also home to the selector engines, network routing, tracing, video, and HAR capture, and it can record all of it because every command and every event flows through this one chokepoint.
Below the driver, uniformity ends, because the three engines speak three different automation dialects. Chromium is driven over the Chrome DevTools Protocol (CDP), the same channel DevTools itself uses. Firefox has no usable built-in equivalent, so the Playwright team maintains patched Firefox builds implementing a custom protocol named Juggler. WebKit likewise ships as a patched build exposing the WebKit remote inspection protocol, the debugging channel the Safari inspector speaks. This is why npx playwright install downloads its own browser builds: the patches are the contract between driver and engine. The driver normalizes all three dialects into one internal model, which is how a single API delivers identical semantics across engines.
| Engine | Wire protocol | Ships as |
|---|---|---|
| Chromium | Chrome DevTools Protocol (CDP) | Stock capability of every Chromium build, which is why channel: 'chrome' can drive installed Chrome or Edge |
| Firefox | Juggler (Playwright-specific) | Patched build downloaded by npx playwright install |
| WebKit | WebKit remote inspection protocol | Patched build; how you test the Safari engine on Windows or Linux |
channel: 'chrome' or 'msedge', since every Chromium ships CDP. For Firefox and WebKit, always use the builds from npx playwright install.Take the Chromium path to the finish. The driver emits Input.dispatchMouseEvent with raw coordinates over CDP, and it lands in the browser process: the privileged coordinator that owns windows, profiles, the network stack, and input routing. Your page does not live there. Each tab (roughly, each cross-origin frame tree) runs in its own sandboxed renderer process, which holds the DOM, runs the page JavaScript, and performs layout and paint. The browser process forwards mousePressed and mouseReleased to the right renderer, and the renderer hit-tests those coordinates against its layout tree: whatever node sits topmost at that point receives pointerdown, mousedown, focus, mouseup, and click, bubbling exactly as a human click would. Notice what never happened: Playwright did not call element.click() from page JavaScript. It sent coordinates through the browser's genuine input pipeline. If a cookie banner overlaps your button, the banner gets the click, which is precisely why the driver checks the hit target before dispatching. The result, plus any events the click caused, streams back up the same connection, your promise resolves, and the next line runs.
Here is the full Playwright end to end architecture in one table. The last column is the payoff: read it as a debugging index.
| Layer | Lives in | Its one job | What breaks there |
|---|---|---|---|
| Test runner | Runner process (Node.js) | Discover tests, spawn workers, aggregate results | Config mistakes, "no tests found", shard and filter errors |
| Worker | Node.js child process | Execute test code with fresh fixtures per test | Test timeouts, fixture failures, crashed workers triggering retries |
| Client SDK | Inside the worker | Serialize each API call into a message against an object GUID | Missing await, acting on a closed page ("target closed") |
| Transport | Pipe or WebSocket | Carry commands down, responses and streamed events up | Dropped connections when the browser or container dies mid-run |
| Driver | Node.js server process | Translate intent to protocol commands; actionability and auto-wait | Actionability timeouts: covered elements, endless animations, detached nodes |
| Automation protocol | CDP / Juggler / WebKit inspection | Engine-specific control channel | Version drift, unpatched system browsers |
| Browser process | The browser | Own contexts, tabs, network; route input to the right tab | Crashes, OOM kills in CI, sandbox issues in Docker |
| Renderer process | One per tab | Hold the DOM, run page JS, hit-test the click to a node | Overlays swallowing clicks, hydration races, re-renders detaching nodes |
Why does the Playwright architecture bother with a server in the middle? Three reasons, and they compound. First, write-once logic: actionability, auto-waiting, selector engines, tracing, and network interception are implemented once, in TypeScript, inside the driver. The Python, Java, and .NET bindings are thin serializers speaking the same protocol to the same driver, so a wait behaves identically in every language and a bug fixed in the driver is fixed for everyone. There is only one Playwright; the bindings are dialects. Second, one API over three protocols: the driver is the adapter that hides CDP, Juggler, and WebKit inspection behind a single model. Third, the chokepoint pays rent: because everything flows through the driver, the trace viewer and video recording need zero instrumentation in your tests. If you have ever wondered how Playwright works internally across four languages with one consistent behavior, this layer is the entire answer.
Now the comparison that makes the design click. Classic WebDriver, the model Selenium standardized, sends each command as an HTTP request to a driver binary: one round trip per command, request and response, done. That model has structural costs. Every action pays HTTP overhead. The server cannot push, so your code is never told "a dialog just appeared" or "that request finished"; it must poll. And because the wire is command-response only, waiting logic has to live client-side in your test process, which is why explicit wait code is so verbose there. Playwright's single persistent socket inverts all three: per-command cost is amortized to almost nothing, events are pushed the instant they occur, and waiting is fused into actions inside the driver. page.waitForRequest() and web-first assertions are cheap subscriptions to a stream, not polling loops hammering the browser. In fairness, the standard is catching up: WebDriver BiDi adds a bidirectional channel, converging on the model Playwright's end to end architecture made mainstream.
| Concern | Classic WebDriver model | Playwright model |
|---|---|---|
| Wire model | One HTTP round trip per command | One persistent connection, messages both directions |
| Browser events | No push; the client polls | Streamed instantly: requests, console, dialogs, frames |
| Where waiting lives | In your test code (explicit waits) | In the driver, fused into every action |
| Per-command cost | HTTP overhead every time | Near zero after the initial connect |
npx playwright test --trace on and open the trace viewer. The action list is the command log made visible, and expanding any action shows each actionability check the driver ran before dispatching input. Ten minutes tracing a test against the practice pages at https://app.thetestingacademy.com/playwright/ teaches more about how Playwright works internally than any diagram.Most teams treat the Playwright test runner as plumbing: run npx playwright test, read the report, move on. That wastes the most architectural layer in the framework. The runner decides what "a browser" means for your suite, how long anything may take, how many operating system processes execute at once, and what every test receives before its first line runs. Encode those decisions once, in config and fixtures, and spec files shrink to pure intent: navigate, act, assert. Leave them implicit and every file reinvents login, waits, and test data, each slightly differently, and the differences become your flake rate.
A Playwright config is not a settings dump, it is the contract every test inherits. Here is a compact one for an app with a login page, a course catalog, and a checkout flow. It anchors the rest of this section.
// playwright.config.ts import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ testDir: './tests', fullyParallel: true, workers: process.env.CI ? 4 : undefined, // undefined = half your CPU cores retries: process.env.CI ? 2 : 0, timeout: 30_000, // per-test budget expect: { timeout: 5_000 }, // per-assertion retry window use: { baseURL: process.env.BASE_URL ?? 'https://app.thetestingacademy.com', actionTimeout: 10_000, navigationTimeout: 15_000, trace: 'on-first-retry', screenshot: 'only-on-failure', }, projects: [ { name: 'setup', testMatch: /.*\.setup\.ts/ }, { name: 'chromium', use: { ...devices['Desktop Chrome'], storageState: '.auth/student.json' }, dependencies: ['setup'], }, { name: 'firefox', use: { ...devices['Desktop Firefox'], storageState: '.auth/student.json' }, dependencies: ['setup'], }, { name: 'mobile-checkout', testMatch: /checkout\/.*\.spec\.ts/, use: { ...devices['Pixel 7'], storageState: '.auth/student.json' }, dependencies: ['setup'], }, ], });
Three things matter here. First, the use block is a cascade: top-level use sets suite-wide defaults, each project can override them, and a single file can override again with test.use(). The most specific layer wins, which is how one suite serves many targets without branching logic inside tests. Second, baseURL turns every page.goto('/checkout') into an environment-relative call; switching from local to staging is one environment variable, not a find-and-replace. If you want a live target to run experiments against, point it at the practice pages under https://app.thetestingacademy.com/playwright/. Third, evidence policy (trace, screenshot) is declared once, so every failure ships with artifacts and nobody bolts screenshot helpers onto individual tests.
Playwright projects are named permutations of the same suite. The classic use is per-browser, but the mechanism generalizes much further:
devices['Desktop Chrome'] presets across projects. The mobile-checkout project above runs only the checkout specs on a phone viewport because testMatch scopes it down.staging project and a prod-smoke project can share spec files but override baseURL and use grep so production only ever gets read-only smoke tags.setup project authenticates, an api project runs request-only tests with no browser, an e2e project runs the UI suite. dependencies wires the execution order between them.Select one with npx playwright test --project=chromium, or run everything and let the dependency graph sort ordering. Because Playwright projects are plain config objects, adding a target is additive: no new folders, no copied suites, no second config drifting out of sync.
Playwright runs four nested clocks, and debugging timeouts stays miserable until you can name each one. From the outside in:
| Clock | Where it is set | Default | What it bounds |
|---|---|---|---|
| Test timeout | timeout in config, test.setTimeout() locally | 30s | One whole test: body, each hook, test-scoped fixture setup and teardown |
| Expect timeout | expect: { timeout } | 5s | One auto-retrying assertion, such as toBeVisible() |
| Action timeout | use.actionTimeout | None (capped by test timeout) | A single action's actionability wait: click(), fill(), check() |
| Navigation timeout | use.navigationTimeout | None (capped by test timeout) | goto(), reload(), waitForURL() |
The nesting explains the two classic mysteries. A stuck click with no action timeout does not fail as a click: it silently eats the whole budget and dies as "Test timeout of 30000ms exceeded" on whichever line the clock happened to run out. And an assertion against a slow page fails in 5 seconds even though the test had 25 to spare, because the expect clock is separate from the test clock. Sane ordering is expect below action, action below navigation, navigation well below test, so the smallest guilty clock fires first and the error message names the real culprit. For one legitimately slow spec, reach for test.slow() (triples the budget) or test.setTimeout() instead of inflating the global number to cover your slowest page.
The Playwright test runner is a small orchestrator in front of a pool of workers, and each worker is a separate OS process with its own browser instance, its own memory, and its own module state. Nothing is shared between workers except the filesystem and your backend. That one fact explains most parallel-run surprises: a module-level counter, an in-memory cache, a singleton helper, none of them exist across workers.
Distribution works like this: test files are assigned to workers, and within one file, tests run in source order on the same worker. Set fullyParallel: true and the unit of distribution becomes the individual test, so two tests from the same file may run simultaneously in different processes. When a test fails, its worker is considered dirty: the process is torn down and a fresh worker boots to continue the remaining tests. That is why worker-scoped state must always be rebuildable from scratch, and why a beforeAll in the affected file runs again in the new worker, a nasty surprise if it seeds non-idempotent data.
Worker count defaults to half your logical cores; pin it in CI (workers: 4) so runtimes are reproducible. Each process exposes testInfo.workerIndex (unique across restarts) and testInfo.parallelIndex (a stable slot from 0 to workers minus 1), the standard tools for namespacing data: [email protected]. If a legacy flow truly cannot parallelize, test.describe.configure({ mode: 'serial' }) exists, but treat it as debt: it couples tests together and forfeits the scaling you paid for.
fullyParallel, two tests can hit your backend at the same instant from different processes. Any test that reuses a shared account, a fixed coupon code, or "the first row of the orders table" will flake. Every test owns its data: unique emails, unique orders, seeded and destroyed by the test itself.Playwright fixtures are the runner's dependency injection system. A test declares what it needs by destructuring the first argument, and the runner constructs exactly those dependencies, in dependency order, before the body runs, then tears them down in reverse afterwards. That destructured signature is not sugar: it is the declaration that drives the whole graph. The built-ins cover the browser primitives:
| Fixture | Scope | What you get |
|---|---|---|
page | test | A fresh Page in a fresh context for every test |
context | test | The BrowserContext behind that page: isolated cookies, storage, permissions |
browser | worker | One Browser instance shared by all tests in the worker process |
request | test | An APIRequestContext for HTTP calls that honors baseURL |
browserName | worker | 'chromium', 'firefox', or 'webkit', for conditional logic |
You extend the system with test.extend, and this is where suite architecture actually lives. Fixtures come in two scopes. Test-scoped fixtures are built and destroyed per test: pages, seeded records, anything isolation demands. Worker-scoped fixtures are built once per worker process and shared by every test it runs: expensive things like an API session or a database pool. A worker fixture cannot depend on a test fixture (their lifetimes cannot nest), and the type system enforces it. There are also auto fixtures, declared with { auto: true }, which run for every test without being requested: the right home for cross-cutting policy such as failing any test that logs a console error.
// fixtures/index.ts import { test as base, expect, request } from '@playwright/test'; import type { Page } from '@playwright/test'; type TestFixtures = { authedPage: Page; testOrder: { id: string; total: number }; }; type WorkerFixtures = { apiToken: string; }; export const test = base.extend<TestFixtures, WorkerFixtures>({ // Worker-scoped: created once per worker process, reused by its tests apiToken: [async ({}, use) => { const api = await request.newContext({ baseURL: process.env.BASE_URL ?? 'https://app.thetestingacademy.com', }); const res = await api.post('/api/auth/token', { data: { email: process.env.API_USER, password: process.env.API_PASS }, }); const { token } = await res.json(); await api.dispose(); await use(token); }, { scope: 'worker' }], // Test-scoped: a page that is already signed in via saved storage state authedPage: async ({ browser }, use) => { const context = await browser.newContext({ storageState: '.auth/student.json', }); const page = await context.newPage(); await use(page); await context.close(); // teardown runs even when the test fails }, // Seeded data: every test gets its own order, then it is cleaned up testOrder: async ({ request, apiToken }, use) => { const res = await request.post('/api/orders', { headers: { Authorization: `Bearer ${apiToken}` }, data: { items: [{ sku: 'PLAYWRIGHT-PRO', qty: 1 }] }, }); const order = await res.json(); await use(order); await request.delete(`/api/orders/${order.id}`); }, }); export { expect };
Read the three against their scopes. apiToken authenticates through the API once per process, not once per test: across a 400-test suite on 4 workers that is 4 token requests instead of 400. authedPage builds a page from saved storageState; since the config already signs in the default page, it earns its keep when a test needs a second role, a student page and an admin page side by side. testOrder is the seeded-data pattern: each test receives a fresh order created over HTTP through the built-in request fixture, and the line after use() deletes it, pass or fail. A spec consuming them reads like a requirement:
// tests/orders/refund.spec.ts import { test, expect } from '../../fixtures'; test('student can request a refund on a fresh order', async ({ authedPage, testOrder }) => { await authedPage.goto(`/orders/${testOrder.id}`); await authedPage.getByRole('button', { name: 'Request refund' }).click(); await expect(authedPage.getByText('Refund requested')).toBeVisible(); });
Never log in through the UI at the top of every test. It is the slowest, flakiest possible opening, and it multiplies by your test count. The pattern instead: authenticate once, save the browser's storage (cookies plus localStorage) to a JSON file with storageState, then start every context preloaded from that file. Playwright offers two homes for the one-time login, and one is clearly better.
// tests/auth.setup.ts import { test as setup, expect } from '@playwright/test'; const studentFile = '.auth/student.json'; setup('authenticate as student', async ({ page }) => { await page.goto('/login'); await page.getByLabel('Email').fill(process.env.STUDENT_EMAIL!); await page.getByLabel('Password').fill(process.env.STUDENT_PASSWORD!); await page.getByRole('button', { name: 'Sign in' }).click(); await expect(page).toHaveURL(/dashboard/); await page.context().storageState({ path: studentFile }); });
The config from earlier already wires it up: the setup project matches this file, every browser project declares dependencies: ['setup'], and their use.storageState points at the saved JSON. Run any project and the Playwright test runner executes setup first, exactly once. The older alternative is globalSetup: a plain exported function that runs before any worker spawns. It still has legitimate uses, but for auth the setup project wins on every axis that matters:
| Concern | globalSetup | Setup project |
|---|---|---|
| What it is | One exported Node function | Real Playwright tests other projects depend on |
| Fixtures and browsers | Manual: you launch and close everything yourself | Full fixture system: page, request, the use block |
| Failure visibility | Console stack trace, no trace file, no report entry | Retries, traces, screenshots, HTML report like any test |
| Filtering | Always runs | Participates in --project; dependencies pull it in only when needed |
| Best for | Non-browser prep: env checks, starting services | Auth, seeding, anything that drives the app |
student.json, admin.json). If your suite runs long, check token lifetime; a state file that expires mid-run fails as a confusing wall of redirects to /login.Coming from older frameworks, the reflex is beforeEach for setup and afterEach for cleanup. The runner supports both, but Playwright fixtures are structurally better on four axes.
extend chains stack, and any spec imports the finished test object. Combining authedPage with testOrder above required zero glue code.testOrder never pay for seeding.beforeEach can skip the matching cleanup, and afterEach blocks scattered across nested describes run in an order people routinely guess wrong.test.use() and project-level use, which is how one spec runs as a student in one Playwright project and as an admin in another, without an if statement anywhere.Hooks keep a narrow role: trivial, file-local prep like navigating to the page under test. The moment two files want the same setup, or any setup acquires cleanup, it belongs in a fixture. That discipline is what keeps a 50-file suite feeling like one system instead of 50 opinions.
Most flaky suites die in the gap between an element existing and an element being ready. Understanding how Playwright closes that gap is the difference between writing tests that pass tonight and architecting a suite that keeps passing after the next redesign. It starts with a decision that looks small and changes everything: Playwright locators are not element handles.
In older drivers, finding an element returned a pointer to one specific DOM node. If the framework re-rendered, that pointer went stale and the test died with a stale reference error pointing nowhere near the real problem. A locator inverts the model. page.getByRole('button', { name: 'Place order' }) does zero DOM work when you create it. It is a stored query, a recipe for finding the element, re-executed against the live DOM at the moment you act on it. If React unmounts that button and mounts a fresh one between steps, the next click() simply finds the new node. Staleness is not handled gracefully; it is structurally impossible.
That laziness matters because a query that runs at action time can run as many times as needed. This is what makes Playwright auto waiting work: the driver retries the lookup, and the readiness checks wrapped around it, until the page agrees or the clock runs out.
Before click() dispatches a single input event, the driver marches the resolved element through the Playwright actionability checks. Each check encodes a human expectation: a real user cannot click a button that is invisible, still sliding into place, buried under a toast, or disabled. Your automation should not be able to either.
| Check | What the engine verifies | The flake it prevents |
|---|---|---|
| Attached | The locator resolves to a node connected to the DOM | Acting on an element a framework re-render just destroyed |
| Visible | Non-empty bounding box, not visibility:hidden | Clicking elements a real user cannot see or reach |
| Stable | Bounding box unchanged across two consecutive animation frames | Clicking a button mid-slide and hitting the pixel it just vacated |
| Receives events | The element is the actual hit target at the action point | Clicks swallowed by spinners, toasts, modals, sticky headers |
| Enabled | The control is not disabled, directly or via a parent | Clicking a submit button the app disabled while saving |
| Editable | Enabled and not readonly (for fill and typing actions) | Typing into inputs that silently discard every keystroke |
The important part is the loop around that table. The checks are not evaluated once. The engine runs the full ladder, and if any gate fails it waits for the next animation frame, re-resolves the locator from scratch, and runs the ladder again. That retry loop lives inside the driver, right next to the browser, not in your test code: there is no polling interval to tune and no expected-condition classes to compose. That loop, plus the lazy locator underneath it, is all Playwright auto waiting is: no magic, just a fast retry engine sitting closer to the browser than your code ever could. Failure is defined purely by time. If the gates never all open within the timeout, the action throws and prints a log of every attempt it made.
playwright.config.ts: expect.timeout for assertions, actionTimeout when actions should fail faster than the test.waitForTimeout(3000) is a bet that the app is never slower than your guess and never faster. You lose both ways: flake when it is slower, wasted CI minutes when it is faster. When you feel the urge to sleep, name the condition you are actually waiting for and assert that condition instead.Actions retry until the element is ready. Playwright web first assertions apply the identical philosophy to checking outcomes. await expect(locator).toHaveText('Coupon applied') does not read the DOM once and compare strings. The matcher re-resolves the locator, reads the current text, and repeats until the expectation passes or the assertion timeout expires. An assertion here is a poll, not a snapshot.
Contrast the snapshot style: expect(await locator.textContent()).toBe('Coupon applied'). That inner await freezes one instant in time, then compares it against a page that has already moved on. It races the application on every run and wins only when the network is kind. The rule of thumb: an await inside the expect() parentheses is a smell; pass the locator itself, so the matcher controls the read. Action retries and assertion retries are separate machines, and it pays to keep them distinct in your head:
expect.timeout, five seconds by default; actions wait on the action timeout. Tune them independently.Retrying assertions also fail better. The error output records the values observed while polling, so you can literally watch the text change across attempts.
Auto waiting solves when to act. Selector choice solves where, and it decides how long the suite survives contact with a redesign. Playwright locators come in families, and they are not equal. Work down this ladder and stop at the first rung that uniquely identifies your element.
| Tier | API | Tied to | Survives a redesign? |
|---|---|---|---|
| 1 | getByRole('button', { name: 'Checkout' }) | The accessibility tree: what users perceive | Yes, unless the product itself changes |
| 2 | getByLabel, getByPlaceholder, getByText | Visible copy and form semantics | Mostly; breaks only when the copy changes |
| 3 | getByTestId('order-total') | A contract negotiated with developers | Yes, but it proves nothing user-facing |
| 4 | CSS / XPath | DOM structure, class names, node order | Rarely; first casualty of any refactor |
Role-based locators win because they query the accessibility tree, not the DOM. The accessibility tree is the user-facing contract of the page: a button named Checkout stays a button named Checkout whether the team migrates from div soup to semantic HTML, renames every CSS class, or swaps component libraries. Structure churns constantly; intent rarely does. There is a second dividend: if getByRole cannot find your element, a screen reader probably cannot either, so this strategy applies constant, free pressure toward accessible markup. Use getByLabel and getByPlaceholder for form fields, getByTestId when semantics genuinely cannot disambiguate (repeated cards, canvas overlays, dense dashboards), and CSS or XPath only when you are pinned against legacy markup you do not control.
Every action on a locator carries an implicit claim: exactly one element matches. If two match, Playwright refuses to guess and throws a strict mode violation. This surprises people coming from tools that silently acted on the first match, and the surprise is the point. Silently clicking the first Delete button on a page that has twelve is how a passing suite deletes the wrong order.
// Throws: strict mode violation, resolved to 3 elements await page.getByRole('button', { name: 'Delete' }).click(); // Fix 1: scope through a container the user would recognize const row = page.getByRole('row', { name: 'Order #1042' }); await row.getByRole('button', { name: 'Delete' }).click(); // Fix 2: filter the candidates by content await page.getByRole('listitem') .filter({ hasText: 'Wireless Mouse' }) .getByRole('button', { name: 'Delete' }) .click(); // Last resort: positional, breaks the day the list reorders await page.getByRole('button', { name: 'Delete' }).nth(2).click();
first() or nth() bakes accidental page order into the test. The day a designer reorders that list, you act on the wrong element and nothing fails loudly. Scope through a container or use filter() so the test states which element it means.Here is the whole section compressed into one before-and-after. The first version is the kind of test that passes locally and melts in CI. Run both against the https://app.thetestingacademy.com/playwright/ practice pages and compare the traces.
// checkout.spec.ts, the version that fails every third run import { test, expect } from '@playwright/test'; test('applies a coupon at checkout', async ({ page }) => { await page.goto('https://app.thetestingacademy.com/playwright/'); await page.click('#nav > ul > li:nth-child(3) > a'); // brittle structural path await page.waitForTimeout(3000); // hope the cart rendered await page.click('.btn.btn-primary'); // which primary button? await page.waitForTimeout(2000); await page.fill('#coupon', 'WELCOME10'); await page.click('.apply'); await page.waitForTimeout(5000); // hope the discount applied const total = await page.textContent('.order-total'); if (!total.includes('900')) throw new Error('discount not applied'); });
// checkout.spec.ts, rebuilt on locators and web-first assertions import { test, expect } from '@playwright/test'; test('applies a coupon at checkout', async ({ page }) => { await page.goto('https://app.thetestingacademy.com/playwright/'); await page.getByRole('link', { name: 'Cart' }).click(); await page.getByRole('button', { name: 'Proceed to checkout' }).click(); await page.getByLabel('Coupon code').fill('WELCOME10'); await page.getByRole('button', { name: 'Apply coupon' }).click(); // Each assertion polls the live DOM until it passes or times out await expect(page.getByRole('status')).toHaveText('Coupon applied'); await expect(page.getByTestId('order-total')).toHaveText('Rs 900.00'); await expect(page.getByRole('button', { name: 'Place order' })).toBeEnabled(); });
Count what the rewrite deleted: ten seconds of fixed sleeps, one brittle structural selector, one ambiguous class-based click, and a hand-rolled check that threw a bare Error with no diff. Every wait is now event-driven, so Playwright auto waiting spends exactly as long as the app needs and not a millisecond more; the stable version is usually the faster one too. Notice that each expect line moonlights as a synchronization point: asserting the coupon status message inherently waits for the apply request to finish, which is why the total assertion after it never races.
When the Playwright actionability engine gives up, it names the exact gate that failed. Most engineers read the first line and start guessing; the message plus its call log usually points straight at the fix.
| Message fragment | What the engine is telling you | First move |
|---|---|---|
waiting for locator(...) then timeout | The query never matched any node at all | Wrong selector, content inside an iframe, or the app never reached this state; open the trace snapshot |
element is not stable | The bounding box kept moving between animation frames | An entry animation or layout shift; assert the state that ends the motion, or disable animations for tests |
strict mode violation: resolved to 2 elements | Your locator is ambiguous on this page state | Scope through a container or add .filter(); treat it as a design gift, not an obstacle |
<div class="overlay"> intercepts pointer events | Every check passed except the hit test; something sits on top | Assert the overlay, spinner, or cookie banner is hidden before clicking |
element is not visible | Attached to the DOM but zero-size or hidden | Open the collapsed parent (accordion, menu, tab) first, the way a user would |
Read these as diagnostics, not obstacles. Each one is the actionability engine refusing to do something a real user could not do, which means each one is either a locator problem or, more often than you would expect, a genuine product defect: an overlay that never dismisses, a button that stays disabled, a spinner that outlives its request. A suite built on tight Playwright locators and web first assertions converts flake investigations into bug reports, and that is precisely the trade this architecture is designed to make.
Most of the speed and reliability that Playwright is famous for is decided at this layer, not in your test code. There are three nested objects with precise jobs, and once the model clicks, everything else in this blueprint (fixtures, parallelism, Playwright network interception) stops being magic and starts being engineering you can reason about.
A Browser object is a real browser process: Chromium, Firefox, or WebKit, launched once and controlled over a single driver connection. Starting one costs from half a second to several seconds, by far the most expensive thing Playwright does. Architecture goal number one: pay that cost as rarely as possible.
A BrowserContext is where the interesting engineering lives. Think of a Playwright browser context as a freshly wiped incognito profile inside that already-running process: its own cookies, localStorage, sessionStorage, cache, permissions, and its own network rules. Nothing leaks across the boundary between two contexts, in either direction. Crucially, creating one spawns no new process, so it costs milliseconds instead of seconds.
A Page is a tab inside a context. It owns the DOM, a main frame plus any child iframes, and its own console and network event streams. Popups opened by the page land in the same context, which is why a login popup can share cookies with the window that opened it.
| Object | Cost to create | Owns | Right lifetime |
|---|---|---|---|
Browser | Heavy: full process launch | Rendering engine plus every context inside it | One per worker, reused across tests |
BrowserContext | Milliseconds | Cookies, storage, cache, permissions, emulation, routes, HAR | One per test, always fresh |
Page | Milliseconds | A tab: DOM, frames, console and network streams | One or a few per test |
This split is the whole trick behind Playwright's speed relative to restart-everything tools. The runner launches one browser per worker process, then hands every test a brand-new context. You get the isolation of a cold start (no leaked login state, no cookies from the previous test, no cache lying to you) at the price of opening a tab. Context-per-test dissolves the old choice between fast-but-leaky shared profiles and clean-but-slow browser relaunches, and it is the default in @playwright/test: the page fixture you destructure already lives inside a fresh context built for that one test.
Because the context is the profile, emulation lives on the context. Viewport, device descriptor, locale, timezone, geolocation, color scheme, reduced motion, offline mode, granted permissions: all context creation options, not global switches. The practical consequence: two Playwright browser contexts in the same worker can simultaneously be a mobile user in Kolkata on dark theme and a desktop user in New York on light, in one browser process, without touching each other.
import { test, devices, expect } from '@playwright/test'; // Everything here configures the CONTEXT the runner creates per test. test.use({ ...devices['Pixel 7'], locale: 'en-IN', timezoneId: 'Asia/Kolkata', geolocation: { latitude: 12.9716, longitude: 77.5946 }, permissions: ['geolocation'], colorScheme: 'dark', }); test('store picker uses device location', async ({ page }) => { await page.goto('/store-locator'); await expect(page.getByText('Bengaluru')).toBeVisible(); });
In @playwright/test you rarely call browser.newContext() yourself. test.use() merges options into the context the runner builds, so the pattern is: project-wide defaults in playwright.config.ts, per-file or per-describe overrides with test.use(), and runtime calls like context.setGeolocation() only for the rare test where the user moves mid-flow. Keep emulation declarative and the runner can keep contexts cheap.
Everything a page fetches (documents, fetch and XHR calls, scripts, images, websocket handshakes) flows past the driver, so Playwright can see all of it. page.on('request') and page.on('response') expose the passive stream, page.waitForResponse() turns it into synchronization points, and page.route() upgrades observation into control. Point a script at the practice pages at https://app.thetestingacademy.com/playwright/ and log the request stream once; watching how chatty a seemingly static page is makes a useful calibration exercise.
The architectural detail that matters: Playwright network interception happens inside the browser, instructed over the driver protocol, not in an external proxy wedged between browser and internet. No proxy to configure, no certificate to install, and HTTPS is interceptable out of the box, because the browser itself does the intercepting on Playwright's behalf. That design is why a route handler can rewrite a live request in three lines, identically on Chromium, Firefox, and WebKit.
Register a handler with page.route(pattern, handler) or context.route(), and every matching request pauses until your handler settles it one of four ways.
| Handler call | Effect | Typical job |
|---|---|---|
route.fulfill() | Browser never sends the request; your canned response returns | Mock API states: empty, error, slow, huge |
route.continue() | Request proceeds with optional mutations (URL, method, headers, body) | Inject auth headers, retarget one endpoint |
route.abort() | Request fails like a network error | Kill analytics, ads, chat widgets |
route.fallback() | Defers to the next matching handler | Layer per-test overrides on suite defaults |
Patterns can be glob strings like '**/api/orders**', regular expressions, or predicate functions over the URL; relative globs resolve against your configured baseURL. Two ordering rules keep layered handlers sane: when several match, they run in reverse registration order (the newest wins), and page.route() handlers outrank context.route() handlers. That yields a clean composition pattern: broad suite defaults on the context, one-off overrides on the page in the test that needs them. Requests you never route continue to the network untouched.
The classic first use case is forcing a UI state that is nearly impossible to reach through the interface once seed data exists: the empty state.
import { test, expect } from '@playwright/test'; test('orders page renders the empty state', async ({ page }) => { // Settle the request inside the browser; it never reaches any backend. await page.route('**/api/orders**', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ orders: [], totalCount: 0 }), }) ); await page.goto('/account/orders'); await expect(page.getByRole('heading', { name: 'No orders yet' })).toBeVisible(); await expect(page.getByRole('link', { name: 'Start shopping' })).toBeVisible(); });
The request for /api/orders never leaves the machine. No test-account gymnastics, no "delete all orders" cleanup colliding with a parallel worker, and the empty state renders identically on every run, on every laptop, with the backend down. Swap the body and the same three lines produce the error banner (status: 500), the pagination edge, or the account with ten thousand orders. States that cost minutes of backend setup become data literals sitting beside the assertions that need them.
route.fulfill() hardcodes an assumption about what the backend returns. If the real API changes shape, mocked tests keep passing while production quietly breaks. Pair route-level mocks with a thin layer of API contract tests against the live service, and audit your fixtures whenever the backend team ships a schema change.The second daily job for interception is subtraction. Analytics beacons, session recorders, chat widgets, and ad pixels add seconds of load time, fire network activity at unpredictable moments, and occasionally hang entirely, and none of them are the thing under test. This is Playwright network interception used defensively: abort the noise at the context level in a shared fixture and every spec in the suite inherits a quieter, faster, more deterministic page.
// fixtures.ts - every spec importing this "test" gets a quiet network. import { test as base } from '@playwright/test'; export const test = base.extend({ context: async ({ context }, use) => { // Kill analytics, session recorders, and ad pixels for the whole context. await context.route( /(analytics|telemetry|tracking|adservice|pixel)\./, (route) => route.abort() ); await use(context); }, });
Blocking pays twice more: screenshots stop flaking because no consent widget or chat bubble pops mid-capture, and your synthetic traffic stays out of production analytics dashboards. Keep the blocklist in one reviewed file, not scattered across specs.
Hand-mocking every endpoint behind a rich page gets tedious fast. HAR record-and-replay automates the fixture authoring: record real traffic once, then replay it forever, offline and byte-for-byte deterministic. Recording is a context option, which fits the model: the Playwright browser context owns the network, so it owns the archive.
// Record once, against the real API (run locally, commit the file): const context = await browser.newContext({ recordHar: { path: 'hars/orders.har', urlFilter: '**/api/**' }, }); const page = await context.newPage(); await page.goto('https://staging.yourshop.example/account/orders'); await context.close(); // close() flushes the HAR to disk // Replay in tests - deterministic, offline: await page.routeFromHAR('hars/orders.har', { url: '**/api/**', update: !!process.env.UPDATE_HARS, // re-record on demand });
routeFromHAR serves matching requests from the archive. By default a request with no recorded entry is aborted, exactly the loud failure you want the day the frontend calls an endpoint nobody recorded. Run with update: true locally to re-record against the live API, review the diff like a snapshot, commit. CI then replays with the network effectively unplugged: no staging outage, rate limit, or slow third party can fail the build. Treat archives as versioned test data with a scheduled refresh, not write-once artifacts.
Interception's read-only sibling fixes a whole family of flake on its own. When a click fires a background save, asserting on the UI can succeed before the request ever lands, and the test passes while the save silently fails. Waiting for the actual response pins the test to reality.
// Arm the wait BEFORE the action that triggers the request. const saved = page.waitForResponse( (res) => res.url().includes('/api/checkout') && res.status() === 200 ); await page.getByRole('button', { name: 'Place order' }).click(); const response = await saved; expect((await response.json()).orderId).toBeTruthy();
Order matters: arm the wait before the click, await it after. Start listening after the action and you are racing the response; against a fast local API you will lose that race often enough to burn an afternoon on a bug that never existed.
Interception is a power tool, not a religion. A suite that mocks everything verifies a frontend against its own assumptions; a suite that mocks nothing inherits every backend hiccup as a red build. A well-architected suite runs all the strategies below deliberately, each assigned to the layer where its trade-off is the right one.
| Strategy | Determinism | What it verifies | Use it for |
|---|---|---|---|
Mock with page.route() | Total; runs offline | Frontend behavior against an assumed contract | Edge-state UI: empty lists, error banners, pagination limits |
HAR replay via routeFromHAR | High; replays recorded reality | Frontend against a captured real contract | Broad journeys on every pull request |
| Seed via API, real backend | Medium; owns its data | Frontend, backend, and the wiring between them | Money paths: signup, login, checkout |
| Real backend, shared data | Low | The whole system, including drift | A thin post-deploy smoke layer only |
A split that has held up well in practice: route-level mocks for edge-state UI tests, HAR replay for broad journey coverage on every pull request, API-seeded tests against a real backend for auth and checkout, and a thin smoke of unmocked reality right after each deploy.
When the portable API runs out, Chromium exposes the entire Chrome DevTools Protocol, and Playwright will hand you a raw session. A Playwright CDP session is a direct line to the same interface DevTools uses internally: performance metrics, JavaScript and CSS coverage, memory details, throttling, and emulation knobs beyond the portable surface.
import { test, expect } from '@playwright/test'; test('checkout stays under the JS heap budget', async ({ page }) => { const cdp = await page.context().newCDPSession(page); await cdp.send('Performance.enable'); await page.goto('/checkout'); const { metrics } = await cdp.send('Performance.getMetrics'); const heap = metrics.find((m) => m.name === 'JSHeapUsedSize'); expect(heap.value).toBeLessThan(60_000_000); });
Two cautions. It is Chromium-only, so anything CDP-dependent belongs in a Chromium-scoped project in your config, never in the cross-browser matrix. And it is deliberately low-level: prefer the portable API when one exists (page.coverage wraps this same protocol for JS and CSS coverage), and treat Playwright CDP as the escape hatch for the last five percent: heap budgets on a perf-sensitive checkout, dead-code hunts, exotic emulation. Playwright can also attach to an already-running Chrome via chromium.connectOverCDP(), sometimes the only way to automate a browser you did not launch.
Folder layout looks like bikeshedding until the suite crosses a hundred specs. After that it decides everything: how fast a new joiner ships their first test, how expensive the next UI redesign is, and whether flake gets fixed or papered over. Playwright gives you the runner, fixtures, and auto-waiting for free. Everything above that line (pages, components, data, tagging) is architecture you own, and it is exactly where most suites quietly rot.
Here is the Playwright project structure I keep converging on, whether the team is two people or forty:
. |-- playwright.config.ts # projects, baseURL, reporters: all environment wiring lives here |-- tests/ # specs only: intent, assertions, tags, grouped by feature | |-- auth/ | |-- checkout/ | `-- orders/ |-- pages/ # page objects: thin locator + action maps, one per screen |-- components/ # shared widgets: header, table, modal, toast |-- fixtures/ # test.extend() wiring: auth state, seeded data, API client |-- data/ # factories and static JSON: builders, never logic |-- utils/ # pure helpers (dates, money, ids): zero Playwright imports `-- .auth/ # gitignored storage states written by the setup project
The tree matters less than the dependency direction it enforces. Tests import pages, fixtures, and data. Pages import components. Utils import nothing from Playwright at all, which keeps them unit-testable in isolation. Nothing, ever, imports from tests/. The moment an import points the wrong way (a page reaching into a spec file, a util accepting a Page), you have found tomorrow's maintenance problem today. Draw the arrows on a whiteboard once and the whole team can police them in review without a linter.
| Layer | Owns | Must never contain |
|---|---|---|
tests/ | User intent, assertions, tags | Raw selectors, sleeps, API plumbing |
pages/ | Locators and actions for one screen | Assertions, waits, test data, mutable state |
components/ | One widget, scoped to a root locator | Navigation, knowledge of host pages |
fixtures/ | Setup, teardown, composition | Assertions, business logic |
data/ | Factories and reference JSON | Locators, page imports |
utils/ | Pure functions | Anything that touches the browser |
One more boundary worth naming: playwright.config.ts is the only file allowed to know about environments. The baseURL, the projects matrix, reporters, retries, and parallelism all live there, which is why every goto() in this section uses a relative path. Specs and pages stay environment-blind; point the config at a local build, staging, or a PR preview URL and the rest of the Playwright project structure runs unchanged.
The Playwright page object model has a reputation problem it inherited from older automation stacks: 400-line classes, wait wrappers around every read, and a BasePage half the team is afraid to touch. None of that is needed here. In Playwright, a page object is exactly two things: a named map of locators, and a few action methods that bundle user gestures. Nothing else earns a place in the file.
Three rules keep it that way. First, assertions stay in tests. A spec that reads await expect(orders.table.rows).toHaveCount(3) declares the behavior it is claiming, in one line, exactly where the reviewer is looking. Bury that inside a verifyOrderCount() method and the spec becomes a list of opaque calls that all "pass". When the same cluster of assertions repeats across specs, extract an expect-helper function that lives next to the tests, not inside the page class.
Second, no waits inside page objects. Locator actions auto-wait for actionability, and web-first assertions retry until timeout. A waitForTimeout or waitForSelector inside a page object is either dead weight or a bandage over a signal you should be asserting on. A large share of Playwright best practices reduce to this one habit: trust auto-wait, assert on visible outcomes.
Third, locators are built once, in the constructor, as readonly fields. A Locator is a lazy query, not a cached element: it re-resolves on every action, so it survives re-renders. Declaring them in one place gives every selector a name, and a UI rename becomes a one-line change. When a test needs a computed value, expose the locator and let the test read it: the page hands out named handles, never conclusions.
waitForTimeout, the app is emitting a signal you have not asserted on yet: a spinner detaching, a toast appearing, a response landing. Assert on that instead. The wait-shaped hole in your page object is really an observability gap in your test.Headers, data tables, modals, and toasts show up on a dozen screens. Copying their locators into every page object guarantees drift. Give each widget its own class that takes a root Locator, so every inner query is scoped to that subtree, then compose instances into pages. The promotion rule is mechanical, not aesthetic: the first time a second page needs the same widget, it moves to components/. Do not design a component library up front; extract one when the duplication appears:
// components/table.component.ts import { Locator } from '@playwright/test'; export class TableComponent { constructor(private readonly root: Locator) {} get rows(): Locator { return this.root.locator('tbody tr'); } row(text: string | RegExp): Locator { return this.rows.filter({ hasText: text }); } cell(rowText: string, column: number): Locator { return this.row(rowText).locator('td').nth(column); } async sortBy(header: string) { await this.root.getByRole('columnheader', { name: header }).click(); } } // pages/orders.page.ts import { Locator, Page } from '@playwright/test'; import { TableComponent } from '../components/table.component'; export class OrdersPage { readonly heading: Locator; readonly search: Locator; readonly statusFilter: Locator; readonly table: TableComponent; constructor(private readonly page: Page) { this.heading = page.getByRole('heading', { name: 'Orders' }); this.search = page.getByPlaceholder('Search orders'); this.statusFilter = page.getByLabel('Status'); this.table = new TableComponent(page.getByRole('table', { name: 'Orders' })); } async goto() { await this.page.goto('/orders'); } async filterByStatus(status: string) { await this.statusFilter.selectOption(status); } async searchFor(term: string) { await this.search.fill(term); await this.search.press('Enter'); } }
Now look at what the spec reads like: navigation, one action, two assertions, nothing else. The page object contributed names, not logic.
// tests/orders/orders-filter.spec.ts import { test, expect } from '@playwright/test'; import { OrdersPage } from '../../pages/orders.page'; test('status filter narrows the orders table', async ({ page }) => { const orders = new OrdersPage(page); await orders.goto(); await orders.filterByStatus('Shipped'); await expect(orders.table.rows).toHaveCount(3); await expect(orders.table.cell('ORD-1042', 4)).toHaveText('Shipped'); });
TableComponent knows nothing about orders. The same class drives the users grid in the admin panel and any sortable table you point it at, and because every query is scoped to the root, two tables on one screen never collide. That is the Playwright page object model done right: names and gestures in the page, truth in the test. If you want a safe target to drill the pattern against, the practice pages at https://app.thetestingacademy.com/playwright/ include sortable tables, modals, and nested widgets built for exactly this kind of rep work.
Playwright test data fails in two familiar ways: hardcoded records that every test fights over, and UI-driven setup that spends ninety seconds clicking through forms to earn a ten-second assertion. The fix is the same pair every time: factory functions for anything a test creates or mutates, and API seeding to get it into the system.
Static fixture files (JSON checked into data/) are fine for reference data nothing mutates: country lists, a catalog snapshot, a permissions matrix. Everything else gets a factory that stamps unique values per call, so parallel workers and retries never collide on a "well-known" record:
| Static fixture file | Factory function | |
|---|---|---|
| Best for | Read-only reference data | Anything created or mutated |
| Uniqueness | None: shared by every test | Timestamped per call |
| Parallel safety | Collides across workers and retries | Safe by construction |
| Drift risk | Rots as the schema evolves | Type-checked against your models |
// data/factories/order.factory.ts export type OrderInput = { reference: string; customerEmail: string; items: { sku: string; qty: number }[]; status: 'pending' | 'paid' | 'shipped'; }; export function buildOrder(overrides: Partial<OrderInput> = {}): OrderInput { const stamp = `${Date.now()}-${process.pid}`; return { reference: `E2E-${stamp}`, customerEmail: `e2e.${stamp}@example.test`, items: [{ sku: 'MUG-BLK', qty: 1 }], status: 'pending', ...overrides, }; }
The Date.now() plus process.pid stamp earns its keep fast: unique emails survive database uniqueness constraints, retried tests create fresh rows instead of tripping over half-deleted ones, and any record left behind by a crashed run traces back to a specific moment and worker. Cheap insurance, two lines of code.
Seeding belongs in fixtures, not in specs and never in the UI. The built-in request fixture hits your app's API directly: creating an order takes tens of milliseconds instead of a minute of form filling, and it cannot break on a cosmetic change to a form you were not even testing. This is the single biggest speed lever in a Playwright test data strategy. Teardown goes after use(), so it runs whether the test passed or failed:
// fixtures/orders.fixture.ts import { test as base } from '@playwright/test'; import { buildOrder, OrderInput } from '../data/factories/order.factory'; type OrderFixtures = { seededOrder: OrderInput & { id: string }; }; export const test = base.extend<OrderFixtures>({ seededOrder: async ({ request }, use) => { const res = await request.post('/api/orders', { data: buildOrder() }); const order = await res.json(); await use(order); // the test runs here await request.delete(`/api/orders/${order.id}`); // teardown, pass or fail }, });
use() never runs. Make seeded records recognizable (the E2E- prefix above) and schedule a sweep that deletes anything older than a day. Residue in a shared environment eventually becomes someone else's flaky test.Tags are the contract between the suite and the pipeline. Keep the taxonomy tiny and documented: @smoke is the five-minute merge gate, @regression is the nightly full pass, @slow marks anything over a minute so PR runs can exclude it. Apply them through test.describe options or inline in titles; both are greppable from the CLI:
import { test, expect } from '../fixtures/orders.fixture'; test.describe('checkout', { tag: ['@smoke', '@checkout'] }, () => { test('pays for a seeded order with a saved card', async ({ page, seededOrder }) => { // ... }); test('rejects an expired card @regression', async ({ page }) => { // ... }); // PAY-311: split-payment service returns 502 in staging test.fixme('splits payment across two saved cards', async () => {}); });
npx playwright test --grep @smoke # merge gate npx playwright test --grep-invert @slow # everything except the heavy stuff
In CI those tags map one-to-one onto pipeline stages: the merge gate runs --grep @smoke, the nightly job runs the full pass, and a targeted job can run --grep @checkout when only checkout code changed. That is the payoff of a disciplined taxonomy: you slice the suite from the command line instead of maintaining parallel test lists that drift out of date.
Annotations deserve the same honesty. test.skip, test.fixme, and test.fail each carry a distinct meaning: skip is "not applicable here", fixme is "broken test, do not run it", and fail is "known product bug, alert me the day it starts passing", which is the correct way to track a bug you have decided to live with. Always attach a reason with a ticket id, in a comment or the annotation description. An unexplained skip is deleted coverage wearing a disguise: nobody remembers why it is off, so nobody dares turn it back on.
Every one of these shows up in teams that skimmed a Playwright best practices thread and applied it halfway. Each feels like structure while you are writing it and taxes you for years afterward:
| Anti-pattern | Why it hurts | Do instead |
|---|---|---|
Deep BasePage inheritance chains | Behavior smears across the hierarchy; one change ripples through every page | Composition: pages hold components; share setup through fixtures |
State in page object fields (this.lastOrderId) | Breaks parallel workers and retries; hides the data flow from the reader | Return values from actions; keep state inside the test's scope |
| Assertions inside page objects | Buries intent; forces unwanted checks on every reuser | expect in specs, or shared expect-helpers beside the tests |
One giant helpers.ts | Merge-conflict magnet with an unknowable API surface | Small single-purpose modules in utils/, named by behavior |
| Wait wrappers "for stability" | Fights auto-wait, adds minutes to every run, still flakes | Web-first assertions on the outcome you actually care about |
The common thread: each anti-pattern moves information away from the test that depends on it. A Playwright project structure earns its keep when the answer to "what does this test do, and why did it fail" is visible in one spec, one page object, and one fixture: flat, boring, greppable. Guard that property in code review with two questions. Does anything in pages/ assert or wait? Does anything in tests/ contain a raw selector? Both answers should stay no, forever.
A suite that only runs on laptops is a demo. Everything you built in the earlier sections (isolation, fixtures, deterministic data) exists so the suite can run sixteen-wide on disposable machines and still produce one coherent answer. A healthy Playwright CI/CD setup has exactly two properties: feedback lands in minutes, not an hour, and every red test ships enough evidence that you never have to say "cannot reproduce". This section builds both.
Playwright's unit of parallelism is the worker: an OS process that runs one test at a time against its own browser context. Test files are distributed across workers, and with fullyParallel: true individual tests inside a file can be split too. Locally the default is half your logical cores. The scaffolded config pins workers: 1 in CI, which is a cautious default, not a law: a standard 4 vCPU runner drives 4 workers comfortably for most web apps, and the real ceiling is usually your backend or runner memory, not Playwright.
Because your tests share nothing (no common accounts, no ordering assumptions), worker count is a dial, not a refactor. But the dial flattens out. Push 8 workers onto a 4 vCPU box and the browsers fight for CPU, timings stretch, and you manufacture flakiness. When one machine saturates, scale horizontally.
Playwright sharding splits one logical run across independent machines. npx playwright test --shard=2/4 executes the second quarter of the suite and nothing else. Each shard is a complete Playwright process with its own workers, so total parallelism is shards times workers: 4 shards with 4 workers each is a 16-wide run.
The historical pain was reporting: four machines produce four disconnected result sets. The blob reporter solves it. Each shard writes a blob-report/ archive containing everything it observed, attachments and traces included. You upload each blob as a CI artifact, then a final job downloads all of them and runs npx playwright merge-reports, producing a single Playwright report indistinguishable from a one-machine run.
// playwright.config.ts - the CI-relevant slice import { defineConfig } from '@playwright/test'; export default defineConfig({ fullyParallel: true, workers: process.env.CI ? 4 : undefined, retries: process.env.CI ? 2 : 0, reporter: process.env.CI ? [['blob'], ['github']] : [['list'], ['html', { open: 'on-failure' }]], use: { baseURL: process.env.BASE_URL ?? 'https://app.thetestingacademy.com/playwright/', trace: 'on-first-retry', screenshot: 'only-on-failure', video: 'retain-on-failure', }, });
Note the shape: reporters and retries switch on process.env.CI, artifacts are failure-scoped, and baseURL comes from the environment so the same suite can point at a preview deployment, staging, or a practice target.
Here is a complete Playwright GitHub Actions workflow: a matrix job that fans out four shards, and a merge job that publishes the unified HTML report.
# .github/workflows/e2e.yml name: e2e on: pull_request: push: branches: [main] jobs: test: name: shard ${{ matrix.shard }} of 4 runs-on: ubuntu-latest timeout-minutes: 20 strategy: fail-fast: false # one red shard must not cancel the others matrix: shard: [1, 2, 3, 4] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: npm - run: npm ci - name: Cache Playwright browsers uses: actions/cache@v4 with: path: ~/.cache/ms-playwright key: pw-${{ runner.os }}-${{ hashFiles('package-lock.json') }} - name: Install browsers run: npx playwright install --with-deps chromium - name: Run shard ${{ matrix.shard }}/4 run: npx playwright test --shard=${{ matrix.shard }}/4 env: BASE_URL: ${{ vars.E2E_BASE_URL }} - name: Upload blob report if: ${{ !cancelled() }} uses: actions/upload-artifact@v4 with: name: blob-report-${{ matrix.shard }} path: blob-report retention-days: 3 report: name: merge and publish report if: ${{ !cancelled() }} needs: [test] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: npm - run: npm ci - name: Download all blob reports uses: actions/download-artifact@v4 with: path: all-blobs pattern: blob-report-* merge-multiple: true - name: Merge into one HTML report run: npx playwright merge-reports --reporter html ./all-blobs - name: Upload merged report uses: actions/upload-artifact@v4 with: name: playwright-report path: playwright-report retention-days: 14
Walk the load-bearing lines. fail-fast: false matters more than it looks: the default cancels sibling shards the moment one fails, which destroys exactly what you want from a run (all failures, in one report). if: !cancelled() on the blob upload means failing shards still ship their blobs, and failing shards are the ones you care about; the merge job runs on failure for the same reason.
The browser cache is the quiet win. ~/.cache/ms-playwright holds the browser builds; keyed on the lockfile hash, a warm cache skips a roughly 150 MB Chromium download per shard per run. Multiply by four shards and dozens of runs a day and it is the difference between a 30 second and a 3 minute setup phase.
npx playwright install --with-deps in the job: OS packages are not cached, and the browser download is skipped automatically when the restored version matches. Key on the lockfile or Playwright version so an upgrade cannot restore stale binaries. If you would rather skip the dance, run jobs in the official Playwright container image with browsers preinstalled.retries: process.env.CI ? 2 : 0 encodes a policy. Locally, zero: a red test on your machine should stay red until you understand it. In CI, up to two extra attempts absorb genuinely environmental failures (a runner CPU stall, a third-party blip) so one bad millisecond does not block the release train.
Retries change what statuses mean, and the Playwright report is explicit about it. Passes on the first attempt: passed. Fails every attempt: failed, and the build goes red. Fails, then passes on a retry: flaky, reported in its own bucket, and by default it does not fail the build. That default is deliberate and dangerous in equal measure. Treat the flaky bucket as a debt ledger, not a trash can: review it weekly, and for money-path suites consider --fail-on-flaky-tests so flakiness blocks merges instead of compounding.
trace: 'on-first-retry' is what makes retries pay rent. Attempt one runs with zero tracing overhead, the common case since most tests pass. If it fails, the first retry records a full trace, so every test that ends the run failed carries a trace of a failing attempt. One honest nuance: for a flaky test, the trace shows the retry that passed; the failing first attempt still has its error, screenshot, and video, but no trace. If your flake hunt demands a trace of the actual failure, trace: 'retain-on-first-failure' records attempt one and keeps it only on failure, at the cost of tracing every first attempt.
When a test fails on a machine that no longer exists, artifacts are the only witnesses. Playwright gives you three, and they are not interchangeable.
The trace is the single best debugging artifact in browser testing. Open one in the Playwright trace viewer and you get a timeline film strip of the whole test, every action with before and after DOM snapshots, a full network log with request and response bodies, console output, and the source line each action came from. The snapshots are real DOM, not pixels: inside the Playwright trace viewer you can open devtools and inspect selector state at the exact moment a click missed. Run npx playwright show-trace path/to/trace.zip, or click the trace icon in the HTML report.
Screenshots ('only-on-failure') are the cheap first look: the final frame of each failed attempt, usually enough to separate "app error page" from "assertion was wrong". Video ('retain-on-failure') records the whole test and keeps the file only when it fails; it earns its storage when the bug is motion, like a toast that appears and vanishes or a layout shift mid-click, which discrete snapshots miss.
| Artifact | Setting | Captured when | Typical size | Retention advice |
|---|---|---|---|---|
| Trace | trace: 'on-first-retry' | First retry of a failing test | 2-15 MB per test | 7-14 days; the one you will actually open |
| Screenshot | screenshot: 'only-on-failure' | Final state of each failed attempt | 50-300 KB | 7 days; cheap, keep liberally |
| Video | video: 'retain-on-failure' | Whole test, kept only on failure | 1-3 MB per minute | 3-7 days; enable when motion matters |
| Blob report | reporter: 'blob' | Every sharded run | 1-50 MB per shard | 1-3 days; dead once the merge job runs |
| HTML report | merge-reports --reporter html | Merge job | Sum of the blobs | 14-30 days; the shareable record |
Storage is a real bill at scale, which is why every capture setting above is failure-scoped. Blob reports embed their shard's traces and screenshots, so keep their retention short: they are an intermediate product, obsolete the moment the merged Playwright report is published.
Reporters are fan-out for results, and you can run several at once. list is for a human watching a terminal. html is the human artifact for triage, with attachments and trace links in one browsable page. junit emits XML for test management systems and CI dashboards that ingest standard results. The github reporter annotates the pull request itself, pinning failure messages to the failing line so reviewers see red without leaving the diff. blob exists purely to feed merge-reports, which can then emit any of the others from the same data. And a json or custom reporter is your integration escape hatch: pipe durations into a script that flags p95 creep before your Playwright CI/CD loop quietly doubles over a quarter.
| Reporter | What it emits | Best audience |
|---|---|---|
list | Live line-per-test terminal output | You, running locally |
html | Interactive report with attachments and traces | Humans triaging CI failures |
blob | Machine-readable shard archive | merge-reports, nothing else |
junit | JUnit XML results | Test management and CI ingestion |
github | Inline PR annotations | Reviewers on the pull request |
json / custom | Structured results for scripts | Dashboards, flake and duration analytics |
Concrete numbers, because "faster" is not a plan. Take 400 tests at an average of 8 seconds: 3,200 seconds of pure test time.
| Configuration | Total parallelism | Pure test time | Realistic wall clock |
|---|---|---|---|
| 1 worker, 1 machine | 1 | 53m 20s | ~55 min |
| 4 workers, 1 machine | 4 | 13m 20s | ~16 min with setup |
| 4 shards x 4 workers | 16 | 3m 20s per shard | ~6-7 min including merge |
Per shard: 100 tests across 4 workers is about 200 seconds of testing. Add the fixed tax every shard pays (checkout, npm ci, warm browser cache restore: roughly 2 minutes) and the slowest shard sets the wall clock near 5 to 6 minutes, with the merge job adding one more. The pipeline goes from 53 serial minutes to about 7, and that last number is the one that changes behavior: engineers stop batching pushes to dodge CI.
Two caveats keep the math honest. First, it assumes even distribution. In the merged report, compare shard durations; if one consistently runs long, find its slow spec files and split them. Second, the fixed tax argues against over-sharding: at 16 shards you would pay 2 minutes of setup for 50 seconds of testing per shard, spending 32 machine-minutes to save about 90 wall-clock seconds. The working rule for Playwright sharding: add shards until per-shard test time drops to 2 or 3 times your setup cost, then stop.
A suite that fails randomly is worse than no suite at all. When a red build might mean a real bug or might mean nothing, people stop reading the results, and the moment they stop reading, the suite is dead weight. Everything in this blueprint so far, the driver model, fixtures, auto-waiting, contexts, has been quietly building toward one payoff: a Playwright suite that is stable by construction. This final section is about defending that stability on purpose, and about the governance that keeps a growing team from eroding it one shortcut at a time.
Flake is not random. It is a race you have not noticed yet. In a browser automation suite the same handful of causes show up again and again, and each one has a specific, boring fix. The instinct to reach for a retry or a longer timeout treats the symptom; the goal here is to name the cause so you can delete it.
| Cause of flake | What it looks like | The real fix |
|---|---|---|
| App not ready when you act | Click lands before hydration wires the handler; nothing happens | Act through locators (auto-wait handles readiness) and assert on outcomes, never on timers |
| Test-order coupling | Passes alone, fails in the suite, or only fails when sharded differently | Fresh context per test, seed data per test, never rely on a previous test's side effects |
| Data collisions in parallel | Two workers register the same email, one wins | Unique data per run (timestamp or uuid suffix), or a per-worker data namespace |
| Time and timezone | A test passes until midnight or in a CI region set to UTC | Freeze time with the Clock API, pin timezone and locale on the context |
| Animations | Element is moving when the click fires, hit point misses | Auto-wait already checks stability; kill motion with reducedMotion for good measure |
| Third-party calls | An analytics or ads endpoint is slow, the page hangs waiting on it | Block third-party origins with route so your test only depends on your app |
| Eventual consistency | A write shows up in the UI a beat after the API returns 200 | expect.poll or a web-first assertion that retries until the value appears |
Read that table twice. Every row is a race between your test and something asynchronous, and in every row the fix is to make the test wait for a real condition instead of guessing at a duration. That single idea is the spine of stable end to end testing.
Playwright ships specific tools for the causes above. Learn these four and most flake simply stops happening.
Web-first assertions and expect.poll cover eventual consistency. When the thing you want to check is not a locator but a computed value or an API result, poll it:
// Retries the whole block until it returns truthy or times out await expect.poll(async () => { const res = await request.get('/api/orders/count'); return (await res.json()).count; }, { timeout: 10000 }).toBe(3);
expect(...).toPass() wraps a group of assertions in a retry loop, useful when a small sequence needs to settle together rather than one value:
await expect(async () => { await page.getByRole('button', { name: 'Refresh' }).click(); await expect(page.getByTestId('status')).toHaveText('Synced'); }).toPass({ timeout: 15000 });
The Clock API takes time out of the equation. If a banner appears "3 days before expiry" or a session logs out after an idle period, you do not wait three days or sit idle; you set the clock:
await page.clock.install({ time: new Date('2026-01-01T09:00:00') }); await page.goto('/dashboard'); await page.clock.fastForward('30:00'); // 30 minutes of idle in an instant await expect(page.getByText('Session expired')).toBeVisible();
reducedMotion and route blocking remove two whole categories of environmental noise. Set them once at the context or config level and forget them:
// playwright.config.ts, in the use block use: { baseURL: 'https://app.thetestingacademy.com/playwright/', reducedMotion: 'reduce', }, // a fixture or beforeEach: your app only, no third parties await context.route(/analytics|doubleclick|hotjar/, r => r.abort());
Even with everything above, a genuinely flaky test will occasionally slip in. The wrong response is to add retries: 3 and move on, because that hides the signal that something is racing. The right response is a quarantine policy. Measure each test's flakiness rate as failures divided by attempts across recent runs. A test that fails more than about ten percent of the time on unchanged code is flaky by definition and gets tagged and moved to a separate, non-blocking job. It keeps running so you can see whether your fix worked, but it stops failing the build for everyone else. Crucially, a quarantined test is still a test you owe a fix to, not a test you have deleted. The two ideas that make this work, measuring a rate and keeping the test alive while you fix the cause, are the whole of a sane flake process.
One careful engineer can keep a suite stable. Ten engineers under deadline pressure will erode it unless the standards live somewhere other than one person's head. The cheapest governance tool is a pull-request checklist that reviewers actually enforce.
| Governance control | What it prevents |
|---|---|
| Locators must be role or test-id first, CSS or XPath only with a written reason | Brittle selectors that break on every restyle |
No page.waitForTimeout in committed tests | Sleep-based flake and slow suites |
| Test data is seeded or generated unique, never a shared fixture account | Parallel collisions and order coupling |
| Trace on first retry stays enabled in CI | Undebuggable CI-only failures |
| Each suite area has a named owner | Flaky tests nobody feels responsible for |
| A wall-clock budget on the full suite (say twelve minutes) | A suite that quietly grows until nobody waits for it |
Ownership deserves a sentence of its own. A suite where "the QA team" owns everything is a suite where no individual owns anything, and flaky tests accumulate in the commons. Assign areas, checkout to one owner, dashboard to another, so that when a test in that area flakes, there is a name attached to fixing it. Governance is not bureaucracy here; it is the difference between a suite that stays green and a suite that slowly rots.
Everything in this Playwright architecture blueprint compresses into a dozen decisions. If you make these twelve, the rest tends to follow.
playwright.config.ts: projects per browser and environment, a sane timeout hierarchy, a single use block for shared setup.storageState; never log in through the UI in every test.getByRole, then label and placeholder, then getByTestId, and CSS or XPath only as a last resort.waitForTimeout.route: mock what is unstable, block third parties, seed real data through the API.--shard and merge blob reports; retry only in CI and capture a trace on the first retry.Is Playwright genuinely faster than Selenium, and why architecturally? Usually yes, and the reason is the wire. Selenium pays one HTTP round trip per command to a driver executable that speaks the W3C protocol, so latency multiplies across a chatty flow. Playwright holds a single persistent connection to a Node.js driver (a local pipe, or a WebSocket to a remote browser) and speaks the browser's native protocol directly, and it reuses cheap browser contexts instead of launching browsers. Fewer round trips and lighter isolation add up to real wall-clock savings, especially in parallel.
Do I still need the Page Object Model? You need the discipline, not the ceremony. Because auto-waiting removes the wait boilerplate that page objects used to hide, a thin locator-and-action map plus reusable components is often enough. Keep assertions in the tests and avoid deep BasePage inheritance. The value of a page object today is a single place to change a locator, not a place to hide sleeps.
How many workers should I run? Start with the default (roughly half your CPU cores) locally, and in CI combine a handful of workers per machine with --shard across machines. Watch two things: total wall-clock time and flake rate. If adding workers stops reducing wall-clock, you are bound by something shared (a database, a rate limit), not by parallelism, and more workers will only add contention.
When should I mock an API versus hit the real backend? Mock with route when you are testing the front end's behavior for a specific server response, especially error and empty states that are hard to produce for real. Seed through the real API when you are testing an integrated flow and want confidence the contract holds. Reserve full end to end against a live backend for a small set of critical journeys; mock or seed everything else for speed and determinism.
How do I debug a failure that only happens in CI? Turn on tracing with the on-first-retry policy and open the resulting trace.zip with npx playwright show-trace. The trace gives you the DOM snapshot, network, and console at every step, which is almost always enough to see the race. If it is environmental, reproduce the CI conditions locally: same browser channel, same timezone and locale on the context, third parties blocked, and the same shard.
Companion reading: the Selenium architecture blueprint (compare the two stacks hop by hop), the CSS selector cheat sheet, the XPath master cheat sheet, and the Playwright MCP + AI agents guide.
Practice on the live pages →