Draft - private preview - this page is not yet wired into the main sidebar. Internal review only.
Concept reference - Beyond the basics
Playwright advanced - the patterns you ship
Nine modules covering the harder ground - Page Object Model, dependency injection through custom
fixtures, data-driven specs from JSON / CSV / XLSX / Faker, network interception, an API plus UI hybrid
seeding strategy, storage-state auth, visual regression, trace debugging, and CI sharding with
merge-reports. Every snippet targets the TTA practice pages.
Stop sprinkling selectors across specs. A Page Object holds the locators and exposes business actions. A good POM reads like a domain script - login.loginAs(user) not page.fill('#user', ...).
classconstructor(page)readonly locatorasync open()action methodsreturn next pageBasePagegetByRoleexpect inside POM?
graph LR
T[spec.ts]:::test --> L[LoginPage]:::pom
L -- loginAs --> I[InventoryPage]:::pom
I -- addToCart --> C[CartPage]:::pom
C -- checkout --> X[CheckoutPage]:::pom
classDef test fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a;
classDef pom fill:#dcfce7,stroke:#15803d,color:#14532d;
POM chain - each action method returns the next page object.
Anatomy of a Page Object
Constructor. Takes page: Page and stores the locators as readonly fields. No business work in the constructor.
Locators. Defined once at the top. Prefer getByRole / getByLabel / getByTestId. Reach for CSS only when semantic is awkward.
Actions. One method per business intent - loginAs(user, pwd), addToCart(productId), checkout(). Methods that change page state return the next page object.
Expectations. Tiny inline asserts inside the POM (await expect(this.banner).toBeVisible()) are fine for state-guards. Heavy assertion logic stays in the spec.
Header / footer / nav live on every page. Put them in a BasePage with protected readonly locators, then have each concrete page extend it. Keep the hierarchy shallow - one level is almost always enough.
Write LoginPage and InventoryPage for the TTACart demo. loginAs returns InventoryPage. Specs should never touch page.fill directly.
Add a BasePage exposing the cart badge and "logout" link. Extend three concrete pages from it.
Refactor a flat spec (200 lines, lots of selectors) into a POM-driven version (less than 30 lines per test).
Add an inline expectation inside LoginPage.loginAs that asserts no error banner shows up. Decide where that assertion best lives.
Ex 1 - spec talks to POM only.Ex 2 - BasePage holds cart badge + logout; three children extend.Ex 3 - 200 lines -> ~25 lines per test.Ex 4 - inline guard expectation inside the POM action.
2Custom fixtures and dependency injection
A fixture is a setup function plus a teardown. Wrap your page objects, your API client, your seeded test data. Specs receive them through destructuring - that's the DI part.
test.extenduse()fixture optionsscope: 'worker'scope: 'test'override fixturetimeout per fixtureauto fixtures
graph LR
B[test-base.ts]:::base --> F1[loginPage]:::fix
B --> F2[cartPage]:::fix
B --> F3[api]:::fix
F1 --> S1[spec - cart flow]:::spec
F2 --> S1
F3 --> S2[spec - api contract]:::spec
F3 --> S1
classDef base fill:#fef3c7,stroke:#b45309,color:#7c2d12;
classDef fix fill:#dcfce7,stroke:#15803d,color:#14532d;
classDef spec fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a;
Custom fixtures inject page objects + api client into specs via destructuring.
Page-object fixtures
Centralise construction in one test-base.ts file. Every spec then imports test from that file, never from @playwright/test directly.
Define an option fixture for swappable values - environment: 'qa', defaultUser: 'standard_user'. Override per project in playwright.config.ts. The same spec runs against qa, stg, and prod by flipping the project.
Worker-scoped vs test-scoped
Default scope is test - new instance per test. Set { scope: 'worker' } for expensive setup that's safe to share - an API token, a seeded database, a logged-in browser context.
Move all six TTACart page objects into a single fixture file. Update three specs to consume them via destructuring.
Add an option fixture defaultUser. Project A uses standard_user, project B uses problem_user.
Build a worker-scoped fixture that creates an API auth token once and shares it across all tests in the worker.
Override the built-in page fixture so it always navigates to the booking landing page before the spec body runs.
Ex 1 - six POMs, three specs destructure what they need.Ex 2 - same spec runs under two user identities via projects.Ex 3 - worker-scoped fixture creates the token once, reuses it.Ex 4 - overridden page fixture pre-navigates.
3Data-driven testing
Same test logic, many inputs. Inline arrays, JSON, CSV, XLSX, Faker - each has a sweet spot. The goal is one spec, many real users.
for...oftest.each patternJSON.parsecsv-parse/syncxlsx@faker-js/fakerdescribe per rowtest title interpolation
graph LR
S1[inline array]:::src --> P[parametrised loop]:::loop
S2[JSON]:::src --> P
S3[CSV]:::src --> P
S4[XLSX]:::src --> P
S5[Faker seeded]:::src --> P
P --> T1[test - row 1]:::test
P --> T2[test - row 2]:::test
P --> T3[test - row N]:::test
classDef src fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a;
classDef loop fill:#fef3c7,stroke:#b45309,color:#7c2d12;
classDef test fill:#dcfce7,stroke:#15803d,color:#14532d;
Data source feeds a loop that mints one test per row.
Inline driver
Best for fewer than ten cases. Readable, no extra files.
Drive the QA Profile form on /tables/practice.html from a 5-row inline array. Each row submits and asserts the resulting summary.
Read users.json with 6 users and run the login spec for every entry. Tag valid users @happy and invalid users @negative.
Read products.csv on the data-driven widget. Loop over each row and assert the price column rounds to two decimals.
Generate a Faker-based name + email + zip for the booking form. Use a fixed seed so the first run and a CI re-run produce identical data.
Ex 1 - inline 5-row array drives 5 form submissions.Ex 2 - JSON users split into @happy + @negative tags.Ex 3 - CSV-driven price-rounding assertion.Ex 4 - seed=42 gives identical data on every run.
4Network interception
Mock the backend, abort the trackers, fail the payment - all from your test. Network control turns brittle E2E suites into deterministic spec runs.
page.routeroute.fulfillroute.abortroute.continueroute.fetchpage.waitForResponsepage.waitForRequestcontext.routeHAR record / replay
graph LR
A[page request]:::s --> B[page.route handler]:::r
B --> C{decide}
C -- fulfill --> D[mock body]:::mock
C -- abort --> E[blocked]:::stop
C -- continue --> F[real server]:::real
D --> G[client receives mock]:::ok
E --> G2[error logged]:::stop
F --> H[client receives real]:::ok
classDef s fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a;
classDef r fill:#fef3c7,stroke:#b45309,color:#7c2d12;
classDef mock fill:#dcfce7,stroke:#15803d,color:#14532d;
classDef stop fill:#fee2e2,stroke:#b91c1c,color:#7f1d1d;
classDef real fill:#f5f3ff,stroke:#7c3aed,color:#4c1d95;
classDef ok fill:#dcfce7,stroke:#15803d,color:#14532d;
page.route(url, handler) hijacks any matching request. Reply with a static body via route.fulfill({ status, json }), drop it with route.abort(), or pass it through with route.continue(). Inspect the request first to make conditional mocks.
Wait for the same response you intercept with page.waitForResponse('**/api/payment') and assert on its body. Combine with Promise.all when the request is triggered by a click.
HAR record and replay
Capture a real session with context.routeFromHAR('flight.har', { update: true }), then run subsequent tests offline against the captured HAR. Great for unstable third-party APIs.
On /network/intercept.html mock the "products" endpoint to return three custom items. Assert the rendered cards match your mock.
Abort any request to **/analytics and confirm the page still renders correctly without analytics.
Fulfill the booking payment with a 500 status and assert the UI shows a retry banner.
Use waitForResponse to capture the JSON body and assert one field with expect.poll.
Record a HAR of the booking flow, then replay it offline to confirm the test passes with the network disabled.
Ex 1 - 3 mocked products render as 3 cards.Ex 2 - analytics blocked, UI renders fine.Ex 3 - fulfilled 500 surfaces the retry banner.Ex 4 - capture JSON body, expect.poll on one field.Ex 5 - record HAR, replay with network disabled.
5API testing and APIRequestContext
Playwright ships a real HTTP client. Use it to seed state via API and then drive the UI - a hybrid that runs 5-10x faster than pure E2E and is far less flaky.
graph LR
A[APIRequestContext]:::api --> B[seed - createOrder]:::seed
B --> C[orderId returned]:::data
C --> D[UI - page.goto orders/id]:::ui
D --> E[expect heading visible]:::ok
classDef api fill:#fef3c7,stroke:#b45309,color:#7c2d12;
classDef seed fill:#f5f3ff,stroke:#7c3aed,color:#4c1d95;
classDef data fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a;
classDef ui fill:#dcfce7,stroke:#15803d,color:#14532d;
classDef ok fill:#dcfce7,stroke:#15803d,color:#14532d;
API+UI hybrid - seed via API, verify via UI. 5-10x faster than pure E2E.
The request fixture
Inject request into any spec. It's an APIRequestContext that respects the project's baseURL and extraHTTPHeaders. Build a small ApiClient wrapper so specs don't repeat the same URL strings.
seed via API, verify via UI
test('order shows up in history', async ({ api, page }) => {
const id = await api.createOrder({ items: ['tta-bike-light'] });
await page.goto(`/playwright/ttacart/orders/${id}`);
awaitexpect(page.getByRole('heading', { name: `Order ${id}` })).toBeVisible();
});
Pure API specs
Reuse the same request fixture for a contract test - expect(await request.get('/health')).toBeOK(). Keep API specs in their own folder and a separate project so they can run on every PR while UI suites run on merge.
Build an ApiClient with login(), createCart(), addItem(id), checkout(addr). Wrap them in a fixture.
Write a hybrid spec that creates a cart via API and asserts the cart page UI shows the correct line items.
Write a pure API spec for the booking endpoint. Cover happy path + four edge cases. No browser involved.
Add a project for API-only tests and confirm it runs in 5 seconds while the UI project takes longer.
Ex 1 - ApiClient surface, wrapped in a fixture.Ex 2 - cart seeded via API, asserted via UI.Ex 3 - pure API spec covers happy + 4 edge cases.Ex 4 - api project in 5s, ui project on merge.
6Authentication and storageState
Log in once. Reuse the session in every spec. Saves 1-3 seconds per test, removes the most common flaky step from your suite.
graph LR
A[setup project]:::s --> B[login once]:::login
B --> C[storageState saved to user.json]:::save
D[spec project]:::spec -. dependencies .-> A
C --> E[spec 1 - pre-auth]:::ok
C --> F[spec 2 - pre-auth]:::ok
C --> G[spec N - pre-auth]:::ok
classDef s fill:#fef3c7,stroke:#b45309,color:#7c2d12;
classDef login fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a;
classDef save fill:#f5f3ff,stroke:#7c3aed,color:#4c1d95;
classDef spec fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a;
classDef ok fill:#dcfce7,stroke:#15803d,color:#14532d;
Auth pattern - login once, write JSON, every spec resumes that session.
The pattern
Create a setup project that runs a single spec - auth.setup.ts - which logs in and writes playwright/.auth/user.json.
Other projects declare dependencies: ['setup'] and set use.storageState: 'playwright/.auth/user.json'.
Every spec in those projects starts already authenticated. No login step in the body.
Save one storage state file per role - admin.json, customer.json, guest.json. In specs that need a specific role, override storageState via test.use({ storageState: 'playwright/.auth/admin.json' }) in a describe block.
Add a setup project that logs into TTACart and writes user.json. Wire one other project to consume it.
Add a second role - admin - with its own storage state file. Override storageState in a single describe block.
Measure the suite duration before and after storage-state auth on a 30-spec suite. Document the savings.
Add a guard - run setup only when the file doesn't exist or is older than 30 minutes.
Ex 1 - setup writes user.json, ttacart project consumes it.Ex 2 - two roles, overridden inside one describe.Ex 3 - 30 specs, ~1m 30s saved.Ex 4 - run setup only when missing or stale.
7Visual regression
Snapshot the rendered DOM. Compare on every run. Catch the unintended pixel before your PM does.
graph LR
A[baseline.png]:::base --> D[diff engine]:::eng
B[current.png]:::cur --> D
D --> E{pixels diff < threshold?}
E -- yes --> OK[PASS]:::ok
E -- no --> F[FAIL with diff.png]:::bad
classDef base fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a;
classDef cur fill:#fef3c7,stroke:#b45309,color:#7c2d12;
classDef eng fill:#f5f3ff,stroke:#7c3aed,color:#4c1d95;
classDef ok fill:#dcfce7,stroke:#15803d,color:#14532d;
classDef bad fill:#fee2e2,stroke:#b91c1c,color:#7f1d1d;
Visual regression - baseline vs current, diff measured against threshold.
How the comparison works
The first run creates a baseline PNG next to your spec. Every subsequent run compares the new screenshot against the baseline pixel-by-pixel, with a configurable threshold tolerance. Update intentionally with npx playwright test --update-snapshots.
Stability tips
Mask dynamic regions.{ mask: [page.getByTestId('timestamp')] } blacks out fields that change every run.
Disable animations. Pass animations: 'disabled' so transitions don't catch you mid-frame.
Pin fonts. Use a web font load wait or self-host fonts; cross-OS font hinting is a common false-positive source.
One project, one baseline. Chromium and WebKit render differently. Either run visuals on one project or accept per-project baselines.
Take a full-page screenshot of /ttacart/checkout-complete.html. Commit the baseline. Re-run and confirm zero diff.
Mask the "order number" element on the same page so subsequent runs don't fail on the rotating value.
Set maxDiffPixels: 200 for a tolerant comparison on one spec and assert it tolerates a small intentional change.
Capture a per-locator snapshot for just the header. When the header gets a logo swap, update only that single PNG.
Ex 1 - baseline + re-run = 0 px diff.Ex 2 - masked order number = stable baseline.Ex 3 - 137 px diff < 200 = pass.Ex 4 - per-locator snapshot scopes the diff.
8Trace, debug, and time travel
When a test fails, you should not have to guess. The trace is a recorded session - DOM snapshots, console, network, screenshots, action log - all replayable in a viewer.
graph TB
T[trace.zip]:::root --> A[timeline + actions]:::a
T --> B[DOM snapshots]:::b
T --> C[network log]:::c
T --> D[console]:::d
T --> E[source map - file:line]:::e
classDef root fill:#fef3c7,stroke:#b45309,color:#7c2d12;
classDef a fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a;
classDef b fill:#dcfce7,stroke:#15803d,color:#14532d;
classDef c fill:#f5f3ff,stroke:#7c3aed,color:#4c1d95;
classDef d fill:#fee2e2,stroke:#b91c1c,color:#7f1d1d;
classDef e fill:#fef3c7,stroke:#b45309,color:#7c2d12;
Set use: { trace: 'on-first-retry' } in the config - free during green runs, full recording on the retry. Open the resulting trace.zip with npx playwright show-trace trace.zip.
What's inside
Action log - every click, fill, expect with timing and result.
DOM snapshots at the moment before and after each action.
Screenshots, console, network - everything an engineer would otherwise have to reproduce by hand.
Source map - click an action and the viewer highlights the line in your spec.
Local debugging modes
npx playwright test --debug launches the Inspector and pauses before the first action. page.pause() drops a manual breakpoint. --ui mode is the time-travel watcher you want during authoring - file changes auto re-run.
Set trace: 'retain-on-failure', force a failure, and walk through the trace viewer until you can pinpoint the failing line in the spec.
Drop a page.pause() mid-test, run with --headed, and use the Inspector to step manually past the breakpoint.
Use test.info().attach('debug-payload', { body: JSON.stringify(...) }) to attach a custom artifact to a failing test.
Open three traces side by side (failing run, retry, last green) and identify the diff in the action log.
Ex 1 - viewer pinpoints the failing line.Ex 2 - inspector pauses, accepts manual steps.Ex 3 - custom artifact attached to the failing test.Ex 4 - 3 traces side by side reveal the diff.
9CI sharding and reports
Run 60 minutes worth of tests in 15. Split the run across N parallel CI jobs, then merge the blob reports into a single readable HTML at the end.
graph LR
M[matrix shard 1..4]:::m --> S1[shard 1/4]:::sh
M --> S2[shard 2/4]:::sh
M --> S3[shard 3/4]:::sh
M --> S4[shard 4/4]:::sh
S1 --> B[blob reports]:::blob
S2 --> B
S3 --> B
S4 --> B
B --> R[merge-reports -> html]:::merge
R --> P[deploy-pages artifact]:::ok
classDef m fill:#fef3c7,stroke:#b45309,color:#7c2d12;
classDef sh fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a;
classDef blob fill:#f5f3ff,stroke:#7c3aed,color:#4c1d95;
classDef merge fill:#fef3c7,stroke:#b45309,color:#7c2d12;
classDef ok fill:#dcfce7,stroke:#15803d,color:#14532d;
CI sharding - 4 parallel jobs, blob reports merge into one HTML.
Shards 101
Sharding splits the file list across N runners. Each shard runs --shard=1/4, --shard=2/4, etc. Inside a shard, --workers=4 gives you per-machine parallelism. The product (N shards x M workers) sets your overall concurrency.
A final job downloads all blob artifacts and runs npx playwright merge-reports --reporter=html ./blob-reports. You get one unified HTML report across the matrix, ready to upload as a workflow artifact.
Reporter choices
html - the default. Self-contained, browsable, good for engineers.
json - for machine ingestion, dashboards, Slack bots.
blob - the intermediate format for sharded runs.
allure-playwright - rich trend graphs, categories, suites. Plug it in as an extra reporter.
Custom - implement Reporter for a branded TTA report. The V1 framework page shows the full pattern.
Split a 40-spec suite across --shard=1/4 ... --shard=4/4 and measure the wall-clock improvement.
Wire up a GitHub Actions workflow with a 4-shard matrix. Upload blob reports per shard.
Add a merge job that downloads the four blobs and produces a single HTML report artifact.
Add allure-playwright as a second reporter and inspect the trend graph after three runs.
Ex 1 - 40 specs across 4 shards = ~3.5x faster.Ex 2 - matrix uploads four blob artifacts.Ex 3 - merge-reports collapses 4 blobs into 1 HTML.Ex 4 - Allure trend graph after a few runs.
From here -> Once these patterns feel natural, you're ready for an opinionated framework. Continue to advanced frameworks for V1 (TypeScript, custom reporter, sharded CI) and V2 (the same framework with AI-assisted authoring and failure analysis).