Practice Learn Playwright advanced
Draft
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.

9Modules
60+Concepts
36Exercises
Frameworks ->Next: V1 / V2

1Page Object Model

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', ...).

class constructor(page) readonly locator async open() action methods return next page BasePage getByRole expect 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.
pages/LoginPage.ts (TTACart)
import { Page, Locator, expect } from '@playwright/test';
import { InventoryPage } from './InventoryPage';

export class LoginPage {
  readonly user: Locator;
  readonly pwd:  Locator;
  readonly btn:  Locator;

  constructor(private readonly page: Page) {
    this.user = page.getByLabel('Username');
    this.pwd  = page.getByLabel('Password');
    this.btn  = page.getByRole('button', { name: 'Login' });
  }

  async open() { await this.page.goto('/playwright/ttacart/index.html'); }

  async loginAs(u: string, p: string): Promise<InventoryPage> {
    await this.user.fill(u);
    await this.pwd.fill(p);
    await this.btn.click();
    return new InventoryPage(this.page);
  }
}

BasePage for shared logic

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.

Exercises

  1. Write LoginPage and InventoryPage for the TTACart demo. loginAs returns InventoryPage. Specs should never touch page.fill directly.
  2. Add a BasePage exposing the cart badge and "logout" link. Extend three concrete pages from it.
  3. Refactor a flat spec (200 lines, lots of selectors) into a POM-driven version (less than 30 lines per test).
  4. Add an inline expectation inside LoginPage.loginAs that asserts no error banner shows up. Decide where that assertion best lives.
spec.ts LoginPage loginAs() Inventory Page spec never calls page.fill
Ex 1 - spec talks to POM only.
BasePage LoginPage CartPage CheckoutPage
Ex 2 - BasePage holds cart badge + logout; three children extend.
flat - 200 lines selectors everywhere copy-pasted waits POM - 25 lines login.loginAs(u, p) cart.addItem(id)
Ex 3 - 200 lines -> ~25 lines per test.
async loginAs(u, p) { await this.user.fill(u); await this.btn.click(); await expect(this.errorBanner).toBeHidden(); }
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.extend use() fixture options scope: 'worker' scope: 'test' override fixture timeout per fixture auto 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.

fixtures/test-base.ts
import { test as base } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { CartPage } from '../pages/CartPage';
import { ApiClient } from '../api/ApiClient';

type Fix = {
  loginPage: LoginPage;
  cartPage: CartPage;
  api: ApiClient;
};

export const test = base.extend<Fix>({
  loginPage: async ({ page }, use) => { await use(new LoginPage(page)); },
  cartPage:  async ({ page }, use) => { await use(new CartPage(page)); },
  api:       async ({ request }, use) => { await use(new ApiClient(request)); },
});
export { expect } from '@playwright/test';

Option fixtures

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.

Exercises

  1. Move all six TTACart page objects into a single fixture file. Update three specs to consume them via destructuring.
  2. Add an option fixture defaultUser. Project A uses standard_user, project B uses problem_user.
  3. Build a worker-scoped fixture that creates an API auth token once and shares it across all tests in the worker.
  4. Override the built-in page fixture so it always navigates to the booking landing page before the spec body runs.
test-base 6 fixtures spec A: ({ loginPage, cartPage }) spec B: ({ inventoryPage }) spec C: ({ checkoutPage, api })
Ex 1 - six POMs, three specs destructure what they need.
project A defaultUser: standard_user project B defaultUser: problem_user
Ex 2 - same spec runs under two user identities via projects.
worker scope - api token (1x) test 1 shares token test 2 test 3
Ex 3 - worker-scoped fixture creates the token once, reuses it.
page (override) goto /booking spec body already on booking landing no goto needed in test
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...of test.each pattern JSON.parse csv-parse/sync xlsx @faker-js/faker describe per row test 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.

inline DDT
const users = [
  { name: 'standard_user',   ok: true  },
  { name: 'locked_out_user', ok: false },
  { name: 'problem_user',    ok: true  },
];

for (const u of users) {
  test(`login > ${u.name}`, async ({ loginPage }) => {
    await loginPage.open();
    await loginPage.loginAs(u.name, 'tta_secret');
    u.ok
      ? await expect(page).toHaveURL(/inventory/)
      : await expect(page.getByText('locked out')).toBeVisible();
  });
}

JSON, CSV, XLSX

  • JSON for stable structured data - users, products, expected totals. Read with JSON.parse(fs.readFileSync(...)) at module load.
  • CSV for tabular data your business team owns. Use csv-parse/sync for synchronous, typed rows.
  • XLSX for multi-sheet workbooks - one sheet per scenario family. Use xlsx (SheetJS) and pick a sheet by name.
  • Faker for randomised data - emails, names, addresses, card numbers. Combine with a stable seed for reproducibility.

Exercises

  1. Drive the QA Profile form on /tables/practice.html from a 5-row inline array. Each row submits and asserts the resulting summary.
  2. Read users.json with 6 users and run the login spec for every entry. Tag valid users @happy and invalid users @negative.
  3. Read products.csv on the data-driven widget. Loop over each row and assert the price column rounds to two decimals.
  4. 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.
5 inline rows city: Pune ... BOM 5 submissions 5 summary asserts
Ex 1 - inline 5-row array drives 5 form submissions.
users.json 6 users @happy x4 @negative x2
Ex 2 - JSON users split into @happy + @negative tags.
products.csv id, name, price .toFixed(2) 9.999 -> 10.00
Ex 3 - CSV-driven price-rounding assertion.
faker.seed(42) deterministic local -> Aarav, 411001 CI -> Aarav, 411001
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.route route.fulfill route.abort route.continue route.fetch page.waitForResponse page.waitForRequest context.route HAR 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;
                
Network intercept - fulfill mocks, abort blocks, continue lets the request pass.

Intercept and respond

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.

mock a flaky endpoint
await page.route('**/api/payment', async route => {
  await route.fulfill({
    status: 200,
    contentType: 'application/json',
    body: JSON.stringify({ ok: true, txn: 'TTA-9001' }),
  });
});
await page.goto('/playwright/booking/payment.html');

Wait for responses

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.

Exercises

  1. On /network/intercept.html mock the "products" endpoint to return three custom items. Assert the rendered cards match your mock.
  2. Abort any request to **/analytics and confirm the page still renders correctly without analytics.
  3. Fulfill the booking payment with a 500 status and assert the UI shows a retry banner.
  4. Use waitForResponse to capture the JSON body and assert one field with expect.poll.
  5. Record a HAR of the booking flow, then replay it offline to confirm the test passes with the network disabled.
page.route /api/products fulfill 3 items card 1 card 2 card 3 3 cards match mock
Ex 1 - 3 mocked products render as 3 cards.
page renders products visible **/analytics aborted PASS UI still works
Ex 2 - analytics blocked, UI renders fine.
payment 500 retry banner shown expect(banner).toBeVisible()
Ex 3 - fulfilled 500 surfaces the retry banner.
click submit waitForResponse .json() captured PASS field ok
Ex 4 - capture JSON body, expect.poll on one field.
record -> flight.har network on replay offline test PASS
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.

request fixture request.newContext request.get / post request.put / patch extraHTTPHeaders baseURL response.json() response.status() expect(response).toBeOK
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}`);
  await expect(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.

Exercises

  1. Build an ApiClient with login(), createCart(), addItem(id), checkout(addr). Wrap them in a fixture.
  2. Write a hybrid spec that creates a cart via API and asserts the cart page UI shows the correct line items.
  3. Write a pure API spec for the booking endpoint. Cover happy path + four edge cases. No browser involved.
  4. Add a project for API-only tests and confirm it runs in 5 seconds while the UI project takes longer.
ApiClient login(user, pwd) createCart() addItem(id) checkout(addr) wrapped in a fixture
Ex 1 - ApiClient surface, wrapped in a fixture.
API createCart() addItem(123) cartId returned UI page.goto(cart/123) expect line item visible PASS in 1.2s
Ex 2 - cart seeded via API, asserted via UI.
happy 200 missing date 400 past date 422 unauth 401 conflict 409 5 specs - no browser
Ex 3 - pure API spec covers happy + 4 edge cases.
api project 5s runs on every PR ui project 3m 40s runs on merge
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.

context.storageState page.context().storageState use.storageState globalSetup project dependencies setup project multiple users role-based contexts
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

  1. Create a setup project that runs a single spec - auth.setup.ts - which logs in and writes playwright/.auth/user.json.
  2. Other projects declare dependencies: ['setup'] and set use.storageState: 'playwright/.auth/user.json'.
  3. Every spec in those projects starts already authenticated. No login step in the body.
auth.setup.ts
import { test as setup } from '@playwright/test';
const file = 'playwright/.auth/user.json';

setup('authenticate as standard_user', async ({ page }) => {
  await page.goto('/playwright/ttacart/index.html');
  await page.getByLabel('Username').fill('standard_user');
  await page.getByLabel('Password').fill('tta_secret');
  await page.getByRole('button', { name: 'Login' }).click();
  await page.context().storageState({ path: file });
});

Multiple users

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.

Exercises

  1. Add a setup project that logs into TTACart and writes user.json. Wire one other project to consume it.
  2. Add a second role - admin - with its own storage state file. Override storageState in a single describe block.
  3. Measure the suite duration before and after storage-state auth on a 30-spec suite. Document the savings.
  4. Add a guard - run setup only when the file doesn't exist or is older than 30 minutes.
auth.setup.ts login + write .auth/user.json ttacart project deps: ['setup'] storageState: user.json
Ex 1 - setup writes user.json, ttacart project consumes it.
customer.json most specs storageState default admin.json describe('admin', ...) test.use overrides
Ex 2 - two roles, overridden inside one describe.
before 3m 30s login in every spec after 2m 00s -43%
Ex 3 - 30 specs, ~1m 30s saved.
missing or >30m run setup fresh reuse session
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.

expect(page).toHaveScreenshot expect(locator).toHaveScreenshot mask threshold maxDiffPixels animations: 'disabled' --update-snapshots snapshotPathTemplate
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.

Exercises

  1. Take a full-page screenshot of /ttacart/checkout-complete.html. Commit the baseline. Re-run and confirm zero diff.
  2. Mask the "order number" element on the same page so subsequent runs don't fail on the rotating value.
  3. Set maxDiffPixels: 200 for a tolerant comparison on one spec and assert it tolerates a small intentional change.
  4. Capture a per-locator snapshot for just the header. When the header gets a logo swap, update only that single PNG.
baseline.png commit run 1 current.png run 2 0 px diff PASS
Ex 1 - baseline + re-run = 0 px diff.
Order Confirmation Order: MASKED Total: $99.97 no flake from order id
Ex 2 - masked order number = stable baseline.
diff = 137 px maxDiffPixels: 200 137 < 200 PASS tolerant
Ex 3 - 137 px diff < 200 = pass.
header.toHaveScreenshot() only header.png updated
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.

use.trace on-first-retry retain-on-failure npx playwright show-trace page.pause --debug --ui PWDEBUG=1 test.info().attach
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;
                
Trace viewer panels - timeline, DOM, network, console, source.

Turn on the trace

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.

Exercises

  1. Set trace: 'retain-on-failure', force a failure, and walk through the trace viewer until you can pinpoint the failing line in the spec.
  2. Drop a page.pause() mid-test, run with --headed, and use the Inspector to step manually past the breakpoint.
  3. Use test.info().attach('debug-payload', { body: JSON.stringify(...) }) to attach a custom artifact to a failing test.
  4. Open three traces side by side (failing run, retry, last green) and identify the diff in the action log.
click Login PASS fill Username PASS expect URL FAIL line 24
Ex 1 - viewer pinpoints the failing line.
page.pause() --headed step / resume / record pick locator on hover
Ex 2 - inspector pauses, accepts manual steps.
testInfo.attach debug-payload.json in report
Ex 3 - custom artifact attached to the failing test.
failing run click on stale btn retry 100ms slower last green smooth path
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.

--shard 1/4 --workers --reporter=blob npx playwright merge-reports html reporter json reporter allure-playwright GitHub Actions matrix artifact upload
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.

.github/workflows/playwright.yml
strategy:
  fail-fast: false
  matrix:
    shard: [1, 2, 3, 4]
steps:
  - run: npx playwright test --shard=${{ matrix.shard }}/4 --reporter=blob
  - uses: actions/upload-artifact@v4
    with:
      name: blob-report-${{ matrix.shard }}
      path: blob-report

Merge the reports

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.

Exercises

  1. Split a 40-spec suite across --shard=1/4 ... --shard=4/4 and measure the wall-clock improvement.
  2. Wire up a GitHub Actions workflow with a 4-shard matrix. Upload blob reports per shard.
  3. Add a merge job that downloads the four blobs and produces a single HTML report artifact.
  4. Add allure-playwright as a second reporter and inspect the trend graph after three runs.
1 runner 12 min sequential 4 shards 3.5 min -70%
Ex 1 - 40 specs across 4 shards = ~3.5x faster.
strategy.matrix.shard: [1,2,3,4] shard 1 shard 2 shard 3 shard 4 upload-artifact blob-report-${matrix.shard} 4 blob artifacts in workflow
Ex 2 - matrix uploads four blob artifacts.
blob 1 blob 2 blob 3 blob 4 html unified
Ex 3 - merge-reports collapses 4 blobs into 1 HTML.
allure trend run 1 run 2 run 3 run 4
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).