P0 · Chapter 13 / 15
Coding patterns / Recipes
The 60-second cheat sheet#
These recipes use hypothetical sandbox endpoints and UI contracts. They are documentation, not an included banking backend. Complete tests import test, expect from @playwright/test; scoped fragments explicitly name injected variables. A configured baseURL, approved authentication, owned data and actual application are required. See framework config for runner setup.
01 · Semantic UI test#
Problem: verify the business result after saving a setting.
import { test, expect } from '@playwright/test';
test('settings save is confirmed', async ({ page }) => {
await page.goto('/settings');
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect(page.getByTestId('save-status')).toHaveText('Saved');
});
Why: locator assertion rereads until the expected value. Trap: click actionability is not backend completion. Deep chapter.
02 · Response before trigger#
Problem: capture even a very fast save response.
// Scoped test body with page and expect.
const responsePromise = page.waitForResponse(r =>
new URL(r.url()).pathname === '/api/settings' && r.request().method() === 'PUT');
await page.getByRole('button', { name: 'Save', exact: true }).click();
const response = await responsePromise;
expect(response.status()).toBe(200);
Why: listener exists before the event. Trap: same-path concurrent requests may need correlation matching. Deep chapter.
03 · Independent concurrent reads#
Problem: overlap unrelated service reads without hiding HTTP errors.
// Scoped test body with request and expect.
const responses = await Promise.all([
request.get('/api/users'), request.get('/api/profiles'),
]);
for (const response of responses) expect(response.status()).toBe(200);
Why: both operations are independent I/O. Trap: rejects do not cancel siblings; HTTP 500 may resolve normally. Deep chapter.
04 · Dependent create then fetch#
Problem: read the actual newly created user rather than a hard-coded ID.
// Scoped test body; cleanup shown in recipe 05.
const created = await request.post('/test-support/users', { data: { name: 'Owned QA user' } });
expect(created.status()).toBe(201);
const user: { id: string } = await created.json(); // runtime guard: recipe 18
const fetched = await request.get(`/api/users/${encodeURIComponent(user.id)}`);
expect(fetched.status()).toBe(200);
expect(await fetched.json()).toMatchObject({ id: user.id });
Why: GET depends on returned identity. Trap: parsing/casting alone doesn't validate the schema; test must own and remove data. Deep chapter.
05 · Preserve failure and attempt cleanup#
Problem: cleanup must run after failed assertions without concealing the original error.
// Scoped test body: user.id already created/validated; request, testInfo, expect injected.
let failed = false;
try {
const read = await request.get(`/api/users/${encodeURIComponent(user.id)}`);
expect(read.status()).toBe(200);
} catch (error) { failed = true; throw error; }
finally {
try {
const deleted = await request.delete(`/test-support/users/${encodeURIComponent(user.id)}`);
expect(deleted.status()).toBe(204);
} catch (error) {
await testInfo.attach('cleanup-error', { body: String(error), contentType: 'text/plain' });
if (!failed) throw error;
}
}
Why: finally runs on assertion failure; secondary evidence is retained. Trap: interrupted create or unavailable cleanup service needs correlation recovery/TTL. Deep chapter.
06 · Test-scoped unique user fixture#
Problem: every mutable test needs its own owned account.
// fixtures/user.ts: complete module, hypothetical authorized provisioning API.
import { test as base, expect } from '@playwright/test';
import { randomUUID } from 'node:crypto';
export const test = base.extend<{ user: { id: string } }>({
user: async ({ request }, use) => {
const created = await request.post('/test-support/users', { data: { correlation: randomUUID() } });
expect(created.status()).toBe(201);
const user: { id: string } = await created.json();
try { await use(user); }
finally {
const deleted = await request.delete(`/test-support/users/${encodeURIComponent(user.id)}`);
expect(deleted.status()).toBe(204);
}
},
});
Why: lifecycle matches test mutation scope. Trap: add runtime body validation and preserve both teardown/test errors in production reporting. Deep chapter.
07 · Worker-scoped service context#
Problem: reusable worker resource cannot depend on test-scoped request.
// fixtures/service.ts: complete module.
import { test as base, request, type APIRequestContext } from '@playwright/test';
export const test = base.extend<{}, { service: APIRequestContext }>({
service: [async ({}, use) => {
const baseURL = process.env.BASE_URL;
if (!baseURL) throw new Error('BASE_URL required');
const api = await request.newContext({ baseURL });
try { await use(api); } finally { await api.dispose(); }
}, { scope: 'worker' }],
});
Why: own context lifetime explicitly. Trap: one context per worker isn't one globally or a unique user by itself. Deep chapter.
08 · Small page object#
Problem: repeat semantic verification interactions without exposing selectors.
import type { Page, Locator } from '@playwright/test';
export class VerificationPage {
readonly status: Locator;
constructor(private readonly page: Page) {
this.status = page.getByTestId('verification-status');
}
async open(id: string): Promise<void> {
await this.page.goto(`/verification/${encodeURIComponent(id)}`);
}
async submit(): Promise<void> {
await this.page.getByRole('button', { name: 'Verify', exact: true }).click();
}
}
Why: page owns mechanics; test asserts outcome. Trap: do not add DB cleanup and service policy to a giant page class. Deep chapter.
09 · HTTP 202 to business completion#
Problem: assert final status without sleeping or replaying POST.
// Scoped test body with isolated user, request and expect.
const path = `/api/users/${encodeURIComponent(user.id)}/verification`;
expect((await request.post(`${path}/start`)).status()).toBe(202);
let state = 'PENDING';
await expect.poll(async () => {
const response = await request.get(path);
expect(response.status()).toBe(200);
const body: { status: string } = await response.json();
state = body.status;
return state === 'VERIFIED' || state === 'REJECTED';
}, { timeout: 20_000, intervals: [250, 500, 1_000] }).toBe(true);
expect(state).toBe('VERIFIED');
Why: observe known terminal outcome. Trap: explicitly handle unknown/malformed statuses and ensure total test budget allows polling. Deep chapter.
10 · UI-only network mock#
Problem: render a hard-to-create rejection state deterministically.
// Scoped test body; mocked contract, not a real integration test.
await page.route('**/api/verification/qa-demo', route => route.fulfill({
status: 200, contentType: 'application/json',
body: JSON.stringify({ id: 'qa-demo', status: 'REJECTED', reason: 'Expired document' }),
}));
await page.goto('/verification/qa-demo');
await expect(page.getByRole('alert')).toContainText('Expired document');
Why: isolate frontend handling. Trap: APIRequestContext calls bypass this mock, and backend behavior remains unproven. Deep chapter.
11 · Real service plus UI counterpart#
Problem: prove the deployed read path and UI show the same owned record.
// Scoped test body: fixture supplies a sandbox user already VERIFIED.
const response = await request.get(`/api/users/${encodeURIComponent(user.id)}/verification`);
expect(response.status()).toBe(200);
expect(await response.json()).toMatchObject({ status: 'VERIFIED' });
await page.goto(`/verification/${encodeURIComponent(user.id)}`);
await expect(page.getByTestId('verification-status')).toHaveText('VERIFIED');
Why: no browser route overrides the real read. Trap: provisioning VERIFIED directly doesn't prove event processing; recipe 09 covers transitions. Deep chapter.
12 · Auth setup and dependent project#
Problem: avoid repeated UI login for compatible read-only tests.
// auth.setup.ts fragment: inside setup('authenticate', async ({page}) => {...}).
// Import mkdirSync from node:fs and expect from @playwright/test.
const email = process.env.QA_EMAIL, password = process.env.QA_PASSWORD;
if (!email || !password) throw new Error('QA credentials required');
await page.goto('/login');
await page.getByLabel('Email').fill(email);
await page.getByLabel('Password').fill(password);
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page).toHaveURL(/\/dashboard/);
mkdirSync('playwright/.auth', { recursive: true });
await page.context().storageState({ path: 'playwright/.auth/user.json' });
// Config fragment inside defineConfig; setup must not inherit saved state.
const projects = [
{ name: 'setup', testMatch: /auth\.setup\.ts/ },
{ name: 'chromium', testMatch: /.*\.spec\.ts/,
use: { storageState: 'playwright/.auth/user.json' }, dependencies: ['setup'] },
];
Why: explicit dependency authenticates before consumers. Trap: ignore auth files in Git and artifacts; allocate distinct accounts across shards. Complete modules.
13 · CI shards#
Problem: partition a suite into independent GitLab jobs.
# Job fragment; enclosing pipeline defines stage test and project dependencies.
playwright:
stage: test
image: node:22-bookworm
parallel: 4
script:
- npm ci
- npx playwright install --with-deps chromium
- npx playwright test --project=chromium --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL
allow_failure: false
Why: GitLab supplies 1-based shard identity. Trap: separate filesystems and shared server accounts need explicit handling; workers multiply concurrency. Full YAML.
14 · Trace and reports#
Problem: retain actionable CI failure evidence without opening a report server.
import { defineConfig } from '@playwright/test';
export default defineConfig({
retries: process.env.CI ? 1 : 0,
reporter: [['html', { open: 'never' }], ['junit', { outputFile: 'reports/junit.xml' }]],
use: { trace: 'on-first-retry', screenshot: 'only-on-failure', video: 'off' },
});
Why: reporters connect failures to steps and DOM/network evidence. Trap: first-retry tracing may not capture a first-attempt-only failure; artifacts can contain sensitive data. Deep chapter.
15 · Status, schema and negative assertions#
Problem: reject malformed input according to the real contract.
// Scoped test body; illustrative 422/error code contract.
const response = await request.post('/api/users', { data: { email: 'bad-address' } });
expect(response.status()).toBe(422);
expect(response.headers()['content-type']).toContain('application/json');
const body: unknown = await response.json();
expect(body).toEqual(expect.objectContaining({ code: 'INVALID_EMAIL' }));
Why: tests the intended rejection, not just non-2xx. Trap: ensure no resource/event was created when that is required. Deep chapter.
16 · Parameterized cases#
Problem: cover partitions with separate readable test results.
import { test, expect } from '@playwright/test';
for (const item of [
{ label: 'missing email', data: {}, code: 'EMAIL_REQUIRED' },
{ label: 'malformed email', data: { email: 'x' }, code: 'INVALID_EMAIL' },
]) {
test(`user validation: ${item.label}`, async ({ request }) => {
const response = await request.post('/api/users', { data: item.data });
expect(response.status()).toBe(422);
expect(await response.json()).toMatchObject({ code: item.code });
});
}
Why: test registration uses ordinary synchronous iteration, execution remains awaited. Trap: titles must be unique; don't register tests after asynchronous runtime discovery. Deep chapter.
17 · Unique data and correlation#
Problem: identify records across overlapping jobs and service logs.
import { randomUUID } from 'node:crypto';
export function buildUser() {
const correlation = randomUUID();
const namespace = `${process.env.CI_PIPELINE_ID ?? 'local'}-${process.env.CI_NODE_INDEX ?? '1'}`;
return { email: `qa-${correlation}@example.test`, name: `QA-${namespace}`, correlation };
}
Why: random identity plus meaningful scope helps isolation and diagnosis. Trap: uniqueness doesn't enforce ownership or guarantee cleanup. Deep chapter.
18 · Runtime DTO parsing#
Problem: network data must be checked before typed use.
export interface UserDTO { id: string; status: 'PENDING' | 'VERIFIED' }
export function parseUser(value: unknown): UserDTO {
if (!value || typeof value !== 'object' || !('id' in value) ||
typeof value.id !== 'string' || !value.id || !('status' in value) ||
(value.status !== 'PENDING' && value.status !== 'VERIFIED')) {
throw new Error('Invalid UserDTO');
}
return { id: value.id, status: value.status };
}
Why: narrows unknown with actual checks. Trap: this small contract excludes REJECTED deliberately; adapt to the real schema or optional validation library. Deep chapter.
19 · Success and transport fault handling#
Problem: distinguish a network exception from a returned error response.
// Scoped test body; catch rethrows with cause instead of swallowing failure.
const response = await request.get('/api/health').catch((cause: unknown) => {
throw new Error('Health request transport failed', { cause });
});
await expect(response).toBeOK(); // broad success is appropriate for this health contract
expect(await response.json()).toMatchObject({ status: 'UP' });
Why: preserves failure provenance. Trap: toBeOK accepts all 2xx; use exact status where required. Deep chapter.
20 · Contract-supported payment idempotency#
Problem: prove safe deduplication in a controlled financial sandbox.
// Scoped test body: contract explicitly supports this header, account and sandbox.
// Import randomUUID from node:crypto. sandboxAccount is an isolated funded fixture.
const key = randomUUID();
const data = { accountId: sandboxAccount.id, amount: '10.00', currency: 'GEL' };
const options = { headers: { 'Idempotency-Key': key }, data };
const first = await request.post('/sandbox/payments', options);
expect(first.status()).toBe(201);
const initial: { id: string } = await first.json();
const replay = await request.post('/sandbox/payments', options);
expect(replay.status()).toBe(200); // illustrative replay contract, not universal
expect(await replay.json()).toMatchObject({ id: initial.id });
const ledger = await request.get(`/sandbox/payments/${initial.id}/ledger`);
expect(ledger.status()).toBe(200);
expect(await ledger.json()).toMatchObject({ debitCount: 1 });
Why: checks same operation identity and one effect. Trap: on lost response reconcile by supported key/correlation lookup before replay; no header guarantees dedupe without backend enforcement. No production financial effects. Deep chapter.
Interview response#
30 seconds: “I can combine these patterns into an isolated test: provision data, call or interact, synchronize on the real contract, assert business meaning and retain cleanup evidence. I adapt fictional endpoints to actual OpenAPI and DOM rather than copy them blindly.”
90-second senior answer: explain a recipe in review
I first identify the requirement and the boundary this recipe proves. I name prerequisites such as baseURL, authenticated sandbox account and an actual endpoint/schema. Then I show the dependency order and explain why a locator assertion, listener or poll is the right synchronization method. I cover status and domain outcome separately, and make fixture ownership/cleanup explicit. I discuss what the example cannot prove: a route mock is not backend integration, a saved state file is not a valid forever session, and an idempotency header is not magic. I finish by identifying failure evidence and the negative case that would demonstrate the test catches a defect.
Memory hook: problem → contract → code → evidence → traps.
Quick practice · reveal answers#
What does async return?
- Always a Promise, including Promise<void>.
- A thrown error becomes rejection; await obtains fulfillment or throws.
Pitfall: Assuming an async function returns its bare value synchronously.
Why can response.status fail after request.get?
- request.get returns Promise<APIResponse>.
- Await it first; status() is synchronous and json() must be awaited.
Pitfall: Confusing response, Promise and parsed body.
When should you use Promise.all?
- Independent operations that can safely overlap.
- Results follow input order; assert each HTTP response.
Pitfall: Concurrent dependent POST and GET.
Does Promise.all cancel other requests?
- No.
- It rejects on a rejected input but remaining operations continue; partial side effects may exist.
Pitfall: Treating rejection as transaction rollback.
Official references and related topics#
Optional online further reading; all core study material is included above.