The GienTech Wealth Automation Runbook
How to build, run and maintain a test suite for a bank and an insurer that share one login — and why every decision in it was made that way.
Orientation
What this book is, and who it is for
This is the manual for the GienTech Wealth automation suite: how it is built, why it is built that way, and what to do when it fails at three in the morning. It assumes you can write TypeScript and have seen a browser automation tool before. It does not assume you have used Playwright.
Three principles run through every chapter. Most of the specific advice later is just these three applied to a situation.
1 · A test is documentation that fails.
Its title states a requirement in the language of the business; its body reads as the steps a person would take. If a stakeholder cannot understand why a failing test matters, the test is written wrong — even when it is technically correct.
2 · Knowledge belongs in exactly one place.
How to click a button lives in a page object. What a rule says lives in business-rules.ts. What the seed data is lives in test-data.ts. A fact repeated in twelve specs is eleven future merge conflicts and one wrong assertion nobody noticed.
3 · Flake is a defect in the test, not weather.
Never “just add a wait”. Every wait in this suite is a wait for a state that must be true; there are no sleeps, and the linter fails the build if one appears.
What “industry standard” means here, concretely
| Practice | Where it lives |
|---|---|
| Page Object Model with component objects | src/pages/ |
| Fixture-based dependency injection | src/fixtures/test-fixtures.ts |
| Centralised test data and builders | src/data/ |
| Business rules declared once, asserted everywhere | src/data/business-rules.ts |
| Environment configuration in one module | src/config/environment.ts |
| Strict TypeScript with path aliases | tsconfig.json |
| Lint rules that ban known test smells | eslint.config.js |
| Cross-browser and mobile projects, tagged subsets | playwright.config.ts |
| API testing beside UI testing | mock-api/, tests/api/ |
| Sharded CI with merged reports | .github/workflows/e2e.yml |
The application under test
GienTech Wealth is a bank and an insurer in one Angular 18 application. It matters that it is not a toy: fourteen screens, Material components (selects rendered in overlays, datepickers, an autocomplete, an expansion panel, a file upload), dependent dropdowns, a three-step money-movement wizard, and — the part that makes it worth automating — real business rules with real failure paths.
Three properties that shaped the entire suite
It ships as one self-contained file. 1.3 MB with every script, style and font inlined, using hash routing (#/dashboard). So: any static server will do, every screen has a stable deep link, and the suite can assert the application makes no third-party network requests at all.
Its state lives in browser memory and resets on reload. This is a gift and a trap.
Gift Every test starts from an identical, pristine dataset. No cleanup, no ordering rules, no shared-state collisions — which is why fullyParallel: true is safe.
Trap A page.goto() in the middle of a test wipes everything the test has done, including the sign-in. Page objects therefore navigate by clicking, and use goto() only as an entry point.
It has no back end. So the suite brings one: mock-api/ reproduces the same dataset and the same rules over REST — see Chapter 10.
From clone to green in five minutes
# exact dependency versions from the lockfile
npm ci
# browser engines — a few hundred MB, once
npx playwright install
npm test # everything: API + chromium + firefox + webkit + mobile
There is no server to start and no .env to write. playwright.config.ts declares two webServer entries; Playwright starts the static server and the mock API, waits for both to answer, runs the suite, and shuts them down.
Your first useful loop is not the full matrix. It is one browser and one file:
npx playwright test --project=chromium tests/e2e/banking/transfer.spec.ts
npx playwright test --project=chromium --headed --grep "@smoke"
npx playwright test --project=api # ~1 second, no browser at all
Architecture
How Playwright actually works
You can write tests for months without knowing what happens beneath await page.click(). You cannot debug them without it. Almost every confusing failure in this book — a stale element, a test that passes alone and fails in parallel, a trace that shows the click landing somewhere unexpected — makes immediate sense once the machinery is visible.
One process talking to a browser over a socket
Your test file runs in Node. The browser runs as a separate process. Between them sits the Playwright driver, speaking each engine's own remote-control protocol — the DevTools protocol for Chromium, patched protocols for Firefox and WebKit. Every command you write is a message; every message returns a result.
await you forgot never fails loudly: the message is simply sent later, or never.Three consequences follow directly, and each one shows up in this suite:
- Everything is asynchronous, including the things that look synchronous.
expect(locator).toHaveText()returns a promise. Forget theawaitand the assertion is never waited on, so the test passes — which is exactly whyno-floating-promisesis an error ineslint.config.jsand not a warning. - A
Locatoris a description, not an element. It holds a selector and a scope; it touches the DOM only at the moment you act or assert. That is why re-rendering does not invalidate it and why this suite never stores element handles. - The browser can talk back. Console messages, page errors, requests, dialogs and downloads all arrive as events — which is what the
pageErrorsfixture and the network specs subscribe to.
Browser, context, page — the isolation model
The single most useful idea in Playwright is the browser context: a complete, isolated browser session — its own cookies, its own storage, its own permissions — created in milliseconds because it shares the already-running browser process. It is an incognito window without the cost of launching a browser.
The default page fixture is a page inside a context created for that one test and thrown away afterwards. You never write that code, which is precisely why it is worth knowing: test isolation is not something this suite implements, it is something the runner guarantees. What the suite adds on top is data isolation — see the per-session datasets in Chapter 18.
Workers, and what “parallel” means here
The runner starts several worker processes. Each worker owns one browser instance and runs test files one after another; different workers run different files at the same time. Inside a file, fullyParallel: true lets individual tests spread across workers too.
| Scope | Created | Destroyed | Cost |
|---|---|---|---|
| Worker process | Once per worker | At the end of the run, or on an unhandled crash | High — a browser launch |
| Browser context | Per test (by default) | After the test, always | Low — milliseconds |
| Page | Per test, or on demand | With its context | Low |
A worker restarts when a test fails in a way that could have poisoned it
If a test times out or crashes the browser, the runner discards that worker and starts a new one rather than risk running the next test in a corrupted session. That is why a single hard failure can make the run look slower than the failure alone would explain — and why a test that leaks a modal dialog does not silently break the next twenty.
Auto-waiting, actionability and the retry loop
Every action runs an actionability check first and retries until it passes or the timeout expires. For a click, the element must be attached to the DOM, visible, stable (not animating), able to receive events (nothing painted on top of it) and enabled.
<nav> subtree intercepts pointer events and found the portrait-phone defect in Chapter 16.Web-first assertions run the same kind of loop: they poll until the expectation holds or expect.timeout expires. This is the mechanism that makes explicit waits unnecessary — and it is why force: true is banned in this repository. Forcing a click skips the actionability check, which means it skips the only part of the system that was telling you the truth.
What the runner does with your test file
Never do real work at the top level of a spec file
A network call, a file read or a Date.now() baked into a module-level constant runs during collection — in every worker, at an unpredictable moment, with no fixtures available and no test to attribute a failure to. Put it in a fixture. This is why the date helpers in this suite are functions (recentPastDate()) rather than exported constants.
The anatomy of the project
src/
├── api/ gientech-wealth-api.client.ts ← service object: one method per endpoint
├── config/ environment.ts, paths.ts ← the ONLY place process.env is read
├── data/ test-data.ts ← the seeded dataset, named by purpose
│ business-rules.ts ← every rule and message, declared once
│ builders/ ← fluent builders for form input
├── fixtures/ test-fixtures.ts ← the custom `test` object (UI)
│ api-fixtures.ts ← the custom `test` object (API)
├── pages/ *.page.ts ← one page object per screen
│ components/ ← toolbar, Material widget driver
└── utils/ date.utils.ts, money.utils.ts
tests/
├── setup/ authentication.setup.ts ← runs first; fails fast; saves state
├── e2e/ authentication | banking | insurance | platform | journeys
└── api/ *.api.spec.ts
Why path aliases
// Without: the reader counts dots, and a moved file breaks fifty imports
import { SignInPage } from '../../../src/pages/sign-in.page';
// With: declared once in tsconfig.json, understood natively by Playwright
import { SignInPage } from '@pages/sign-in.page';
Naming, and why it is not bikeshedding
A name is the only documentation read every single time. The convention: the file name says what kind of thing it is; the symbol name says which thing it is.
| Kind | Convention | Example |
|---|---|---|
| Page object file | <screen>.page.ts | policy-detail.page.ts |
| Component object | <name>.component.ts | app-toolbar.component.ts |
| Builder | <entity>.builder.ts | claim.builder.ts |
| API spec | <area>.api.spec.ts | insurance.api.spec.ts |
| Class | PascalCase, named for the screen | AccountDetailPage |
| Action method | verb phrase | payPremium() |
| Value reader | read… | readReceiptNumber() |
| Element accessor | noun, returns Locator | get errorMessage() |
| Fixture | noun — what it is | signedIn |
The Page Object Model
A page object knows how to interact with a screen; a spec decides what is correct.
Once assertions leak into page objects, two things happen: the spec stops telling you the requirement, and the page object can no longer be reused by a test whose expectations differ.
src/pages/ keeps.The base class
export abstract class BasePage {
protected constructor(
protected readonly page: Page,
private readonly screenTestId: string, // 'premium-screen'
private readonly route: string, // '/premium'
) { this.material = new MaterialControls(page); }
get root(): Locator { return this.page.getByTestId(this.screenTestId); }
protected byTestId(testId: string): Locator {
return this.root.getByTestId(testId); // scoped: never matches another screen
}
async expectLoaded(): Promise<void> {
await expect(this.root).toBeVisible(); // the ONE assertion a page object owns
}
}
Scoping to root is not decoration. Angular keeps a previous screen in the DOM for the length of an animation; a page-wide getByTestId('claim-error') can resolve against the outgoing screen and assert on a message that is on its way out.
A page object, annotated
export class PremiumPage extends BasePage {
readonly toolbar: AppToolbar; // composition, not inheritance
// Accessors are getters returning Locator — lazy, auto-retrying, never stale.
get outstandingAmount(): Locator { return this.byTestId('premium-outstanding'); }
get errorMessage(): Locator { return this.byTestId('premium-error'); }
// Actions are verbs. Material widgets go through the component driver.
async selectPolicy(policyId: string) {
await this.material.selectOption('premium-policy', `premium-policy-${policyId}`);
}
}
Why payPremium() is not the only method
Four of this screen's rules are refusals. A test for “a dormant account is refused” must fill the form, submit, and read an error — it must not be forced through a method that also asserts success. Composed helpers sit on top of granular steps, never replace them.
Anti-patterns, and the failure each one causes
| Anti-pattern | What it costs |
|---|---|
| Business assertions inside a page object | The spec no longer states the requirement; negative tests cannot reuse the object |
row(2) instead of row('sav') | Passes against the wrong account the day a row is added |
page.goto() mid-test on this app | Wipes the store and the session; the result is unrelated to the title |
| Returning a boolean the spec then branches on | A test that silently tests nothing when the branch flips |
Component objects, and the widgets somebody else wrote
A page object per screen is only half the model. The other half is the fragment that appears on many screens — a toolbar, a data table, a date picker — and the widget whose internals belong to a component library you do not control. Both get a component object: a class that takes a Page or a parent Locator and owns the knowledge of how that fragment behaves.
| Kind | Owns | Example here |
|---|---|---|
| Page object | One screen, addressed by its route and root test id | PremiumPage |
| Component object (fragment) | A region that appears on many screens | AppToolbar |
| Component object (widget driver) | The mechanics of a third-party control | MaterialControls |
The fragment: one toolbar, fourteen screens
The toolbar is not a screen, so it is not a page object. It is held by every page object through composition, which is what lets a test navigate from wherever it happens to be:
export class AppToolbar {
constructor(private readonly page: Page) {}
get root(): Locator { return this.page.getByTestId('app-toolbar'); }
async goToClaims(): Promise<void> { await this.page.getByTestId('nav-claims').click(); }
/** Material hides the badge at zero, so "no badge" and "badge showing 0" are different DOM
states. Returning 0 for the hidden case gives specs ONE number to assert on. */
async readNotificationCount(): Promise<number> {
const badge = this.root.locator('.mat-badge-content');
if ((await badge.count()) === 0 || !(await badge.first().isVisible())) return 0;
return Number((await badge.first().innerText()).trim() || 0);
}
}
Normalise the widget's oddities at the component boundary
That readNotificationCount() could have returned number | null and pushed the “is there even a badge?” question into every spec. One conversion here removes a conditional from every test that would otherwise have to ask — and a conditional in a test is a branch nobody can see in the report.
The widget driver: when the DOM is not yours
A <mat-select> is not a <select>. Clicking it opens a CDK overlay appended to the end of <body>, and its options exist only while that overlay is open.
MaterialControls.async selectOption(selectTestId: string, optionTestId: string) {
const trigger = this.byTestId(selectTestId);
await expect(trigger).toBeVisible();
await trigger.click();
// Inside the overlay — a page-wide lookup can find a DETACHED option from a closed panel.
const option = this.page.locator('.cdk-overlay-container').getByTestId(optionTestId);
await expect(option).toBeVisible();
await option.click();
await expect(this.page.locator('.cdk-overlay-container .mat-mdc-select-panel')).toBeHidden();
}
The same file handles the two shapes of a checkable control — <mat-checkbox> wraps a real input, while <mat-slide-toggle> renders a <button role="switch"> — and makes toggling idempotent by reading before clicking, because a blind click toggles.
What a widget driver should and should not absorb
| Absorb into the driver | Leave in the spec |
|---|---|
| Opening an overlay and waiting for it to close | Which option is the right one |
Finding options inside .cdk-overlay-container | Whether the option list is correct |
| Reading a checkbox's state before clicking it | Whether the box should end up ticked |
| Typing a date in the browser's locale format | Which date the business rule requires |
Knowing that a slide toggle is a button[role=switch] | Whether alerts should default to on |
The line is the same one the Page Object Model draws, one level down: mechanics inside, meaning outside.
Two interaction styles, and why both are covered
A datepicker can be driven by typing into its text field or by opening its calendar and clicking a day. Typing is an order of magnitude faster and is what a keyboard user does, so it is the default:
async fillDate(inputTestId: string, date: Date): Promise<void> {
const field = this.byTestId(inputTestId);
await field.fill(toMaterialDateInput(date)); // M/D/YYYY under the pinned en-US locale
await field.blur(); // let Material parse the text into the model before anything submits
}
/** The popup gets its own method so the widget itself is not left untested. */
async pickDateFromCalendar(toggleTestId: string, dayOfMonth: number): Promise<void> {
await this.byTestId(toggleTestId).click();
const calendar = this.page.locator('.cdk-overlay-container mat-calendar');
await expect(calendar).toBeVisible();
await calendar.getByRole('button', { name: String(dayOfMonth), exact: true }).click();
await expect(calendar).toBeHidden();
}
Covering only the fast path would leave the calendar — a real control a real customer taps — with no coverage at all. Covering only the calendar would make every date-dependent test slow. One method each, and the specs choose.
Component objects for tables
A table is the other classic component object. This suite keeps table accessors on the page object because every table here has per-row test ids, but the moment a table gains sorting, pagination and selection it deserves its own class — with one rule carried over from Chapter 6: rows are addressed by identity, never by index.
// Identity, from the application's own test ids — survives sorting, filtering and new rows
row(accountId: string): Locator { return this.byTestId(`account-row-${accountId}`); }
// Identity, from a value the test only learns at runtime
rowByReference(reference: string): Locator {
return this.table.locator('tr', { hasText: reference });
}
Fixtures and the test lifecycle
A fixture is a named, lazily-constructed dependency with guaranteed teardown. Playwright builds only the fixtures a test actually names.
| beforeEach | Fixtures | |
|---|---|---|
| Cost | Runs for every test, needed or not | Built only when named |
| Visibility | Read the hook to learn dependencies | The signature says it |
| Teardown | A second hook, easy to forget | Code after await use(), runs on failure too |
| Composition | Manual ordering | Declared, resolved automatically |
Authentication, and an honest word about storageState
signedIn: async ({ page, signInPage, dashboardPage }, use) => {
await page.goto(appUrl);
// Ask the APPLICATION whether the restored state was enough.
const alreadySignedIn = await page.getByTestId('app-toolbar').isVisible();
if (!alreadySignedIn) await signInPage.signInAsDemoCustomer();
await dashboardPage.expectLoaded();
await use(dashboardPage);
},
GienTech Wealth keeps its session in Angular component memory and writes no cookie and no localStorage entry, so the saved state is legitimately near-empty and the UI sign-in happens. The mechanism is wired up anyway for two reasons: it is the pattern to copy on a real application, and the moment the product issues a real token the whole suite gets faster with no spec changes at all. Pretending the optimisation already worked would produce a suite that looks fast in a diagram and signs in twice in reality.
The diagnostics fixture
A screen can render perfectly and throw on every keystroke. Exposing errors as an ordinary fixture lets a spec make it a first-class requirement — expect(pageErrors).toEqual([]) — instead of burying it in an invisible global hook.
Scope, worker fixtures and options
Chapter 8 introduced fixtures as named dependencies. This chapter is the part people discover late and wish they had known first: fixtures have a scope, they can be automatic, they can be declared as tunable options, and they can override the runner's own built-ins.
Test scope and worker scope
| Test scope (default) | Worker scope | |
|---|---|---|
| Built | Once per test | Once per worker process |
| Torn down | After each test | When the worker exits |
| Good for | Page objects, per-test data, request contexts | A seeded database, a licence token, an expensive server |
| Danger | None — isolation is free | Anything mutable becomes shared state between tests |
export const test = base.extend<{ premiumPage: PremiumPage }, { seededLedger: Ledger }>({
// Test scope: rebuilt for every test, so nothing leaks.
premiumPage: async ({ page }, use) => { await use(new PremiumPage(page)); },
// Worker scope: built once per worker. The second type parameter of `extend` is what
// declares the worker-scoped shape; { scope: 'worker' } is what enforces it.
seededLedger: [async ({}, use) => {
const ledger = await Ledger.seed();
await use(ledger);
await ledger.drop();
}, { scope: 'worker' }],
});
Worker scope buys speed and sells isolation
Every test in that worker shares the object. If one test mutates it, the next test's result depends on the order the runner happened to choose — the single hardest class of flake to diagnose, because the failing test is not the guilty one. Reach for worker scope only for things that are expensive AND read-only, and say so in a comment.
This suite has no worker-scoped fixtures on purpose: the application resets its state on every page load, so a per-test context is already free, and the mock API hands each session its own dataset (Chapter 18). When state isolation is that cheap, sharing buys nothing.
Automatic fixtures
An auto fixture runs for every test whether or not the test names it. That is exactly what you want for cross-cutting instrumentation, and exactly what you do not want for anything a reader would need to know about.
failOnConsoleErrors: [async ({ page }, use) => {
const errors: string[] = [];
page.on('pageerror', (error) => errors.push(error.message));
await use();
// Teardown runs after the test body, so this reports the whole test's errors at once.
expect(errors, 'the page raised uncaught errors').toEqual([]);
}, { auto: true }],
This repository deliberately stops one step short of that: pageErrors is an ordinary fixture that collects, and the spec asserts. The reason is legibility — a test that fails on a console error should say so in its own body, not in a hook the reader has to go looking for. Use auto when the behaviour is genuinely invisible plumbing (starting a trace, attaching a video, tagging a report), not when it changes whether a test passes.
Option fixtures: configuration a spec can override
An option fixture is a fixture with a default that can be set from playwright.config.ts per project, or from test.use() per file, per describe block, or per test. It is how you build a suite that runs the same specs against different personas, tenants or feature flags.
// 1. Declare the option with a default.
export const test = base.extend<{ customer: 'amara' | 'locked-out' }>({
customer: ['amara', { option: true }],
});
// 2. Set it per project, in the config…
projects: [{ name: 'locked-out-customer', use: { customer: 'locked-out' } }],
// 3. …or per file, in the spec.
test.use({ customer: 'locked-out' });
The built-in use options — viewport, locale, timezoneId, storageState, colorScheme, baseURL, trace, video — work the same way, which is what the portrait-phone audit relies on: its project sets a device, and nothing in the spec has to know.
Overriding a built-in fixture
Any built-in can be replaced, most usefully page and context. The pattern is to take the original and wrap it:
export const test = base.extend({
page: async ({ page }, use) => {
// Every page in the suite starts with the same instrumentation attached.
page.setDefaultTimeout(environment.timeouts.assertion);
await page.route('**/*.{png,jpg,woff2}', (route) => route.abort()); // e.g. a speed run
await use(page);
},
});
Overriding page is powerful and easy to regret
Everything in the suite inherits the change, including tests written a year later by someone who has never read the fixture file. Keep overrides to behaviour that is genuinely universal, and name the file so it is the first place a confused engineer looks.
Fixture ordering, teardown and failures
await use(...) is the only place guaranteed to run when a test fails.Two practical rules follow. First, put diagnostics in teardown: attaching a server log or a screenshot after await use() means it exists precisely when the test failed. Second, a fixture that throws fails the test before its body runs, and the report says so — which is why the API suite's authenticatedApi fixture asserts the sign-in status with a message naming the likely cause, rather than letting sixty tests fail on a confusing 401.
Steps: structure inside a test
test.step() groups actions in the report and the trace. It costs one line and pays for itself the first time somebody reads a failure in a journey test.
await test.step('bring the lapsed policy current', async () => {
await premiumPage.selectPolicy(lapsed.id);
await premiumPage.enterAmount(amountOwed);
await premiumPage.submitPayment();
});
Steps are most valuable in the multi-screen journeys of Chapter 12 and least valuable in a three-line rule test, where the test title already says everything.
Test data, builders and business rules
Three files carry all the knowledge that would otherwise be scattered as literals.
test-data.ts — the seed, named by purpose
export const accounts = {
primarySavings: { id: 'sav', name: 'Everyday Savings', status: 'Active', currency: 'SGD', balance: 18420.55 },
businessCurrent: { id: 'cur', name: 'Business Current', status: 'Active', currency: 'SGD', balance: 42100.00 },
/** Proves the dormancy rule. */
dormantForeignCurrency: { id: 'usd', name: 'Multi-Currency USD', status: 'Dormant', currency: 'USD' },
/** Proves the SGD-only rule: active, but not SGD. */
activeForeignCurrency: { id: 'eur', name: 'Multi-Currency EUR', status: 'Active', currency: 'EUR' },
} as const satisfies Record<string, AccountFixture>;
satisfies does real work: the object is checked against the interface and the literal types survive, so accounts.primarySavings.id is 'sav', not string. Derived values are computed, never re-typed — change one balance and the dashboard assertions stay correct.
business-rules.ts — the sentences the product says
export const premiumRules = {
accountDormant: (accountName: string) =>
`${accountName} is dormant and cannot be used for payments. Reactivate it at a branch.`,
partialPayment: (outstanding: number) =>
`Partial payments are not accepted. SGD ${outstanding.toFixed(2)} is outstanding on this policy.`,
lapsedPolicyMultiplier: 2,
};
Functions, not strings, wherever a message interpolates a value — the requirement is that the message names the balance, and a prefix-only assertion would pass on a message naming the wrong number.
A real inconsistency, encoded on purpose
Balances in tables go through Angular's number:'1.2-2' pipe and carry a thousands separator (SGD 18,420.55). Balances inside error messages are built with toFixed(2) and do not (SGD 18420.55). The suite reproduces both forms exactly rather than smoothing the difference away — an assertion loose enough to accept either would also accept the wrong number.
Builders — one valid baseline, one deliberate defect
The claim form stacks five rules, evaluated in order. To prove rule four, the submission must be valid for rules one to three; otherwise the test passes for the wrong reason and keeps passing after rule four is deleted.
ClaimBuilder.valid() // the known-good baseline
.withAmount(3500).withoutDocuments() // ← breaks exactly one thing
.build();
ClaimBuilder.valid().withFutureIncidentDate().build(); // ← breaks a different one
Dates: the rule that saves the most maintenance
Never hard-code a date. '8/18/2026' is in the past today and in the future eventually, and the failure lands months later in an unrelated pull request.
A defect this suite pins rather than hides
The application parses a typed date into local midnight, then stores it with .toISOString().slice(0, 10), which converts to UTC first. In any timezone ahead of UTC — the suite pins Asia/Singapore — local midnight is the previous day in UTC, so a claim filed for 1 September is stored as 31 August. One helper, toDateStoredByApplication(), reproduces that with a comment explaining it. When the product is fixed, one function changes and no spec is touched.
Practice
Configuration in depth
playwright.config.ts is the contract between a laptop and a build agent. It is also where a suite quietly acquires its worst habits — a global timeout raised to hide a flake, retries added until the pipeline goes green. This chapter walks the file this repository actually ships, option by option.
The timeout hierarchy
There are four timeouts, they nest, and confusing them is the most common configuration mistake in the ecosystem.
timeout: environment.timeouts.test, // 60 s — a whole test, sign-in included
expect: { timeout: environment.timeouts.assertion }, // 10 s — one assertion
use: {
actionTimeout: environment.timeouts.assertion, // 10 s — one action
navigationTimeout: environment.timeouts.navigation,// 30 s — a 1.3 MB bundle on a cold cache
},
Every number in that block answers a question
Not “what makes the red go away”, but “how long may THIS class of wait legitimately take?”. They live in src/config/environment.ts under names — assertion, navigation, test, webServer — so a reviewer can challenge a change on its meaning instead of arguing about a magic number.
Individual tests can opt out where the product, not the suite, is slow: test.slow() triples the timeout for one test, and test.setTimeout(ms) sets it outright. Both are better than raising the global number, because both leave the reason attached to the test that needs it.
Projects: the unit of “run this differently”
A project is a named configuration over a set of files. This repository uses projects for six distinct jobs, and the list is worth reading as a menu of what projects are for:
| Project | What it varies | Why it is separate |
|---|---|---|
setup | Runs one file, first | Fails fast and mints the authenticated state |
api | No browser, its own testDir | A sub-second feedback loop, and an API-only CI stage |
chromium / firefox / webkit | The engine | Cross-browser risk is real risk |
mobile-chrome | Device emulation, grepInvert | Small-screen coverage without desktop-only specs |
mobile-portrait-audit | A portrait device, one spec | Holds a known defect in place (Chapter 16) |
{
name: 'mobile-chrome',
testDir: './tests/e2e',
testIgnore: PORTRAIT_AUDIT_SPEC, // this spec belongs to its own project
use: { ...devices['Pixel 7 landscape'], storageState: paths.authenticatedState },
dependencies: ['setup'], // nothing runs until authentication succeeds
grepInvert: /@mobile-unfriendly/, // desktop-only specs, excluded by tag
}
dependencies is the mechanism behind the setup project: a dependent project does not start until its dependency has passed, and if setup fails the run reports one failure instead of sixty identical ones. teardown (a property of the setup project) is its mirror image, for cleaning up whatever setup created.
webServer: making npm test the only command
webServer: [
{ command: 'node tools/static-server.mjs', url: appUrl, // the application
reuseExistingServer: !environment.isContinuousIntegration, timeout: 60_000 },
{ command: 'node mock-api/server.mjs', url: `${apiUrl}/health`, // the mock service
reuseExistingServer: !environment.isContinuousIntegration, timeout: 60_000 },
],
Playwright starts both, polls each url until it answers, runs the suite and shuts them down. reuseExistingServer is true locally so an already-running instance is left alone, and false in CI, where a stale process is a mystery rather than a convenience. Use stderr: 'pipe' and stdout: 'ignore' as this repository does: server noise drowns a report, but a server's error output is the first thing you want when it refuses to start.
What CI changes, and why each change is defensible
| Option | Local | CI | Reason |
|---|---|---|---|
retries | 0 | 1 | Locally a flake must be seen; in CI one retry separates infrastructure noise from a defect, and the report still marks the test flaky |
workers | auto (half the cores) | 2 | Shared agents thrash |
forbidOnly | off | on | A stray .only must break the build, not silently shrink it |
reporter | list, HTML, Allure | + JUnit, GitHub | Machines need JUnit; humans need HTML |
trace | on-first-retry in both | Full replay exactly when it is needed, nothing when it is not | |
Options every suite should set deliberately
| Option | This suite | Why it matters |
|---|---|---|
testIdAttribute | default data-testid | Set it once if your app uses data-qa or data-cy, and getByTestId works everywhere |
locale, timezoneId | en-US, Asia/Singapore | Date pickers and number formats are locale-driven; unpinned, they are a flake generator |
viewport | 1440 × 900 | Layout-dependent visibility is real; a laptop-sized default is a decision, not an accident |
baseURL | from environment.ts | Lets specs use paths, and lets one flag point the suite at another host |
outputDir | test-results | One directory to clean, one to upload |
snapshotPathTemplate | not used | Set it before you have 300 screenshots, not after (Chapter 19) |
Environment layering
The rule this repository follows is: one module reads the environment, and nothing else does. src/config/environment.ts loads .env, applies documented defaults, and exports typed values. A page object that read process.env directly would behave differently on a laptop and on an agent for reasons invisible to the reader.
const readString = (name: string, fallback: string): string => {
const value = process.env[name];
return value === undefined || value.trim() === '' ? fallback : value.trim();
};
export const environment = {
isContinuousIntegration: Boolean(process.env['CI']),
baseUrl: readString('BASE_URL', `http://127.0.0.1:${APP_PORT}`),
credentials: {
username: readString('GW_USERNAME', 'amara'),
password: readString('GW_PASSWORD', 'Passw0rd!'),
},
} as const;
Note the defaults: the suite runs against the bundled build with no .env file at all. A configuration that requires a setup ritual before the first green run is a configuration that will be wrong on somebody's machine within a week.
Writing a test, start to finish
The requirement: a premium cannot be paid from a dormant account, and the message must name the account and tell the customer what to do.
- Find or add the page object method.
PremiumPage.selectFundingAccount()exists. If it did not, it would be added there — not in the spec. - Check the rule is declared.
premiumRules.accountDormant(name)exists. If it did not, it would go inbusiness-rules.ts, not be typed as a literal. - Write the spec.
test('refuses a dormant account before considering its currency @smoke', async ({ premiumPage }) => {
const dormant = accounts.dormantForeignCurrency;
await premiumPage.selectPolicy(policies.activeLife.id);
await premiumPage.selectFundingAccount(dormant.id);
await premiumPage.enterAmount(policies.activeLife.premium);
await premiumPage.submitPayment();
// The dormant account is ALSO non-SGD; the exact message proves WHICH rule fired first.
await expect(premiumPage.errorMessage).toHaveText(premiumRules.accountDormant(dormant.name));
await expect(premiumPage.successPanel).toBeHidden();
});
Then read it back and check four things: does the title state a requirement a business analyst would recognise; is every literal a named fixture or rule; does it assert both what happened and what must not have; and would it still pass if the rule were deleted? The last answer must be no.
Tags, and how to choose one
| Tag | Meaning | Where it runs |
|---|---|---|
@smoke | If this fails, the build is not worth testing further | Every commit |
@regression | Full coverage of a rule or a screen | Pull requests, nightly |
@api | Service-level, no browser | Every commit (seconds) |
@mobile-unfriendly | Cannot pass on a phone viewport by design | Excluded from mobile-chrome |
Locators in depth
A locator is the sentence a test uses to point at something. Get it right and the test survives redesigns; get it wrong and it either breaks constantly or — far worse — quietly points at the wrong element and passes.
The priority order, and the reasoning behind it
| Preference | Locator | Breaks when |
|---|---|---|
| 1 | getByTestId('premium-error') | Somebody removes the id — a deliberate act, caught in review |
| 2 | getByRole('button', { name: 'Pay Premium' }) | The accessible name changes — which is a real user-facing change |
| 3 | getByLabel('Amount due (SGD)') | The label changes — again, user-facing |
| 4 | getByText('Premium Paid') | Copy changes, including translation |
| 5 | locator('.mat-mdc-card > div:nth-child(2)') | Anybody touches the CSS or the markup |
Test ids win here because GienTech Wealth ships them on everything that matters, and because they are a contract: developers know not to move them. On an application without them, role-based locators come first — they test the accessibility tree, so a locator that cannot find the button is often telling you a screen-reader user cannot either.
A test id is a contract, not a hack
The common objection is “test ids pollute production markup”. Weigh that against the alternative: a suite whose locators are CSS paths, which turns every restyle into a day of test repair, and which therefore makes the team afraid to restyle. One attribute per meaningful element is the cheapest insurance a front end can buy.
Strictness: the safety net people try to disable
A locator that matches more than one element throws a strict mode violation rather than silently acting on the first match. This is a feature. The error names every match, which usually reveals the real problem in one read.
// ✗ Two "View" links on the claims table — this THROWS, and should.
await page.getByRole('link', { name: 'View' }).click();
// ✓ Narrow by the row that identifies the thing you mean.
await claimsPage.rowByReference(reference).getByRole('link', { name: 'View' }).click();
// ✓ Or, when "any of them" is genuinely the requirement, say so explicitly.
await page.getByRole('link', { name: 'View' }).first().click();
.first(), .last() and .nth(i) resolve strictness by index. Use them when position is genuinely the meaning (“the most recent notice is index 0”) and avoid them when identity is available — an index silently follows the wrong row the day the list order changes.
Chaining, filtering and scoping
// Chaining scopes a search to a subtree. Every page object in this suite does this:
protected byTestId(testId: string): Locator { return this.root.getByTestId(testId); }
// filter() narrows a set by content or by a nested locator…
const lapsedRows = page.getByRole('row').filter({ hasText: 'Lapsed' });
const rowsWithAView = page.getByRole('row').filter({ has: page.getByRole('link', { name: 'View' }) });
const activeOnly = page.getByRole('row').filter({ hasNotText: 'Lapsed' });
// …and locators compose with or() and and() when a UI has two shapes for one thing.
const checkable = host.locator('input[type="checkbox"]').or(host.locator('button[role="switch"]'));
That last line is not a toy example — it is exactly how MaterialControls.toggleInput() handles the fact that a Material checkbox wraps a real <input> while a slide toggle renders a <button role="switch">.
Frames, shadow DOM and things that hide
| Situation | What to do |
|---|---|
An <iframe> (a payment form, an embedded report) | page.frameLocator('#pay').getByRole('textbox', { name: 'Card number' }) |
| Open shadow DOM (most web components) | Nothing special — Playwright pierces open shadow roots automatically |
| Closed shadow DOM | It is not reachable; ask for a test id on the host, or an open root |
A CDK/portal overlay appended to <body> | Scope to the overlay container, as MaterialControls does |
An element hidden by visibility: hidden or zero size | Playwright treats it as not visible — that is usually correct, and the assertion is telling you something |
A hidden <input type="file"> | setInputFiles() works on hidden inputs by design; never click the fake button |
Debugging a locator
npx playwright test --ui # pick locators interactively from a DOM snapshot
npx playwright test --debug # the Inspector's locator explorer, live
await page.pause(); # stop here and explore mid-test
console.log(await locator.count()); # how many did it match?
console.log(await locator.evaluate((el) => el.outerHTML)); # what exactly did it find?
Anti-patterns
| Anti-pattern | What goes wrong |
|---|---|
| XPath copied from DevTools | Encodes the whole document structure; breaks on any markup change |
| Locators built from translated UI text | The suite passes in English and fails in the locale a customer uses |
nth(3) where an id exists | Follows the wrong row silently after a sort or an insert |
Storing an ElementHandle | Goes stale on re-render — the classic “not attached to the DOM” |
| A locator built by string concatenation of user data | Quotes and apostrophes break the selector; use filter({ hasText }) |
Assertions in depth
An assertion is where a test states the requirement. Everything before it is setup; everything about its precision decides whether the test can actually detect the defect it is named after.
Web-first assertions retry; generic ones do not
// ✓ Web-first: polls until it passes or expect.timeout expires. No wait needed.
await expect(premiumPage.errorMessage).toHaveText(premiumRules.partialPayment(312.4));
// ✗ Generic: reads ONCE, right now. If the UI is still rendering, this fails —
// and the usual "fix" is a sleep, which is how a suite starts rotting.
expect(await premiumPage.errorMessage.innerText()).toBe('…');
The tell is the position of await: await expect(locator) is web-first; expect(await …) is a snapshot of one moment. Both have their place — the second is right for a value you have already extracted, such as a reference number — but the first is the default for anything on screen.
| Assertion | Use it for |
|---|---|
toBeVisible() / toBeHidden() | The screen rendered — and, just as often, that a refusal did not render a success panel |
toHaveText() | Exact wording, normalised for whitespace. The default for rule messages |
toContainText() | A fragment inside a larger block — use sparingly; it hides wrong values |
toHaveValue() / toHaveValues() | Form state, including “the field kept its value after Back” |
toBeChecked({ checked }) | Checkboxes, radios and ARIA switches alike |
toBeEnabled() / toBeDisabled() | Lockouts — a message alone does not prove the form is unusable |
toHaveCount() | Filters and tables: what survived and what disappeared |
toHaveAttribute() / toHaveClass() | State a component encodes in markup rather than text |
toHaveURL() / toHaveTitle() | Navigation, including hash routes |
toBeOK() | An APIResponse in the 2xx range |
Precision: the difference between a test and a formality
| Weak | Strong | What the strong one catches |
|---|---|---|
toBeVisible() on the error box | toHaveText(rule(balance)) | The wrong refusal firing, or the wrong number named |
toContainText('Insufficient') | toHaveText(transferRules.insufficientFunds(name, balance)) | A message that names somebody else's balance |
toHaveCount(2) after filtering | count and the excluded row is hidden | A filter that does nothing on a two-row dataset |
| Success panel visible | Success panel visible and reference matches /^CLM-\d{6}$/ | A receipt screen with an empty reference |
Custom messages: the second half of a good failure
await expect(
navigation,
'KNOWN DEFECT: the toolbar holds more than it can show on a portrait phone…',
).toBeGreaterThan(viewportWidth);
The message appears at the top of the failure. Spend one on any assertion whose failure would otherwise read as a bare number comparison — a future reader gets the reason without opening the file.
Soft assertions
// Collect several failures in one run instead of stopping at the first.
await expect.soft(accountsPage.name('sav')).toHaveText('Everyday Savings');
await expect.soft(accountsPage.status('sav')).toHaveText('Active');
await expect.soft(accountsPage.balance('sav')).toHaveText('SGD 18,420.55');
Right for a page of independent facts, where knowing all three failures beats knowing the first. Wrong for a sequence, where the second assertion is meaningless once the first has failed.
Polling for things that are not locators
// expect.poll: re-runs a function until the assertion passes.
await expect.poll(async () => (await api.getClaims()).status(), {
message: 'the claim service should become reachable',
timeout: 10_000,
}).toBe(200);
// expect.toPass: retries a whole BLOCK, assertions included.
await expect(async () => {
const claims = await (await api.getClaims()).json();
expect(claims).toHaveLength(3);
}).toPass({ timeout: 15_000 });
These are the sanctioned way to wait for something the DOM does not express — an asynchronous job, a queue, an eventually-consistent read. They are not a licence to retry a flaky UI step: if a locator needs toPass, the locator is wrong.
Custom matchers
When the same compound check appears in five specs, promote it to a matcher so the failure message can speak the language of the domain.
export const expect = base.expect.extend({
async toShowRefusal(locator: Locator, expected: string) {
const actual = (await locator.innerText()).trim();
return {
pass: actual === expected,
message: () => `expected the screen to refuse with\n "${expected}"\nbut it said\n "${actual}"`,
};
},
});
Snapshot-style assertions
| Tool | Compares | Fragile when |
|---|---|---|
toMatchAriaSnapshot() | The accessibility tree as YAML — roles and names | Rarely; it ignores styling entirely |
toMatchSnapshot() | Arbitrary text or binary | Any incidental change to the value |
toHaveScreenshot() | Rendered pixels | Fonts, animation, scrollbars, platform (see Chapter 19) |
Aria snapshots are the underrated one: they pin the structure and naming of a screen without pinning a single pixel, which makes them a good fit for a component library upgrade — exactly the change that breaks screenshot suites wholesale.
Actions, files and the things browsers do on their own
Clicking and typing are the easy half. The other half — uploads, downloads, dialogs, new tabs, clipboards, drag and drop — is where a suite either grows a set of reliable patterns or grows a set of sleeps.
Input, and why fill beats type
await page.getByTestId('claim-amount').fill('3500'); // sets the value, fires input events
await page.getByTestId('txn-search').pressSequentially('Sal'); // key by key — for typeaheads that need it
await page.getByTestId('reference-input').clear();
await page.keyboard.press('Escape'); // close an overlay without selecting
fill() is atomic and fast and is right almost always. Reach for pressSequentially() only when the application reacts to individual keystrokes in a way the test is about — an autocomplete that filters as you type, a field with an input mask.
Uploads
// The input is hidden behind a styled button — setInputFiles works on hidden inputs by design.
await claimNewPage.fileInput.setInputFiles(uploadPath('discharge-summary.pdf'));
// Several files, a buffer built in the test, or clearing the selection:
await input.setInputFiles([pathA, pathB]);
await input.setInputFiles({ name: 'invoice.csv', mimeType: 'text/csv', buffer: Buffer.from('a,b\n1,2') });
await input.setInputFiles([]);
Never drive the native file chooser
A real file dialog is an operating-system window, not a web page. setInputFiles() bypasses it entirely, works identically on all three engines and in CI, and is what the claim tests in this suite use. If you find yourself reaching for page.on('filechooser'), check first whether the input is reachable — it almost always is.
Downloads
// Start waiting BEFORE the click: the event can fire before the click promise resolves.
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Download statement' }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toMatch(/statement-\d{4}-\d{2}\.pdf/);
await download.saveAs(`test-results/${download.suggestedFilename()}`);
The “start the wait first” shape applies to every browser-initiated event — downloads, popups, dialogs, websockets. Written the other way round, the test passes on a slow machine and fails on a fast one, which is the worst possible failure mode.
Dialogs
// Playwright auto-dismisses dialogs unless you handle them. Handle before triggering.
page.once('dialog', async (dialog) => {
expect(dialog.type()).toBe('confirm');
expect(dialog.message()).toBe('Cancel this transfer?');
await dialog.accept();
});
await page.getByRole('button', { name: 'Cancel transfer' }).click();
New tabs and popups
const popupPromise = page.context().waitForEvent('page');
await page.getByRole('link', { name: 'Terms and conditions' }).click();
const popup = await popupPromise;
await expect(popup).toHaveTitle(/Terms/);
await popup.close();
Drag, hover, and the pointer
await page.getByTestId('card-1').dragTo(page.getByTestId('column-done'));
await page.getByTestId('policy-row-pol1').hover();
await page.mouse.wheel(0, 600); // scroll a virtualised list
await locator.scrollIntoViewIfNeeded(); // rarely needed: actions scroll on their own
Controlling time
Anything that depends on the clock — a session-expiry banner, a countdown, a poll — is untestable in real time and trivial with a fake clock.
await page.clock.install({ time: new Date('2026-09-08T09:00:00+08:00') });
await page.goto(appUrl);
await page.clock.fastForward('15:00'); // fifteen minutes, instantly
await expect(page.getByTestId('session-warning')).toBeVisible();
This suite does not need it — GienTech Wealth has no timers — but a fake clock is the difference between a fifteen-minute test and a fifteen-millisecond one, and it belongs in any suite that has ever contained the words “wait for the session to expire”.
Emulation the runner gives you free
| Concern | Option |
|---|---|
| Screen size and touch | devices['Pixel 7'], or viewport + hasTouch |
| Language and formats | locale, timezoneId |
| Dark mode, reduced motion, forced colours | colorScheme, reducedMotion, forcedColors |
| Location and permissions | geolocation, permissions |
| Offline | context.setOffline(true) — used by the resilience specs in Chapter 17 |
| Credentials for basic auth | httpCredentials |
Waiting, and the war on flake
Chapter 13 covered how to address an element. This chapter is about the other half of every interaction — when it happens — and about the failure mode that follows from getting it wrong. Flake is not weather. It is a defect in the test, and it has causes that can be named.
Every Playwright action waits for the element to be attached, visible, stable, enabled and able to receive events; every web-first assertion retries until it passes or times out. That is why this suite contains no explicit waits.
The rules the linter enforces
'playwright/no-wait-for-timeout': 'error', // a sleep is flake with a delay
'playwright/no-force-option': 'error', // force:true hides a real UI defect
'playwright/no-conditional-in-test': 'error', // a branch means nobody knows what ran
'playwright/expect-expect': 'error', // a test with no assertion asserts nothing
'@typescript-eslint/no-floating-promises': 'error',
That last one is the most valuable rule in the file:
expect(page.getByTestId('premium-error')).toHaveText('…'); // ✗ no await — ALWAYS passes
await expect(page.getByTestId('premium-error')).toHaveText('…'); // ✓
When a “flaky” test is actually a defect
The small-screen project found one, and how it was handled is the model to copy. At a portrait phone width (412 px) the application shell lays its toolbar out 646 px wide; the links spill past the screen edge and paint over the page, so Playwright reported <nav> subtree intercepts pointer events on 74 tests. The temptation is obvious — force: true, a hard wait, or quietly dropping the mobile project.
- The cause was measured, not guessed.
scrollWidth(646) againstclientWidth(412) on the toolbar itself. - The finding was confirmed against a human. A customer holding the phone upright cannot tap those controls either. The tests were right; the product is wrong.
- Coverage was kept.
mobile-chromeruns the phone in landscape, where the toolbar fits and all 152 small-screen tests pass. A project that is permanently half red teaches a team to ignore red. - The defect was pinned, in its own project.
mobile-portrait-auditruns one spec under true device emulation.
test('a customer can open an account from the list on a portrait phone @regression', async ({
signedIn, accountsPage, accountDetailPage,
}) => {
// Expected to fail until the shell is made responsive. When it starts passing, Playwright
// fails the run so this annotation — and the defect — get closed out deliberately.
test.fail();
await signedIn.toolbar.goToAccounts();
await accountsPage.openAccount(accounts.primarySavings.id);
await accountDetailPage.expectLoaded();
});
test.fail() is the tool for a known defect. The test passes because it fails, so the build stays green and honest; the day the product is fixed, Playwright reports “expected failure but passed” and turns the build red until somebody deletes the annotation. A defect recorded this way cannot be quietly forgotten — and it cannot quietly stay fixed either.
Flake triage
| Symptom | Real cause, nine times in ten | Fix |
|---|---|---|
| “Element is not attached to the DOM” | An element handle, or a re-render between find and act | Use Locator; never store handles |
| Click lands on the wrong thing | A Material overlay still fading out | Wait for the panel to be hidden |
| Passes alone, fails in parallel | Shared state — here, a stray goto() | Navigate by clicking |
| Passes locally, fails in CI | A timing assumption, or unpinned locale/timezone | Config pins both; find the assumption |
| Fails only on WebKit | A real cross-engine difference in focus or animation | Fix the wait, never force: true |
| Fails at 08:00 UTC only | A date computed in one timezone, asserted in another | Use date.utils.ts |
Network: observing, mocking and replaying
The network is the seam between the thing you are testing and everything you are not. Playwright lets a test watch it, change it, or replace it entirely — and knowing which of the three a situation calls for is most of the skill.
Observing
const offOriginRequests: string[] = [];
page.on('request', (request) => {
if (!request.url().startsWith(environment.baseUrl)) {
offOriginRequests.push(`${request.method()} ${request.url()}`);
}
});
// …drive the app…
expect(offOriginRequests, 'no request should leave the application origin').toEqual([]);
That assertion is a real requirement in this repository, not a demonstration: a banking screen that phones home to an analytics domain is a compliance incident, and tests/e2e/platform/network-resilience.spec.ts is the only thing standing between the product and somebody pasting a tracking snippet into the shell.
Mocking a response
// Replace a response entirely.
await page.route('**/api/v1/policies', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([]), // the empty state, on demand
});
});
// Fail one call, to prove the screen degrades honestly.
await page.route('**/api/v1/claims', (route) => route.fulfill({ status: 500, body: '{}' }));
// Let the real call happen, then edit what came back.
await page.route('**/api/v1/accounts', async (route) => {
const response = await route.fetch();
const accounts = await response.json();
accounts[0].balance = 0; // the zero-balance case, without a fixture user
await route.fulfill({ response, json: accounts });
});
// Abort, to test the offline path or to speed a run up by dropping images.
await page.route('**/*.{png,jpg,woff2}', (route) => route.abort());
| Method | Effect | Typical use |
|---|---|---|
route.fulfill() | Answer without touching the network | Error states, empty lists, fixed data |
route.fetch() + fulfill() | Real response, edited | One field changed, everything else genuine |
route.continue() | Pass through, optionally with changed headers or body | Injecting an auth header, forcing a feature flag |
route.abort() | Fail the request | Offline behaviour, third-party blocking |
page.unroute() | Remove a handler | Undoing a mock mid-test, as the resilience specs do |
Register routes before the navigation that triggers them
page.route() only affects requests made after it is registered. A mock added after goto() catches nothing, the page loads real data, and the test fails in a way that looks like a product bug. Route first, navigate second — every time.
Waiting for a specific request or response
const claimPosted = page.waitForResponse(
(response) => response.url().endsWith('/claims') && response.request().method() === 'POST',
);
await claimNewPage.submitClaim();
const response = await claimPosted;
expect(response.status()).toBe(201);
Useful for asserting the contract from a UI test — the screen said “submitted”, and the request it sent was actually a well-formed 201. Do not use it as a substitute for a visible assertion: users read screens, not status codes.
HAR: record once, replay forever
// Record while running against the real service.
npx playwright open --save-har=fixtures/claims.har https://staging.example.com
// Replay in a test — deterministic, offline, and fast.
await page.routeFromHAR('fixtures/claims.har', { url: '**/api/**', update: false });
HAR replay is the middle ground between “mock every endpoint by hand” and “depend on a staging environment being up”. Its weakness is the weakness of all recorded fixtures: it goes stale silently. Re-record on a schedule (update: true rewrites the file), and never let a HAR be the only coverage of an integration.
WebSockets and server-sent events
// Observe frames…
page.on('websocket', (ws) => {
ws.on('framereceived', (frame) => console.info('←', frame.payload));
});
// …or take the socket over entirely, and push the message the UI must react to.
await page.routeWebSocket('wss://**/live', (ws) => {
ws.onMessage(() => ws.send(JSON.stringify({ type: 'claim-approved', reference: 'CLM-482913' })));
});
What this suite does, and why it is unusual
GienTech Wealth has no back end: the whole application is one document with its state in memory. There is nothing to mock, which turns the network into a requirement rather than a dependency — “self-contained” is a claim that regresses the moment somebody adds a CDN font. So the network specs here assert the negative (exactly one document requested, no off-origin traffic), prove the application still works with the network switched off, and use route for the two things it is genuinely good at: failing the document to check the failure is honest, and delaying it to prove the page objects wait on rendered state rather than on luck.
test('renders correctly when the document is served slowly @regression', async ({ page, signInPage }) => {
await page.route(appUrl, async (route) => {
await new Promise((resolve) => setTimeout(resolve, 1_500));
await route.continue();
});
await page.goto(appUrl);
// If any locator in the suite depended on a fixed sleep, this test would fail.
await signInPage.expectLoaded();
await page.unroute(appUrl);
});
The API layer
The API suite proves sixty-eight requirements in less time than a single browser takes to start. The UI suite proves the customer can actually reach those behaviours — which no API test can tell you. Because GienTech Wealth has no back end, mock-api/ supplies one: seed.mjs (a mirror of the app's store), domain.mjs (the rules, in the app's own order and wording) and server.mjs (routing, bearer auth, session isolation).
Methods return the raw response, never a parsed body
Half the requirements are about the status and the error code of a refusal. A client that threw on non-2xx would force try/catch into every negative test — and a try/catch in a test is a branch, and a branch means nobody knows which path ran.
The version prefix belongs in the client, not in baseURL
Playwright joins URLs with new URL(path, base) semantics: a path starting with / is absolute and replaces the base's path. A baseURL of …/api/v1 plus a request for /accounts silently resolves to …/accounts, and every call 404s. This suite hit exactly that, once.
Status-code discipline
| Situation | Status | Why the distinction matters |
|---|---|---|
| Malformed body, missing field | 400 | Fix the request |
| No / bad / signed-out token | 401 | Re-authenticate |
| Locked account | 423 | Stop retrying — a human must act |
| Duplicate payee | 409 | The request was fine; the state refused it |
| Business refusal (dormant, partial, lapsed) | 422 | Show the message to the customer |
| Unknown path vs. wrong verb | 404 / 405 | A routing bug looks nothing like a typo |
Visual regression and accessibility
Two kinds of checking that functional tests cannot do, with two very different risk profiles. One of them this suite deliberately does not use; the chapter explains both, and says plainly which is which.
Screenshot comparison, and its real cost
await expect(page).toHaveScreenshot('dashboard.png'); // whole page
await expect(policiesPage.table).toHaveScreenshot('policies.png'); // one component — much steadier
await expect(page).toHaveScreenshot('dashboard.png', {
mask: [page.getByTestId('next-premium-due')], // hide anything that legitimately changes
maxDiffPixelRatio: 0.01, // tolerate sub-pixel text rendering
animations: 'disabled', // freeze CSS animations before capture
fullPage: false,
});
| Screenshot tests catch | Screenshot tests break because of |
|---|---|
| A stylesheet regression no assertion covers | A different OS or browser build rendering fonts a pixel differently |
| An element that moved or vanished visually | Any legitimate copy or layout change, in bulk |
| A broken dark theme | Animations, carets, scrollbars, lazy images |
| Component-library upgrades that shift spacing | Dates, balances and reference numbers that change per run |
Baselines must be generated where they are compared
A baseline captured on a designer's laptop and compared on a Linux agent fails on font hinting alone. Generate baselines in the same container CI uses — Playwright's own Docker image is the usual answer — and commit them from there, or the suite spends its life re-approving noise.
This repository does not ship screenshot tests. The reason is a judgement, not an oversight: GienTech Wealth's visible values (balances, due dates, references) change on almost every run, its layout is Material's rather than the team's, and 198 functional assertions already cover what the screens must say. A component-level baseline set — the toolbar, the policy table, the receipt panel — would be the right first step if the team ever needed it, and snapshotPathTemplate should be configured before that set exists, not after three hundred files have landed in the wrong place.
Accessibility scanning
Automated tooling catches roughly a third of WCAG issues — contrast, missing labels, ARIA misuse, heading order. That third is worth automating precisely because it is the mechanical part.
import AxeBuilder from '@axe-core/playwright';
test('the dashboard has no critical accessibility violations @a11y', async ({ signedIn, page }) => {
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa'])
.exclude('#third-party-widget') // scope out what the team cannot fix
.analyze();
expect(results.violations.filter((v) => v.impact === 'critical')).toEqual([]);
});
Two practical notes. First, scan states, not just pages: run the scan again with the claim form showing an error, or the mat-select open, because that is where ARIA usually breaks. Second, adopt it as a ratchet — start by failing only on critical, record the rest as a report, and tighten the threshold as the backlog shrinks. A scan that fails a build on ninety pre-existing violations gets switched off within a week.
What a functional suite already gives you for free
Role-based locators are an accessibility test in disguise. getByRole('button', { name: 'Pay Premium' }) fails when a developer replaces a button with a clickable <div>, or drops the accessible name — the same two defects an audit would report. A suite that prefers roles over CSS is quietly protecting the accessibility tree on every run, without a single dedicated test.
The same is true of toMatchAriaSnapshot() from Chapter 14: it pins the structure and naming of a screen, which is the part a screen reader announces, while ignoring the pixels that make screenshot suites brittle.
Operations
Running the suite
The full matrix takes two minutes and 681 test runs. You should almost never run it while you work. This chapter is about choosing the smallest run that answers the question in front of you.
Selecting what to run
npx playwright test # everything
npx playwright test --project=chromium # one engine
npx playwright test --project=api # no browser at all, ~1 second
npx playwright test tests/e2e/insurance # a folder
npx playwright test transfer.spec.ts # a file (substring match on the path)
npx playwright test transfer.spec.ts:120 # one test, by line number
npx playwright test --grep "@smoke" # by tag
npx playwright test --grep "refuses a dormant" # by title
npx playwright test --grep-invert "@regression" # everything except
npx playwright test --last-failed # only what failed last time
npx playwright test --only-changed # only specs touched vs. the base branch
| Question you are asking | Command |
|---|---|
| Did I break the rule I just edited? | --project=chromium --grep "@smoke" |
| Does my new page object work everywhere? | tests/e2e/banking/transfer.spec.ts (all projects) |
| Is this a real failure or a flake? | --repeat-each=10 --workers=1 |
| Did my fix clear the whole failure list? | --last-failed |
| Is the service still honest? | --project=api |
| Will CI be green? | npm run verify && npx playwright test |
Controlling how it runs
--workers=1 # serial: rules out parallelism as a cause
--workers=50% # a share of the cores, portable across machines
--fully-parallel # tests within a file spread across workers too
--repeat-each=10 # run each selected test ten times — the flake hunter
--retries=2 # override config retries for one run
--max-failures=1 # stop at the first failure (CI smoke gates, bisecting)
--timeout=120000 # override the per-test timeout for one run
--headed # watch it happen
--trace on # record a trace for every test, not just retries
--forbid-only # fail if a .only survived review (CI already sets this)
--fail-on-flaky-tests # treat "passed on retry" as failure — for release branches
--repeat-each=10 --workers=1 is the single most useful command in this chapter
It answers the question every intermittent failure raises — is this test unreliable, or was the machine busy? Ten green runs in series is strong evidence of the second; two failures in ten is a defect in the test, and Chapter 16 tells you where to look.
Sharding
npx playwright test --shard=1/4 # this machine runs a quarter of the tests
Shards split the test list deterministically, so four machines with --shard=1/4 … 4/4 cover the suite exactly once. Each shard writes its own report; merging them is Chapter 23. Shard when wall-clock time matters more than machine count — and remember the fixed cost: every shard installs dependencies and launches browsers, so four shards of a two-minute suite can easily be slower than one.
Reading the output
| Reporter | Shows | Use when |
|---|---|---|
list (default) | A line per test as it finishes | Local runs |
line | One updating line, failures printed in full | Long runs where scrollback matters |
dot | One character per test | Very large suites |
html | A browsable report with traces attached | Investigating anything |
github | Inline annotations on the pull request | CI |
junit / blob / allure | Machine formats, merge input, dashboards | CI |
Read the summary line, never the tail of the log
Earlier in this project a run was reported as “605 passed” from a truncated tail — the real result was 74 failed. The summary block (N passed, N failed, N flaky, N did not run) is the only trustworthy source, and the exit code is the only trustworthy gate.
Annotations that change how a test runs
test.skip(); // do not run — must carry a reason in review
test.skip(browserName === 'webkit', 'WebKit lacks the API this screen needs');
test.fixme(); // known broken, do not run, do not hide
test.fail(); // EXPECTED to fail; passing turns the build red (Chapter 16)
test.slow(); // triple this test's timeout, with the reason attached to it
test.describe.configure({ mode: 'serial' }); // a chain that must run in order — a last resort
test.describe.configure({ retries: 2 }); // extra retries for one describe block
serial mode deserves a warning: it makes a whole block share one worker and stop at the first failure, which trades the runner's isolation guarantee for convenience. This suite uses none of it, because the application's in-memory reset makes every test independent for free.
Debugging
A failing test is a question: what did the browser actually do? Playwright answers it better than any other tool in this space, and the difference between an engineer who knows the four instruments below and one who does not is measured in hours per week.
--repeat-each=10 --workers=1 when the failure is intermittent. Reach for the cheapest instrument that can answer the question.1 · UI mode — the default answer
npx playwright test --ui
A window with the whole suite in a tree, a timeline of every action, and a DOM snapshot at each step that you can inspect with real DevTools. What it gives you that a log cannot:
- Time travel. Click any action in the timeline and see the page exactly as it was before and after — including the element the action targeted, highlighted.
- A locator picker. Point at anything in the snapshot and get a suggested locator, which you then rewrite into a page object accessor rather than pasting into a spec.
- Watch mode. Re-runs on save, so a locator fix is a two-second loop.
- Everything else in one place: console output, network requests, the source line, attachments, and the error, all pinned to the moment they happened.
2 · The trace viewer — for failures that already happened
npx playwright show-trace test-results/<test-folder>/trace.zip
# or drag the zip onto https://trace.playwright.dev — nothing is uploaded, it runs locally
A trace is a recording: actions, DOM snapshots, console, network, source, and screenshots-on-every-action. This suite records on-first-retry, so every CI failure arrives with one attached to the HTML report. Turn it on for a single local run with --trace on when you want the same evidence for something that is not failing yet.
| Panel | Answers |
|---|---|
| Timeline (top) | What ran, how long each step took, where the time went |
| Actions (left) | Every call, its parameters, and whether it retried |
| Before / After / Action snapshots | What the page looked like on either side of the step — the fastest way to see an overlay |
| Source | The exact line, with the call stack into your page objects |
| Network | Every request, with timing — including the ones a mock intercepted |
| Console / Errors | What the application said while it was failing |
Read the “Action” snapshot before you read the error
The error tells you the click timed out. The snapshot tells you why — a toolbar painted over the target, a dialog nobody dismissed, a spinner still spinning. That is how the portrait-phone defect in Chapter 16 was diagnosed in one look after seventy-four identical-looking timeouts.
3 · The Inspector — stepping through live
npx playwright test --debug # whole run, paused at the first line
npx playwright test transfer.spec.ts:120 --debug # one test
PWDEBUG=1 npx playwright test # same, via environment
// Or stop at exactly the interesting moment, leaving the browser live and explorable:
await transferPage.fillTransferForm(details);
await page.pause(); // Inspector opens here; resume when ready
await transferPage.continueToConfirmation();
The Inspector steps action by action, highlights the element each locator resolves to, and has a live locator explorer for trying alternatives against the real page. PWDEBUG=1 also disables timeouts, so the test will not expire while you think.
4 · The VS Code extension
Run or debug a single test from a gutter icon, set breakpoints in test and page-object code, pick locators from a live browser, and record new steps directly into the file at the cursor. For engineers who live in the editor, this replaces the CLI for everyday work — the CLI stays for CI and for the flag-heavy runs in Chapter 20.
Turning the volume up
DEBUG=pw:api npx playwright test transfer.spec.ts # every API call and its arguments
DEBUG=pw:browser npx playwright test # browser stdout/stderr — launch failures
DEBUG=pw:webserver npx playwright test # why your webServer never came up
npx playwright test --headed --project=chromium # watch the run
npx playwright test --workers=1 --headed # watch it without four windows fighting
// slowMo is a config option, not a flag — useful for demos and for spotting a fast redirect.
use: { launchOptions: { slowMo: 250 } },
Reading an error message properly
Error: locator.click: Timeout 10000ms exceeded.
Call log:
- waiting for getByTestId('nav-account-menu')
- locator resolved to <button data-testid="nav-account-menu"…>
- attempting click action
- waiting for element to be visible, enabled and stable
- element is visible, enabled and stable
- scrolling into view if needed
- done scrolling
- <span>Transfer</span> from <nav>…</nav> subtree intercepts pointer events
Every line matters. Resolved to means the locator was fine — the problem is later. Visible, enabled and stable means the element is real and settled. The last line is the actual cause: something else is on top. Contrast that with a call log that never resolves the locator, which is a locator problem, or one that resolves to two elements, which is strict mode telling you to narrow the scope.
| Message | What it really means | First move |
|---|---|---|
Timeout … waiting for locator | Nothing ever matched | Check the id in the app; check you are on the right screen |
strict mode violation: resolved to N elements | The description is ambiguous | Scope to a row or region; do not reach for .first() reflexively |
subtree intercepts pointer events | Something is painted over the target | Open the action snapshot — it is usually a real overlay defect |
element is not stable | An animation is still running | Assert on the settled state; consider animations: 'disabled' for screenshots |
Test timeout of 60000ms exceeded | The whole test ran out, not one action | Look at the trace timeline for the step that ate the budget |
Target page, context or browser has been closed | Something closed the page — often a missing await | Run the lint; look for the floating promise |
Expected to fail, but passed | A test.fail() annotation is now stale | The product was fixed — close out the finding |
Debugging the suite rather than the test
| Symptom | Instrument |
|---|---|
| Passes alone, fails with the others | --workers=1, then look for shared state or a stray goto() |
| Fails only in CI | Match locally: CI=true npx playwright test --project=chromium |
| Fails only on one engine | Run that project headed; compare the action snapshots side by side |
| Fails only at a certain time of day | A date or timezone assumption — Chapter 10 |
| The suite never starts | DEBUG=pw:webserver; check the port is free |
| Everything fails identically | Read the setup project's result first — it is designed to be the single failure |
Codegen, and what to do with what it gives you
Codegen records what you do in a browser and writes Playwright code for it. It is excellent at one job — telling you which locator the tooling would choose — and dangerous at another: producing tests that look finished and are not.
Recording
npx playwright codegen http://127.0.0.1:8123/gientech-wealth.html # this project: npm run codegen
npx playwright codegen --device="Pixel 7" <url> # record on a phone viewport
npx playwright codegen --color-scheme=dark <url>
npx playwright codegen --viewport-size=1440,900 <url>
npx playwright codegen --target=javascript <url> # or python, java, csharp
npx playwright codegen --save-storage=.auth/recorded.json <url> # save the signed-in state…
npx playwright codegen --load-storage=.auth/recorded.json <url> # …and start already signed in
npx playwright codegen --save-har=fixtures/session.har <url> # record the traffic too (Chapter 17)
The --save-storage / --load-storage pair is the one people miss. Recording a deep screen means signing in first every single time, unless you sign in once, save the state, and start every later recording from it.
What it produces, and what is wrong with it
// Straight out of the recorder — this is NOT a test.
await page.goto('http://127.0.0.1:8123/gientech-wealth.html');
await page.getByTestId('username-input').fill('amara');
await page.getByTestId('password-input').fill('Passw0rd!');
await page.getByTestId('sign-in-button').click();
await page.getByTestId('nav-policies').click();
await page.getByTestId('open-policy-pol1').click();
await page.getByTestId('pay-premium-button').click();
await page.getByTestId('premium-amount').fill('312.40');
await page.getByTestId('premium-pay-button').click();
| Problem | Why it matters | Fix |
|---|---|---|
| No assertions | It proves the clicks did not throw, nothing more | State the requirement with expect |
| Hard-coded credentials and amounts | Changes to seed data break every recording | environment.credentials, policies.activeLife.premium |
| Page-wide locators | Can match a detached element from the outgoing screen | Scope through a page object's root |
| Absolute URL | Cannot point at another environment | baseURL plus a route helper |
| No structure | The next test repeats all of it | Lift into PremiumPage.payPremium() |
The same journey, after the four steps in Figure 22.1
test('pays a premium in full from an active SGD account @smoke', async ({ premiumPage }) => {
await premiumPage.payPremium({
policyId: policies.activeLife.id,
fromAccountId: accounts.primarySavings.id,
amount: policies.activeLife.premium,
});
await expect(premiumPage.successPanel).toBeVisible();
await expect(premiumPage.successMessage).toContainText(policies.activeLife.product);
expect(await premiumPage.readReceiptNumber()).toMatch(referencePatterns.premium);
});
Nine recorded lines became three, the data came from one place, and the test now states a requirement instead of describing a click path.
Use codegen as a locator oracle, not a test factory
Its most valuable output is the locator it chooses, because that reflects the same priority order Chapter 13 recommends and it can see the accessibility tree you cannot. Record a flow, harvest the locators into page objects, throw the rest away. A suite of recorded scripts is a suite that nobody can refactor — which is the same as a suite nobody can keep.
Recording assertions
The recorder's toolbar can also record assertions — visibility, text, value — while you point at elements. They come out as page-wide expect calls, so they still need lifting, but they are a fast way to capture the exact rendered string a message produces before you promote it into business-rules.ts.
Where recording genuinely wins
- Exploring an application nobody has automated yet. Twenty minutes of recording tells you which elements have test ids and which do not — which is the first thing to negotiate with the developers.
- A complex widget. Record one interaction with a date picker or a drag-and-drop board to see how the tooling addresses it, then write the driver properly (Chapter 7).
- Reproducing a manual tester's bug report. Have them record it; you get a precise, replayable sequence instead of a paragraph of prose.
- Capturing a HAR of a real session to replay offline later.
Continuous integration and reporting
await is caught in thirty seconds, not by six browser shards twenty minutes later.Each engine is split across two runners with --shard=1/2. Every shard writes a blob report and a final job merges them — four partial reports are four times the work for whoever has to read them; one merged report is one.
| Setting | Locally | In CI | Why |
|---|---|---|---|
| retries | 0 | 1 | Locally a flake should be seen; in CI one retry separates infrastructure noise from a defect, and the report still flags it |
| workers | auto | 2 | Shared agents thrash |
| forbidOnly | off | on | A stray .only must break the build, not silently shrink it |
| reporters | list, HTML, Allure | + JUnit, GitHub | Machines need JUnit; humans need HTML |
| reuseExistingServer | on | off | A stale process in CI is a mystery |
Evidence is captured only when it is needed: trace: 'on-first-retry', screenshot: 'only-on-failure', video: 'retain-on-failure'. Always-on tracing costs minutes and gigabytes; on-failure tracing gives a full replay exactly when someone needs it.
The maintenance playbook
Common changes and where they land
| The change | Files to touch | Files not to touch |
|---|---|---|
| A button's test id moved | One page object | Any spec |
| An error message reworded | business-rules.ts | Every spec asserting it |
| A seeded balance changed | test-data.ts | Derived totals — they recompute |
| A new field on the claim form | claim-new.page.ts, claim.builder.ts | Existing claim specs |
| A whole new screen | A new *.page.ts, the barrel, the fixtures | Anything else |
Rules for keeping the suite trustworthy
- Never skip a test to make a build green. A skipped test is a lie with a checkbox. Fix it, or delete it and say why.
- Never assert a substring where an exact match will do.
toContainText('Insufficient')passes on a message naming the wrong balance. - A new rule needs a positive and a negative test. The negative is the one that catches its deletion.
- Encode defects, do not work around them silently. One helper, one comment, one line to change when it is fixed.
- If a test needs a comment to explain what it does, rewrite it. Comments explain why.
Suite economics: speed, cost and where coverage belongs
A test suite has a budget, whether or not anyone writes it down: the wall-clock time a team will tolerate before they stop waiting for it, and the machine time somebody pays for. Both are spent by decisions made when tests are written, and both are recoverable by the same means.
Where this suite's time actually goes
| Project | Tests | Wall clock | Per test |
|---|---|---|---|
api | 68 | ~0.7 s | ~10 ms |
chromium | 153 | ~25 s | ~160 ms |
firefox | 153 | ~40 s | ~260 ms |
webkit | 153 | ~35 s | ~230 ms |
mobile-chrome | 151 | ~25 s | ~165 ms |
| Full matrix | 681 | ~2 min | ~175 ms |
The ratio is the point: a business rule costs about ten milliseconds to prove over HTTP and about a sixth of a second to prove through a browser, per engine. Proving it in both is a deliberate purchase — the API test proves the rule, the UI test proves a customer can reach it — and it is affordable only because the rule tests are small.
The four levers, in order of effect
| Lever | Effect | Cost |
|---|---|---|
| Push a check down a layer (UI → API) | 10–20× per check | You stop testing the screen; keep one UI test that proves reachability |
| Parallelism (workers, shards) | Near-linear until I/O saturates | Machines, and flakiness if tests share state |
| Skip redundant setup (storage state, seeded data) | Seconds per test | Isolation, if the shared state is mutable |
| Cut work per test (fewer navigations, no sleeps) | Tens of ms per test | Almost none — this is free money |
Worker tuning, without cargo cult
More workers help until something saturates — usually CPU on a laptop, memory or the application server in CI. The honest way to pick a number is to measure two or three values on the machine that will actually run it:
time npx playwright test --project=chromium --workers=1
time npx playwright test --project=chromium --workers=4
time npx playwright test --project=chromium --workers=8 # where the curve flattens, stop
This repository uses the runner's default locally (half the cores) and two workers in CI, because shared agents are small and a thrashing agent is slower than a patient one. Both numbers are in one file, with the reasoning attached.
What makes a suite slow that nobody notices
| Habit | Typical cost | Fix |
|---|---|---|
| Signing in through the UI in every test | 0.5–3 s per test | Storage state, or a fixture that reuses a session (Chapter 8) |
waitForTimeout “to be safe” | Exactly what it says, every run, forever | Wait for state; the linter already forbids it |
networkidle as a readiness signal | Seconds on any page with polling | Wait for the element that proves the screen rendered |
| Full-page screenshots everywhere | Hundreds of ms, plus review time | Component-level baselines, and only where they earn it |
| Journeys used for rule coverage | 10–30× the cost of the rule test | Rules in focused specs, seams in a handful of journeys |
| Re-navigating between assertions | A page load each time | Assert several facts on one screen — that is not a violation of “one assertion per test” |
The economics of retries
A retry is a purchase: you pay one extra run of a failing test to avoid one false alarm. It is worth it in CI, where a red build stops a team, and not worth it locally, where a flake should be seen by the person who created it. What is never worth it is hiding the purchase: a suite with three retries and no flake tracking is a suite that has quietly agreed to ship intermittent defects. Keep retries at one, read the flaky count in every report, and treat a test that needed a retry as a bug with a deadline.
A suite nobody waits for is a suite nobody runs
The target that matters is not “fast”; it is “faster than the developer's patience”. Under about two minutes, people run it before pushing. Past ten, they push and hope. Every decision in this chapter is really about staying on the right side of that line.
Conventions cheat sheet
FILES kebab-case + role suffix sign-in.page.ts · claim.builder.ts · transfer.spec.ts
CLASSES PascalCase, named for screen SignInPage · AppToolbar · GienTechWealthApiClient
METHODS verbs for actions payPremium() · acknowledgeUnverifiedPayee()
read… for values readReceiptNumber() · readOutstandingAmount()
get x(): Locator errorMessage · successPanel
FIXTURES nouns, what they ARE signedIn · premiumPage · authenticatedApi
TESTS the requirement, + a tag 'refuses a partial payment and names the outstanding amount @smoke'
CONSTANTS named for purpose accounts.dormantForeignCurrency (not accounts.usd)
SELECTORS data-testid → role → label → text → CSS
ASSERTIONS web-first, always awaited await expect(locator).toHaveText(rule(value))
WAITING never a sleep the linter fails the build on waitForTimeout
The business rules under test
| # | Rule | UI spec | API spec |
|---|---|---|---|
| 1 | Three wrong passwords lock the account | sign-in | authentication |
| 2 | The error never says which half was wrong | sign-in | authentication |
| 3 | A transfer above the balance is refused, naming the balance | transfer | banking |
| 4 | An unverified payee needs an acknowledgement | transfer | banking |
| 5 | A payee account number must match 123-45678-9 | payees | banking |
| 6 | A duplicate payee account number is refused | payees | banking |
| 7 | A dormant account cannot pay a premium — checked first | premium | insurance |
| 8 | Premiums are collected in SGD only | premium | insurance |
| 9 | Partial premiums refused; a lapsed policy owes two | premium | insurance |
| 10 | Paying in full reinstates a lapsed policy | journeys | insurance |
| 11 | A lapsed policy provides no cover | claims | insurance |
| 12 | An incident dated in the future is refused | claims | insurance |
| 13 | Claims over SGD 1,000 need a document — exactly 1,000 does not | claims | insurance |
| 14 | A claim cannot exceed the sum assured | claims | insurance |
| 15 | The declaration must be ticked | claims | insurance |
Command reference
| Command | Purpose |
|---|---|
npm test | The whole suite: API + three engines + mobile |
npm run test:smoke | The @smoke build gate |
npm run test:chromium | One project (also :firefox, :webkit, :mobile) |
npm run test:ui | Interactive UI mode — the best debugging tool |
npm run test:debug | Playwright Inspector, step by step |
npm run report | Open the last HTML report |
npm run report:allure | Generate and open the Allure report |
npm run codegen | Record interactions (raw material only) |
npm run verify | Typecheck + lint + format — what CI gates on |
npm run serve:app | Serve the application alone, for manual exploration |
npm run clean | Delete reports, results and saved auth state |
Troubleshooting index
Symptom first, because that is what you have at three in the morning.
The run will not start
| Symptom | Likely cause | Fix |
|---|---|---|
Error: browserType.launch: Executable doesn't exist | Engines not downloaded | npx playwright install (add --with-deps on Linux) |
EADDRINUSE 127.0.0.1:8123 | A previous server is still running | Kill it, or let reuseExistingServer use it |
| Timed out waiting for the webServer | The command failed silently | DEBUG=pw:webserver; check stderr: 'pipe' is set |
| Every test fails identically at sign-in | The setup project failed | Read the setup result first — it exists to be the single failure |
Cannot find module '@pages/…' | Path alias missing from tsconfig.json | Add it to paths; Playwright reads them natively |
A test fails
| Symptom | Likely cause | Fix |
|---|---|---|
| Timeout waiting for a locator | Nothing matched | Wrong screen, wrong id, or the app never rendered it — check the action snapshot |
strict mode violation | Ambiguous locator | Scope to a row or region (Chapter 13) |
subtree intercepts pointer events | Something is painted over the target | Usually a real overlay defect — do not reach for force |
element is not attached to the DOM | An element handle, or a re-render | Use locators; never store handles |
| Assertion sees stale text | Generic assertion instead of web-first | await expect(locator).toHaveText(…), not expect(await …) |
| Test passes when it should fail | A missing await | npm run lint — no-floating-promises catches it |
Expected to fail, but passed | A test.fail() is stale | The product was fixed; close the finding out |
It only fails sometimes
| Pattern | Likely cause | Fix |
|---|---|---|
| Alone it passes, in parallel it fails | Shared state, or a mid-test goto() | --workers=1 to confirm, then remove the sharing |
| Fails roughly one run in ten | A race against an animation or a re-render | Assert the settled state; wait for the overlay to close |
| Fails only in CI | Timing, locale, timezone, or a smaller machine | CI=true locally; pin locale and timezone in config |
| Fails only on WebKit | A genuine engine difference in focus or animation | Fix the wait; if it is a product bug, raise it |
| Fails at a particular hour | A UTC boundary in a date computation | Chapter 10 — relative dates and one conversion helper |
| Fails after a dependency bump | The component library changed its DOM | Update the widget driver in src/pages/components/, not the specs |
The report or the tooling misleads you
| Symptom | Cause | Fix |
|---|---|---|
| “N passed” but the build failed | You read a truncated log tail | Read the summary block and the exit code |
| Far fewer tests ran than expected | A .only, or a grep in the command | --forbid-only in CI; check the invocation |
| Report shows no trace | trace: 'on-first-retry' and retries are 0 | --trace on for that run |
| Four partial HTML reports | Sharded run, not merged | npx playwright merge-reports --reporter html ./all-blob-reports |
| Screenshots differ on every machine | Baselines generated elsewhere | Generate them in the CI container (Chapter 19) |
Glossary
| Term | Means |
|---|---|
| Actionability | The checks — attached, visible, stable, receives events, enabled — an action waits for before it runs |
| Auto-waiting | The retry loop that makes explicit waits unnecessary |
| Blob report | A machine-readable per-shard report, merged into one HTML report afterwards |
| Browser context | An isolated session — own cookies and storage — created in milliseconds |
| Component object | A class owning a fragment (a toolbar) or a widget's mechanics (a mat-select) |
| Fixture | A named, lazily-built dependency with guaranteed teardown |
| Flake | A test whose result changes without the code changing. A defect, not weather |
| Locator | A lazy, auto-retrying description of an element — not the element itself |
| Page object | A class that knows how to interact with one screen, and asserts nothing |
| Project | A named configuration over a set of files — engine, device, directory, tags |
| Shard | A deterministic slice of the test list, for spreading a run over machines |
| Soft assertion | expect.soft — records a failure and keeps going |
| Storage state | Saved cookies and local storage, reused to skip signing in |
| Strict mode | The rule that a locator matching several elements is an error, not a coin toss |
| Test id | An attribute (data-testid) that exists as a contract for automation |
| Trace | A recording of a run — actions, snapshots, network, console — replayable offline |
| Web-first assertion | An assertion that retries until it passes or times out |
| Worker | A process running test files, owning one browser instance |
Starting a suite from zero
The order below is the order this repository was built in, and it is deliberate: each step makes the next one cheaper, and the first green test arrives before any structure exists to argue about.
| Step | Done when | Trap to avoid |
|---|---|---|
| 1 · One passing test | npx playwright test is green from a clean clone | Building the framework before proving the app is reachable |
| 2 · Config | No .env is needed for the default run | Timeouts chosen to make red go away |
| 3 · Test ids | Every control you need has one | Accepting CSS paths “for now” — there is no later |
| 4 · First page object | A spec reads as a requirement | Assertions inside the page object |
| 5 · Fixtures | Specs declare what they need | beforeEach that builds everything for everybody |
| 6 · Data and rules | No literal balance or message in any spec | Hard-coded dates — they expire silently |
| 7 · Lint and types | no-floating-promises and no-wait-for-timeout are errors | Warnings — nobody reads warnings |
| 8 · CI | Quality gate runs before browsers | Uploading gigabytes of always-on traces |
| 9 · Second engine | The suite is green on all of them | Adding engines before the suite is stable on one |
| 10 · API layer | Rules are proved in milliseconds | Mocking so much that nothing real is tested |
A review checklist for a new test
- Does the title state a requirement a non-engineer would recognise, and carry a tag?
- Is every literal a named fixture, a rule, or a builder call?
- Does it assert what happened and what must not have happened?
- Would it still pass if the rule it names were deleted? It must not.
- Are all the promises awaited, and is there no branch in the body?
- Does it navigate by clicking rather than reloading mid-test?
- Is any new locator a test id or a role — and scoped to its screen?
- If it found a product defect, is the defect raised rather than worked around?
Every code sample in this book is copied from the repository it documents, not paraphrased. If a sample and the code disagree, the code is right and this book has a bug.