BMad TEA — Knowledge Base
Comprehensive reference for the BMad TEA (Test Architect Enterprise) knowledge system. Covers 37 of 51 knowledge files across the first two tiers — Core & Extended — with enough depth to understand concepts, apply them in practice, and use them as training material. Tier 3 Specialized will be added next.
§1 — Utilities & Infrastructure
Files 01 – 04What it is: @seontechnologies/playwright-utils is the shared toolbox used across all TEA-managed test suites. It wraps common testing operations — HTTP calls, authentication, polling, file handling, network monitoring — into single-import utilities so teams stop re-solving the same problems project to project.
Design philosophy: Functional Core, Fixture Shell. Each utility is a pure TypeScript function at its core, then wrapped in a Playwright fixture for clean dependency injection. Full TypeScript support with typed return values throughout.
The 9 Core Utilities
Importing utilities — pattern
// Each utility has its own fixture subpath export import { test as apiTest } from '@seontechnologies/playwright-utils/api-request/fixtures'; import { test as authTest } from '@seontechnologies/playwright-utils/auth-session/fixtures'; import { mergeTests } from '@playwright/test'; // Merge all into one test object (see fixture-architecture.md) export const test = mergeTests(apiTest, authTest);
Quick-reference: before vs after
| Task | Without playwright-utils | With playwright-utils |
|---|---|---|
| Download & parse CSV | ~80 lines: handle download event, save file, wait for fs, parse, error-handle | 2 lines: handleDownload + readCSV |
| Login in every test | UI form fill in each test (~10 s each) | Token cached on first run; reused in < 1 ms |
| Wait for background job | waitForTimeout(10000) or custom loop | recurse(() => checkJob(), r => r.status === 'done') |
| Detect silent API errors | Manually assert every response status | Import network-error-monitor fixture — automatic |
npm install @seontechnologies/playwright-utils. All utilities are tree-shakeable — only what you import is bundled.What it is: A pattern for organizing shared test setup code (fixtures) so that common needs — login state, clean databases, seed data, test users — are written once and reused everywhere without duplication.
Why it matters: Without this pattern, every test file duplicates the same login, setup, and teardown code. When the API changes, you update 50 files instead of 1. Fixtures are the solution — but only if structured correctly.
The Three-Layer Pattern
createUserViaApi(userData) that calls the REST endpoint and returns the created user ID.test() can inject it. Handles setup (use()) and teardown (code after await use()). Never contains test assertions.tests/support/merged-fixtures.ts) so every test file imports from one place. When the fixture changes, only the shared file changes.Full code example
// Layer 1: pure function (tests/support/factories/user.factory.ts) export async function createUserViaApi(data: Partial<User>) { const res = await fetch('/api/users', { method: 'POST', body: JSON.stringify({ ...defaults, ...data }) }); return res.json() as User; } // Layer 2: fixture wrapper (tests/support/fixtures/user.fixture.ts) import { test as base } from '@playwright/test'; export const test = base.extend<{ createUser: (data?: Partial<User>) => Promise<User> }>({ createUser: async ({}, use) => { const created: User[] = []; await use(async (data) => { const user = await createUserViaApi(data); created.push(user); // track for cleanup return user; }); // afterEach cleanup: delete all users created during the test for (const u of created) await deleteUser(u.id); } }); // Layer 3: merged export (tests/support/merged-fixtures.ts) import { mergeTests } from '@playwright/test'; import { test as userFixture } from './fixtures/user.fixture'; import { test as apiFixture } from '@seontechnologies/playwright-utils/api-request/fixtures'; export const test = mergeTests(userFixture, apiFixture); // one import for all tests export { expect } from '@playwright/test'; // In your test file (tests/users/create.spec.ts) import { test, expect } from '../support/merged-fixtures'; test('create user', async ({ createUser, apiRequest }) => { const user = await createUser({ role: 'admin' }); // user is automatically deleted after this test });
When to create a fixture vs inline code
| How many tests need it? | Action | Reason |
|---|---|---|
| 1 time | Write inline in the test | Not worth the abstraction overhead |
| 2–3 times | Extract to a utility function | Avoid duplication, not ready for full fixture pattern |
| 3+ times | Create a proper fixture | Enables automatic cleanup, consistent setup, Playwright injection |
Recommended folder structure
tests/
├── support/
│ ├── merged-fixtures.ts ← Single import for all test files
│ ├── fixtures/
│ │ ├── user.fixture.ts
│ │ └── auth.fixture.ts
│ └── factories/
│ ├── user.factory.ts ← Pure functions
│ └── order.factory.ts
└── features/
└── users/
└── create.spec.ts ← import { test } from '../../support/merged-fixtures'
What it is: A mandatory rule for UI test reliability — always register network interceptors before navigating to a page or clicking a trigger. This is the single most common cause of intermittent test failures in Playwright.
Why it matters: When a page loads, it immediately fires API requests. If your test navigates first and then registers a listener, those early requests are already gone — your listener will never fire, the Promise will never resolve, and the test will hang until timeout.
The correct sequence (always)
// ✅ CORRECT: register listener FIRST, then trigger the navigation const usersCall = interceptNetworkCall({ url: '**/api/users' }); // step 1 await page.goto('/dashboard'); // step 2 const { responseJson, status } = await usersCall; // step 3: resolve expect(status).toBe(200); // ❌ WRONG: navigating first means the request fires before the listener exists await page.goto('/dashboard'); // request already fired! const usersCall = interceptNetworkCall({ url: '**/api/users' }); // too late
Two modes: Spy vs Stub
| Mode | What it does | When to use | Code |
|---|---|---|---|
| Spy (observe) | Lets the real request through; captures request and response for assertion | Verifying that the UI calls the right API with the right payload | interceptNetworkCall({ url }) |
| Stub (mock) | Intercepts the request and returns a fake response; server never receives it | Testing error states (500, 404, slow responses) without server cooperation | interceptNetworkCall({ url, fulfillResponse: { status: 500 } }) |
Stub example — testing error handling
// Force a 500 error and verify the UI shows an error message const failedCall = interceptNetworkCall({ url: '**/api/orders', fulfillResponse: { status: 500, body: { error: 'Internal Server Error' } } }); await page.goto('/orders'); await failedCall; await expect(page.getByRole('alert')).toContainText('Something went wrong');
Waiting rules — mandatory
- NeverHard-coded timeouts:
waitForTimeout(5000),sleep(3000),cy.wait(2000)— these make tests slow and still flaky on overloaded CI machines. - Never
networkidlein SPAs: Single-Page Apps with background polling never reach "idle" — this wait will always time out. - AlwaysWait for a specific API response:
page.waitForResponse('**/api/resource')— resolves the moment the named endpoint responds. - AlwaysWait for an element state:
locator.waitFor({ state: 'visible' })— resolves when the element reaches the expected state. - AlwaysWait for navigation:
page.waitForURL('/expected-path')— resolves when the URL matches after a navigation action.
HAR Recording — offline testing
HAR (HTTP Archive) recording captures every network request/response to a file. This enables two valuable workflows:
| Mode | Env var | What happens | Use case |
|---|---|---|---|
| Record | PW_NET_MODE=record | All requests hit the real server; traffic saved to HAR file | Initial capture; update after API changes |
| Playback | PW_NET_MODE=playback | Requests are served from HAR; no server needed | CI without a live server; deterministic regression tests |
network-recorder utility tracks in-memory state during playback — so if a test creates a user, subsequent GETs return that user correctly. Native Playwright HAR does not support this.What it is: A pattern for generating test data automatically — unique, random values per test run — so tests can run in parallel without stepping on each other's data.
The problem it solves: If all tests use the static email test@example.com, two parallel tests will conflict: test A creates the user, test B fails because the user already exists. Factories produce unique data every run.
Basic factory pattern
import { faker } from '@faker-js/faker'; interface User { name: string; email: string; role: string; } // Factory function — generates unique data, allows specific overrides export function createUserData(overrides: Partial<User> = {}): User { return { name: faker.person.fullName(), // "Jane Smith" — unique every run email: faker.internet.email(), // "abc123@example.net" — unique every run role: 'viewer', ...overrides // caller can pin specific values }; } // Usage: all unique, no conflicts in parallel const admin = createUserData({ role: 'admin' }); // random name+email, role=admin const viewer = createUserData(); // fully random const specific = createUserData({ email: 'fixed@test.com' }); // pin email when needed
Cleanup pattern — always clean up after each test
// In your fixture (inside the fixture wrapper from file-02) const createdUserIds: string[] = []; await use(async (overrides) => { const data = createUserData(overrides); const user = await apiRequest.post('/api/users', { body: data }); createdUserIds.push(user.id); // track every created entity return user; }); // afterEach: runs automatically, even if the test fails for (const id of createdUserIds) { await apiRequest.delete(`/api/users/${id}`); }
Performance: API setup vs UI setup
| Method | Speed | Use when |
|---|---|---|
| API (direct HTTP call) | ~50–200 ms | Creating test preconditions — always prefer this |
| UI (Playwright form fill) | ~2–10 s | Only when testing the creation flow itself |
A test suite with 100 tests, each requiring a pre-existing user: API setup = ~10 s total. UI setup = ~500 s total. Always use API factories for test data setup.
Factory rules
- RuleEvery factory function must use
fakerfor all string/email/name fields — never hardcode static values - RuleEvery created entity ID must be tracked and deleted in
afterEach— even if the test fails midway - RuleUse API calls (not UI) to create test data — 10–50× faster and does not depend on UI being functional
- NeverShare a single user/entity between multiple parallel tests — parallel tests read and modify the same data, causing race conditions
- NeverRely on the order of tests to build state — each test must set up its own data independently
§2 — Testing Strategy & Prioritization
Files 05 – 09What it is: Defines three test levels (Unit, Integration, E2E), when each is appropriate, the expected speed and scope of each, and the anti-patterns that arise when levels are misapplied. This is the foundational mental model for all test planning in TEA.
The Test Pyramid
When to use: Critical user paths that cross multiple systems (e.g. login → add to cart → checkout → confirmation email). No more than ~10–20% of your total test count.
Example: "User registers, verifies email, logs in, updates profile, logs out" — one E2E flow verifying the whole chain.
When to use: Testing data flows across layers (controller → service → database), API contract validation, authentication flows. ~20–30% of total test count.
Example: "POST /api/users creates a database record, returns 201, and the record can be retrieved via GET /api/users/:id."
When to use: Pure business logic — validation, calculations, transformations, data parsing. Should be the majority (~60–70%) of your total test count.
Example: "calculateDiscount(price, code) returns 90 when code is 'SAVE10' and price is 100."
Decision guide — which level to write
| Question | If YES → write at this level |
|---|---|
| Does it test a single function with no external deps? | Unit |
| Does it test multiple components interacting (DB, API, files)? | Integration |
| Does it test a complete user journey through the browser? | E2E |
| Are you testing business logic inside a controller/service? | Unit (extract logic; don't test via HTTP) |
| Are you testing "does clicking Submit call the API?" | Integration (use interceptNetworkCall) |
Anti-patterns
- AvoidE2E tests for business logic: Using Playwright to test a discount calculation costs 100× more time than a unit test and fails for UI reasons unrelated to the logic.
- AvoidUnit tests for framework behavior: Testing "does Express route a POST request?" is testing the framework, not your code. No value.
- AvoidInverted pyramid: More E2E than unit tests means very slow CI, very high maintenance, and very poor failure diagnosis. A failing E2E test tells you "something broke" — a failing unit test tells you exactly what.
- NoteAPI tests are a subset of Integration: Tests using
apiRequestwithout a browser sit at the integration level and are 10–100× faster than browser-based E2E tests for the same coverage.
What it is: Four priority levels (P0–P3) that map to minimum coverage requirements. Every feature/requirement in a project must be classified at one of these levels before the test plan is written. Priority is determined by business impact of failure — not technical complexity.
Priority Levels
Integration: >80%
E2E: all critical paths
Blocks release if failing
Integration: >60%
E2E: key flows
Smoke test only
No E2E required
Manual testing acceptable
No blocking requirement
Classification examples
| Feature | Priority | Reason |
|---|---|---|
| User login / authentication | P0 | No login = product unusable |
| Payment processing | P0 | Direct revenue loss on failure |
| JWT token validation / RBAC | P0 | Security breach if broken |
| Search functionality | P1 | Core product feature; users can browse manually |
| Email notification on sign-up | P1 | Expected behavior; degradation if missing |
| CSV export in admin panel | P2 | Admin-only; workarounds exist |
| Dark mode toggle | P3 | Cosmetic; no functional impact |
| Button hover color | P3 | Purely visual, rare user complaint |
How to apply priorities in a test plan
@p0, @p1, etc. This enables selective test execution (e.g., run only @p0 tests before a hotfix deployment).What it is: A structured method for scoring, categorizing, and tracking risks in a software system so that testing effort is concentrated where failure would hurt the most. Each risk item receives a numeric score that maps to a mandatory action tier.
Risk Score Formula
Risk Score = Probability (1–3) × Impact (1–3) = range: 1–9
Action tiers
| Score | Color | Tier | Required Action |
|---|---|---|---|
| 1–3 | Green | Low | Document in risk register. Review at end of sprint. |
| 4–5 | Yellow | Medium | Monitor. Add mitigation options to backlog. |
| 6–8 | Orange | High | Must have a documented mitigation plan before this feature ships. |
| 9 | Red | Critical | BLOCK — Deployment blocked until risk is resolved or formally accepted by stakeholders. |
Risk categories and examples
| Category | Code | Example risks |
|---|---|---|
| Technical | TECH | Third-party library with breaking changes; complex algorithm with no test coverage; legacy code with no documentation |
| Security | SEC | Unauthenticated endpoints; plain-text passwords in logs; missing CSRF protection; JWT stored in localStorage |
| Performance | PERF | N+1 query on user list page; unindexed column in frequent search; no CDN for static assets |
| Data | DATA | No database backup strategy; migration without rollback; data shared between test and production environments |
| Business | BUS | Payment integration with no fallback; single point of failure in checkout flow; feature flag with no off switch |
| Operations | OPS | No monitoring on critical endpoints; deployments require manual steps; no on-call runbook |
How to run a risk assessment
Risk register format
# Risk Register — Sprint 12
| ID | Category | Description | P | I | Score | Action | Mitigation |
|-------|----------|-------------------------------|-----|-----|-------|--------------|-----------------------------------|
| R-001 | SEC | JWT stored in localStorage | 3 | 3 | 9 | BLOCK | Move to httpOnly cookie |
| R-002 | PERF | N+1 on /api/orders list | 2 | 2 | 4 | Monitor | Add eager loading, add perf test |
| R-003 | DATA | No migration rollback script | 2 | 3 | 6 | Mitigate | Write down migration before ship |
bmad-testarch-trace skill enforces this mapping automatically.What it is: A 29-criterion checklist across 8 categories used to assess whether a system is ready to be approved for production release from a quality and testability perspective. This is the gate evaluation model used by the bmad-testarch-nfr skill.
How it works: TEA reads the architecture documents, ADRs, and available evidence (test results, monitoring dashboards, deployment runbooks), then scores each criterion as met / partial / missing. The final score determines PASS / CONCERNS / FAIL.
All 8 categories and their criteria
- Components can be tested in isolation
- Test environment is separated from production
- External dependencies can be mocked or stubbed
- Test data can be provisioned programmatically
- Test data is isolated from production data
- Data factories exist for all major entities
- No shared mutable state between parallel test workers
- NFR thresholds are defined (latency, throughput, error rate)
- Load testing has been run at projected peak volume
- Auto-scaling rules are defined and tested
- Database connection pooling is configured correctly
- RTO and RPO targets are defined and documented
- Backup restore procedure has been tested
- Rollback procedure exists and has been dry-run
- Authentication mechanism is implemented and tested
- Authorization (RBAC/ABAC) is enforced at all endpoints
- Sensitive data is never logged or returned in error responses
- OWASP Top 10 risks have been addressed
- Structured logging is in place on all services
- Metrics (latency, error rate, throughput) are being collected
- Distributed tracing is implemented (request IDs propagated)
- Alerts are configured for P0 error conditions
- Latency budgets are defined per endpoint
- Rate limiting is implemented and tested
- Circuit breaker or retry logic handles downstream failures
- SLA/SLO targets are documented
- Zero-downtime deployment is supported
- Feature flags exist for risky changes (can be turned off post-deploy)
- Deployment pipeline includes automated smoke tests
Scoring and gate outcomes
| Score | Percentage | Gate Decision | What it means |
|---|---|---|---|
| ≥ 24 / 29 | ≥ 83% | PASS | System meets the quality bar. Release can proceed. |
| 15–23 / 29 | 52–79% | CONCERNS | Missing items must be reviewed. Release requires explicit sign-off on each gap. |
| < 15 / 29 | < 52% | FAIL | Too many gaps. Address deficiencies before re-assessment. |
What it is: Detailed definitions for the Probability (P) and Impact (I) axes used in the Risk Score formula from risk-governance.md. Without explicit definitions, two team members scoring the same risk often choose different values. This file eliminates that ambiguity.
Probability Scale (P)
| Value | Label | Definition | Example |
|---|---|---|---|
| P1 = 1 | Unlikely | Occurs rarely, less than once per quarter under normal conditions | Hash collision in ID generation; hardware failure in redundant cluster |
| P2 = 2 | Possible | Occurs occasionally, roughly monthly under normal load | Third-party service downtime; edge case in user input validation |
| P3 = 3 | Likely | Occurs regularly — weekly or with any meaningful load increase | Race condition in high-concurrency path; timeout on slow external API |
Impact Scale (I)
| Value | Label | Definition | Example |
|---|---|---|---|
| I1 = 1 | Minor | Cosmetic or invisible to most users; no data loss, no revenue impact | Wrong color on error message; incorrect timezone in log entry |
| I2 = 2 | Degraded | A feature is impaired or unavailable; system is still running; workaround exists | CSV export produces wrong column order; search returns incomplete results |
| I3 = 3 | Severe | System down, data loss, security breach, or direct financial loss | Users cannot log in; payment charges incorrect amount; personal data exposed |
Full 3×3 Score Matrix
Low / Document
Low / Document
Medium / Monitor
Low / Document
Medium / Monitor
High · Must mitigate
Medium / Monitor
High · Must mitigate
BLOCK release
Common real-world score examples
| Scenario | P | I | Score | Action |
|---|---|---|---|---|
| Webhook delivery failure (event-driven system) | 2 | 3 | 6 | Mandatory test coverage |
| Payment gateway timeout with no retry | 2 | 3 | 6 | Mandatory retry + alert |
| JWT not validated on admin endpoints | 3 | 3 | 9 | BLOCK — fix before ship |
| Wrong label on a form field | 2 | 1 | 2 | Log it, fix in next sprint |
| Search returns 0 results on empty DB | 1 | 2 | 2 | Document, low priority |
| CSV export missing one optional column | 2 | 2 | 4 | Monitor; add test coverage |
§3 — Test Resilience & Quality
Files 10 – 14What it is: Defines the specific criteria, thresholds, and tooling used to evaluate Non-Functional Requirements (NFRs) across four domains: Security, Performance, Reliability, and Maintainability. These criteria are what the bmad-testarch-nfr skill checks when performing an NFR evidence audit.
Important distinction: TEA does not run these tests. It evaluates the evidence that already exists (k6 reports, Playwright results, CI dashboards) against these criteria to produce a gate decision.
AuthN · AuthZ · Data Protection
- Expired or invalid JWT token is rejected with 401 — not silently accepted
- Role-based access: a user with role "viewer" cannot access admin endpoints
- Sensitive fields (passwords, tokens, PII) are never returned in API responses or logged
- CORS policy allows only whitelisted origins
- Brute-force protection: account locks or rate-limits after N failed login attempts
- HTTPS enforced — plain HTTP redirected or rejected
Threshold for PASS: All security test cases pass. Any failure = FAIL gate (no partial credit on security).
Latency · Throughput · Load
- P95 response time under projected peak load (typical threshold: < 200 ms for API, < 2 s for page load)
- Error rate under load remains below 0.1%
- No memory leaks under 30-min sustained traffic
- System gracefully degrades (returns 429 or queues) rather than crashing under excess load
- Cold start latency for serverless functions within SLA
Threshold for PASS: P95 latency within budget, error rate < 1% at peak load. Missing load test evidence = CONCERNS.
Retry · Recovery · Consistency
- Automatic retry on transient network errors (not on 4xx client errors)
- Circuit breaker activates after N consecutive failures; recovers after cooldown
- Data consistency is preserved after a mid-transaction failure and rollback
- RTO (Recovery Time Objective) and RPO (Recovery Point Objective) targets are met in DR tests
- Idempotent endpoints return the same result on repeated identical requests
Threshold for PASS: All reliability test cases pass. Untested RTO/RPO = CONCERNS.
Coverage · Build Health · Test Quality
- Code coverage meets P-level thresholds (P0: ≥90% unit; P1: ≥80% unit)
- No permanently skipped tests (
.skipwith no ticket reference) - CI build time within acceptable budget (typical: < 15 min for full suite)
- Flaky test rate below threshold (typical: < 1% of test runs)
- All test files conform to the 8-point quality checklist (see file 13)
Threshold for PASS: Coverage thresholds met, flaky rate < 1%, no orphaned skips.
NFR gate outcomes
What it is: Defines which HTML element selection strategies to use in UI tests, ranked from most to least resilient. Poor selector choices are the #1 cause of tests breaking after a UI redesign even when the underlying functionality is unchanged.
Priority hierarchy (use #1 whenever possible)
-
page.getByTestId('submit-btn')Best: Uses
data-testidattribute — added by developers specifically for tests. Does not change when the UI is redesigned, text is translated, or CSS classes are renamed. Requires team agreement to adddata-testidto meaningful interactive elements. -
page.getByRole('button', { name: 'Submit' })Best: ARIA semantic selector — also tests accessibility at the same time. Stable across visual redesigns. Reflects how assistive technology sees the page.
-
page.getByLabel('Email address')Good: Finds the input associated with a label. Stable until the label text changes. Also validates correct label/input association.
-
page.getByText('Submit')Acceptable: Text-based selection. Fragile if the text is internationalised or reworded. Use regex (
/Submit|Enviar/) for multilingual apps. -
.submit-btn · #mainSubmit · form button:nth-of-type(2)Avoid: CSS classes are implementation details that change with every refactor. Positional selectors (
nth) break when the DOM order changes. Never acceptable for tests that need to remain stable. -
//button[@class='btn btn-primary']Never: XPath is verbose, brittle, and incompatible with Playwright's recommended locator patterns. The Playwright team explicitly discourages XPath use.
Filtering from a list — filter() not nth()
// ❌ BAD: positional — breaks when list order changes await page.locator('.user-row').nth(2).click(); // ✅ GOOD: semantic filter — finds by content, not position await page.locator('.user-row') .filter({ hasText: 'Jane Smith' }) .getByRole('button', { name: 'Edit' }) .click();
Migration guide — before and after
| Before (brittle) | After (resilient) | Why |
|---|---|---|
$('.btn-submit') | getByRole('button', {name:'Submit'}) | CSS class removed in redesign |
$('#login-form input')[0] | getByLabel('Email') | Order of inputs may change |
getByText('登録する') | getByTestId('register-btn') | Text changes with i18n |
//div[@id='modal']//button | getByRole('dialog').getByRole('button', {name:'Confirm'}) | XPath breaks on any DOM change |
What it is: Catalogs the most common timing mistakes in UI and API testing, provides the correct event-based replacements, and documents the debugging tools available when a test produces intermittent failures on CI.
Why it matters: Timing issues cause tests that "work on my machine" but fail 30% of the time on CI. The root cause is almost always a hard-coded wait that is too short on a slow machine, or a missing wait that relies on implicit browser timing.
Complete timing anti-patterns table
| Anti-pattern | Why it's wrong | Correct replacement |
|---|---|---|
waitForTimeout(5000) | Adds 5 s to every test run. Still fails on overloaded CI. Hides the real timing issue. | waitForResponse('**/api/...') |
cy.wait(3000) | Same problem in Cypress | cy.wait('@apiAlias') |
sleep(2000) | Blocking wait — no intelligence about actual system state | recurse(() => check(), pred) |
networkidle in SPA | SPAs with polling never reach "idle" — this wait always times out | Wait for the specific route or element that confirms readiness |
page.waitForTimeout before assertion | "Give it time to load" — lazy workaround for a missing await | await expect(locator).toBeVisible() (has built-in retry) |
Event-based waiting patterns
// Pattern 1: Wait for a specific API response after an action const savePromise = page.waitForResponse('**/api/users/**'); await page.getByRole('button', { name: 'Save' }).click(); const response = await savePromise; expect(response.status()).toBe(200); // Pattern 2: Wait for element to reach expected state await page.getByText('Changes saved').waitFor({ state: 'visible' }); // Pattern 3: Wait for navigation after form submit await Promise.all([ page.waitForURL('/dashboard'), page.getByRole('button', { name: 'Login' }).click() ]); // Pattern 4: Poll for async background job completion await recurse( () => apiRequest({ method: 'GET', path: `/jobs/${jobId}` }), (res) => res.body.status === 'completed', { timeout: 60_000, interval: 2_000 } );
Debugging workflow for flaky tests
npx playwright show-trace trace.zip to see the exact sequence of actions, DOM snapshots, and network requests at the point of failure.waitForResponse.log.step() calls around each major action to narrow down where the test blocks. Check the HTML report to see which step was the last to complete before the failure.page.pause() to stop test execution and open the Playwright Inspector for interactive debugging. Inspect element state, run locator queries, and check network requests in real time.waitFor on the first element that confirms the server is ready rather than a time-based wait.{ timeout: 30000 } to a slow test hides the real problem and makes the entire suite slower. Find and fix the missing event-based wait instead.What it is: An 8-point checklist that defines what a production-quality test looks like. Used by the bmad-testarch-review skill when auditing an existing test suite. A test must pass all 8 criteria to be considered acceptable for production.
The 8 quality criteria — with examples
-
No hard-coded waits — Zero
waitForTimeout,sleep, orcy.wait(N)calls. All waits are event-based.
Bad:await page.waitForTimeout(3000)· Good:await page.waitForResponse('**/api/users') -
No conditional logic inside the test — No
if/else,try/catch, or ternary expressions in the test body. Each test must have exactly one deterministic execution path.
Bad:if (await button.isVisible()) { await button.click() } - Test body under 300 lines — If a test is longer than 300 lines, shared logic must be extracted into fixtures or utility functions. Long tests are hard to read and impossible to maintain.
- Completes within 1.5 minutes — Including setup, execution, and teardown. If a test consistently takes longer, it should be split into focused smaller tests or moved to a nightly suite.
-
Self-cleaning — The test deletes all data it creates, regardless of whether it passes or fails. Cleanup runs in
afterEach(notafterAll) to prevent data leaking to the next test. -
Specific, unambiguous assertions — Assertions check exact values, not just truthy/falsy states. No
toBeTruthy(), notoBeGreaterThan(0)when an exact value is known.
Bad:expect(user).toBeTruthy()· Good:expect(user.id).toBe('usr_123') -
Unique test data every run — All strings, emails, names, and IDs used in the test are generated with
fakerorcrypto.randomUUID(). Never hardcoded static values that would conflict with a parallel test run. - Parallel-safe — The test does not depend on other tests running before or after it. Does not modify shared state (global users, shared feature flags, hardcoded IDs). Can run in any order, at any time, on any worker.
How to score a test against these criteria
| Criteria met | Verdict | Action |
|---|---|---|
| 8 / 8 | PASS | No action required |
| 6–7 / 8 | CONCERNS | Fix within this sprint; test is conditionally acceptable |
| ≤ 5 / 8 | FAIL | Test must be refactored before merge |
What it is: Five structured repair patterns for the most common categories of broken or flaky tests. When a test starts failing, diagnose the failure type, then apply the matching pattern. Each pattern has a diagnostic symptom, root cause, and step-by-step fix.
How to use this file
test.fixme() and file a ticket.Pattern 1 — Stale Selector
| Detail | |
|---|---|
| Symptom | "Locator did not match any elements" · "Element not found" · "getByTestId('x') resolved to 0 elements" |
| Root cause | A UI change renamed or removed the element's data-testid, changed its ARIA role, or restructured the DOM |
| Fix | Run playwright-cli snapshot to take a fresh snapshot of the page. Find the new selector for the target element. Update the test. If the element was removed intentionally, delete the test too. |
Pattern 2 — Race Condition
| Detail | |
|---|---|
| Symptom | Test passes on first run, fails 20–40% of subsequent runs with no code change. Failure is non-deterministic. |
| Root cause | An assertion fires before an async operation (API call, state update, animation) has completed |
| Fix | Find the missing waitForResponse() or waitFor({ state }). Add it between the trigger action and the assertion. Never add a time-based wait — find the specific event. |
// Before: assertion fires before API response arrives await page.getByRole('button', { name: 'Save' }).click(); await expect(page.getByText('Saved')).toBeVisible(); // ← flaky // After: wait for the API call that triggers the success message const saved = page.waitForResponse('**/api/profile'); await page.getByRole('button', { name: 'Save' }).click(); await saved; // ← deterministic await expect(page.getByText('Saved')).toBeVisible();
Pattern 3 — Dynamic Data Mismatch
| Detail | |
|---|---|
| Symptom | "Expected 'John Smith' but received 'Jane Doe'" — assertion comparing a hardcoded value to dynamically generated data |
| Root cause | Test uses a hardcoded string that was valid when the test was written but is no longer the actual value |
| Fix | Replace hardcoded values with the dynamically generated values from your factory, or use partial/regex matchers when the exact value is not what you're testing. |
// Bad: hardcoded value that depends on database state expect(response.body.name).toBe('John Smith'); // Good: assert against what your factory generated const userData = createUserData(); const user = await createUser(userData); expect(response.body.name).toBe(userData.name); // always matches
Pattern 4 — Network Timeout
| Detail | |
|---|---|
| Symptom | Random "Timeout 30000ms exceeded" or "net::ERR_CONNECTION_TIMED_OUT" — more frequent on CI than local |
| Root cause | External service or test environment has variable latency; no retry logic in place |
| Fix | Wrap the request in recurse() with sensible retry count and interval. Do not just increase the timeout — add intelligent retry. |
Pattern 5 — Hard Wait Replacement
| Detail | |
|---|---|
| Symptom | Test always passes, but is very slow (> 1 min) because it has waitForTimeout(10000) or similar |
| Root cause | Developer added a conservative time-based wait instead of identifying the actual event to wait for |
| Fix | Identify the event (API response, element state, URL change) that actually signals completion. Replace the hard wait with an event-based wait. Run 10 times to confirm the fix is stable. |
test.fixme('reason', { ticket: 'JIRA-123' }) and file a proper investigation ticket. Never leave a permanently flaky test running silently — it degrades trust in the entire suite.§4 — Contract Testing
File 15What it is: A testing approach where the interface contract between a consumer (the service that calls an API) and a provider (the service that hosts the API) is formally captured and verified. The contract is a living document — generated by the consumer and verified by the provider, enabling both sides to evolve independently without breaking each other.
Primary tooling: Pact.js (contract generation and verification) + PactFlow (contract broker — stores contracts, tracks verification status). The can-i-deploy CLI checks whether both parties have verified the current contract before allowing a deployment to proceed.
How contract testing flows
can-i-deploy acts as a gate — before either party deploys, the CLI queries PactFlow. If the latest contract has not been verified, deployment is blocked.Consumer-side test: defining the contract
import { PactV3, MatchersV3 } from '@pact-foundation/pact'; const { like, eachLike, regex } = MatchersV3; const provider = new PactV3({ consumer: 'user-management-ui', provider: 'user-api', dir: './pacts', }); it('returns a user by ID', () => provider .given('a user with ID usr_123 exists') .uponReceiving('a GET request for usr_123') .withRequest({ method: 'GET', path: '/api/users/usr_123' }) .willRespondWith({ status: 200, body: { id: like('usr_123'), // any string is OK email: regex('.+@.+\\..+', 'u@e.com'), roles: eachLike('viewer'), // array, at least 1 item } }) .executeTest(async (mockServer) => { const user = await new UserApiClient(mockServer.url).getUser('usr_123'); expect(user.id).toBeDefined(); }) );
Provider-side verification
import { Verifier } from '@pact-foundation/pact'; it('validates the contract from PactFlow', () => new Verifier({ providerBaseUrl: 'http://localhost:8080', pactBrokerUrl: process.env.PACTFLOW_URL, pactBrokerToken: process.env.PACTFLOW_TOKEN, provider: 'user-api', publishVerificationResult: true, providerVersion: process.env.GIT_SHA, stateHandlers: { 'a user with ID usr_123 exists': async () => { await db.createUser({ id: 'usr_123', email: 't@e.com', roles: ['viewer'] }); } } }).verifyProvider() );
can-i-deploy gate in CI
# In the consumer's deploy pipeline pact-broker can-i-deploy \ --pacticipant user-management-ui \ --version $GIT_SHA \ --to-environment production \ --broker-base-url $PACTFLOW_URL # Exit 0 = proceed · Exit 1 = blocked (verification missing or failed)
Postel's Law — the asymmetry rule
| Side | Guidance | In practice |
|---|---|---|
| Request (send) | Be conservative — send only what is needed, exactly as specified | Validate required fields strictly; no extra unknown fields |
| Response (accept) | Be liberal — accept anything that satisfies your minimum needs | Use like() and regex() matchers; ignore extra fields you don't use |
When to use contract testing
| Scenario | Use? | Reasoning |
|---|---|---|
| Two internal services in the same org | Yes | Both sides in PactFlow; feedback is instant when either changes |
| Your service calls a third-party API | Consumer side only | The provider will not run verification — record mocks instead |
| Microservices replacing a monolith | Yes — priority | Most valuable during migration when interfaces change frequently |
| Module-to-module calls within one service | No | Unit tests with real function calls are faster and more direct |
Summary Map — All 15 Core Files
Quick reference| # | File | Section | One-line purpose | Key output |
|---|---|---|---|---|
| 01 | playwright-utils.md | §1 | 9 shared utilities — never build these from scratch | Import path, utility names |
| 02 | fixture-architecture.md | §1 | 3-layer fixture pattern; mergeTests() for composition | Pure function → fixture → export |
| 03 | network-patterns.md | §1 | Network interception: spy vs stub, HAR record/replay | Correct waitForResponse usage |
| 04 | data-factories.md | §1 | Faker-based factories + cleanup tracking; API > UI setup | Factory pattern + afterEach cleanup |
| 05 | test-levels.md | §2 | Unit / Integration / E2E — what each level covers | Test pyramid + decision guide |
| 06 | test-priorities.md | §2 | P0–P3 priorities with coverage thresholds per level | Priority classification + thresholds |
| 07 | risk-governance.md | §2 | Risk = P × I; tiers 1–9; 6 categories; register format | Risk score → action tier |
| 08 | adr-quality-checklist.md | §2 | 29-point ADR readiness checklist; PASS ≥24/29 | Gate decision: PASS / CONCERNS / FAIL |
| 09 | risk-matrix.md | §2 | 3×3 P×I matrix with colour-coded tiers and examples | Score → mandatory / conditional test coverage |
| 10 | nfr-criteria.md | §3 | 4 NFR domains: Security / Perf / Reliability / Maintainability | Tool + threshold + PASS criteria per domain |
| 11 | selector-resilience.md | §3 | Selector priority: testid > role > label > text > CSS | Migration table, filter() pattern |
| 12 | timing-debugging.md | §3 | Anti-patterns → event-based waits; 5-step debug workflow | Correct waitForResponse / waitFor usage |
| 13 | test-quality.md | §3 | 8-point quality checklist; PASS requires all 8 | Checklist + scoring thresholds |
| 14 | test-healing-patterns.md | §3 | 5 patterns for fixing broken/flaky tests; 3-strike rule | Symptom → root cause → fix procedure |
| 15 | contract-testing.md | §4 | Pact.js + PactFlow; consumer contract → provider verification | Consumer test, provider verify, can-i-deploy gate |
Extended Tier — 22 Files
Loaded on demand when the context requires it. Covers deep-dive implementations of each playwright-utils utility, advanced testing patterns, CI execution strategies, feature flag governance, resilience patterns, email authentication, and webhook testing infrastructure.
§5 — Authentication & Core Utilities
Files 16 – 24What it is: A utility that persists authentication tokens to disk and reuses them across test runs and parallel workers — no re-authentication before every test. Supports multiple simultaneous user identities, ephemeral (one-time) auth, and worker-specific accounts for fully parallel execution.
Why it matters: Playwright's built-in authentication re-authenticates on every run, only supports a single user, and does not handle token expiration. With auth-session, a token is acquired once and reused everywhere — login overhead drops from ~10 s per test to <1 ms.
Global setup lifecycle — 4 calls in order
| Function | When to call | Purpose |
|---|---|---|
authStorageInit() | globalSetup — first | Create the token storage directory on disk |
configureAuthSession() | globalSetup | Set base URL and token configuration options |
setAuthProvider() | globalSetup | Register the custom AuthProvider implementation |
authGlobalInit() | globalSetup — last | Trigger initial token acquisition and write to disk |
Custom AuthProvider interface — 5 methods
// Implement all 5 methods to plug in any authentication system interface AuthProvider { getEnvironment(): string; // 'local' | 'staging' | 'production' getUserIdentifier(): string; // unique cache key per user role extractToken(response): string; // parse token from login response isTokenExpired(token): boolean; // check if cached token is still valid manageAuthToken(): Promise<string>; // acquire or refresh token }
Authentication modes
isTokenExpired() returns true — then auto-refresh.authOptions.userIdentifier per describe block or test. Each identifier gets its own cached token — admin and viewer tests run in the same suite without interference.userIdentifier (e.g. worker-0, worker-1). Prevents token collision when multiple tests modify user state simultaneously.applyUserCookiesToBrowserContext() injects auth directly into a browser context without writing to disk. Used for temporary or single-use auth states.API-only auth — no browser required
// Return the raw token string — auth-session handles caching automatically async manageAuthToken(): Promise<string> { const res = await fetch(`${baseUrl}/api/auth/login`, { method: 'POST', body: JSON.stringify({ email, password }) }); const { token } = await res.json(); return token; // no browser, no cookies — pure API token flow } // Using a different user role in a specific test file test.use({ authOptions: { userIdentifier: 'admin-user' } }); test('admin sees delete button', async ({ page }) => { await page.goto('/users'); await expect(page.getByRole('button', { name: 'Delete' })).toBeVisible(); });
- AlwaysCall
authStorageInit()first in globalSetup — if the storage directory does not exist, the other functions fail silently or throw obscure errors. - AlwaysUse
userIdentifieroverrides for role-based tests — never share one token across tests that need different permission levels. - NeverHard-code tokens in test files — they expire, rotate, and get committed to version control where they become security liabilities.
- NeverCall logout actions in tests that use cached tokens — a logout invalidates the shared token for all subsequent tests in the run.
isTokenExpired() returns true, the utility calls manageAuthToken() to acquire a fresh token before the next test begins. No refresh logic is needed in individual tests.What it is: A typed HTTP client that wraps Playwright's request context with automatic JSON response parsing, schema validation (Zod, JSON Schema, OpenAPI), automatic retry on 5xx errors with exponential backoff, and a 4-tier URL resolution strategy. No browser required — ideal for pure API and service-layer tests.
Why it matters: Vanilla Playwright requires boilerplate for every API call: response.json(), manual status checking, custom retry loops, zero type safety. apiRequest replaces all of that with a single typed call and catches contract drift through schema validation.
Basic usage — request and destructure
// Response body is always pre-parsed JSON — no await response.json() needed const { status, body } = await apiRequest({ method: 'POST', path: '/api/users', body: { name: 'Jane Smith', role: 'admin' } }); expect(status).toBe(201); expect(body.id).toBeDefined();
URL resolution — 4-tier priority (highest to lowest)
| Priority | Source | When used |
|---|---|---|
| 1 | baseUrl field in the call itself | Per-call override — e.g. call a different microservice |
| 2 | configureApiRequest({ baseURL }) global config | Default for the entire project, set once in globalSetup |
| 3 | Playwright config use.baseURL | Inherited automatically from playwright.config.ts |
| 4 | Path used as full URL | When path starts with http:// or https:// |
Schema validation — Zod, JSON Schema, OpenAPI
import { z } from 'zod'; const UserSchema = z.object({ id: z.string(), email: z.string().email(), roles: z.array(z.string()) }); // Validation throws immediately if response shape doesn't match the schema // Catches contract drift the moment a field is renamed or removed const { body } = await apiRequest({ method: 'GET', path: '/api/users/usr_001', validateSchema: UserSchema // Zod schema, JSON Schema object, or YAML OpenAPI path }); // body is now typed as { id: string; email: string; roles: string[] }
Retry behavior — automatic
| Response status | Behavior | Rationale |
|---|---|---|
| 5xx (server error) | Retry 3× with exponential backoff: 1 s → 2 s → 4 s | Transient server errors — likely to recover on retry |
| 4xx (client error) | Fail immediately — no retry | Client sent a bad request — retrying won't fix it |
| 2xx / 3xx | Return immediately | Success or redirect — no retry needed |
GraphQL — always check body.errors
// GraphQL always returns HTTP 200, even for errors — check body.errors not status const { body } = await apiRequest({ method: 'POST', path: '/graphql', body: { query: `query GetUser($id: ID!) { user(id: $id) { id email roles } }`, variables: { id: 'usr_001' } } }); expect(body.errors).toBeUndefined(); // ← mandatory GraphQL error check expect(body.data.user.email).toBeDefined();
Combine with recurse for polling
// Poll an async job until it completes — apiRequest + recurse combo await recurse( () => apiRequest({ method: 'GET', path: `/api/jobs/${jobId}` }), (res) => res.body.status === 'completed', { timeout: 60_000, interval: 2_000 } );
operation object generated by your OpenAPI code generator instead of manual method + path. The operation carries fully typed request/response types — eliminates path typos and keeps types synchronized with the spec automatically.- AlwaysAdd
validateSchemafor responses at P0/P1 API endpoints — catches breaking provider changes at the moment they happen, not when a UI test fails weeks later. - AlwaysCheck
body.errorsfor GraphQL responses — HTTP 200 does not mean success in GraphQL. - NeverUse
apiRequestfor WebSocket or Server-Sent Events — it is HTTP-only. Use Playwright'spage.on('websocket')for those.
What it is: A polling utility that repeatedly executes a command until a predicate returns true, with configurable timeout, interval, logging, and post-poll callbacks. Returns typed error shapes for precise diagnosis. Fills the gap in Playwright's native expect.poll: no built-in logging, generic timeout messages, no post-success hooks.
Primary use cases: Background job completion, webhook delivery, email arrival, database eventual consistency, cache propagation, state machine transitions — any scenario where you must wait for an asynchronous outcome without knowing when it will arrive.
Core signature
await recurse( command, // () => Promise<T> — executed on every retry predicate, // (result: T) => boolean | void — return truthy OR run assertions options? // RecurseOptions )
Options reference
| Option | Default | Purpose |
|---|---|---|
timeout | 30 000 ms | Max total wait before throwing RecurseTimeoutError |
interval | 1 000 ms | Delay between attempts |
log | true | Emit log entries per attempt in the HTML report |
post | — | Callback executed once after the predicate succeeds |
delay | 0 ms | Extra delay before the first execution |
error | — | Custom message appended to timeout error |
Three predicate styles
// Style 1: return truthy value await recurse( () => apiRequest({ method: 'GET', path: `/api/jobs/${jobId}` }), (res) => res.body.status === 'completed', { timeout: 60_000, interval: 2_000 } ); // Style 2: run assertions (no return needed — passes if assertions don't throw) await recurse( () => apiRequest({ method: 'GET', path: '/api/emails/latest' }), (res) => { expect(res.body.subject).toContain('Welcome'); expect(res.body.to).toBe('user@example.com'); } ); // Style 3: with post-poll callback after success await recurse( () => getOrderStatus(orderId), (status) => status === 'shipped', { post: (s) => log.success(`Order ${orderId} shipped`), timeout: 30_000 } );
Three typed error shapes
| Error type | When thrown | Key properties to inspect |
|---|---|---|
RecurseTimeoutError | Predicate never satisfied within timeout | attempts, lastResult, timeoutMs |
RecurseCommandError | The command itself threw an exception | cause (original error), attempt |
RecursePredicateError | The predicate threw an assertion error | cause, lastResult, attempt |
recurse with waitForTimeout. Adding a hard wait before or inside a polling loop forces every poll to wait the full duration even when the condition is met immediately. Use interval and delay options to control spacing instead.What it is: A single declarative call to intercept browser network requests — either observing real requests as they pass through (spy mode) or replacing them with a fabricated response (stub mode). Automatically parses JSON and returns a structured result with responseJson, status, and requestBody.
Why it matters: Vanilla Playwright requires three separate steps: page.route() for setup, page.waitForResponse() for capture, and manual response.json(). interceptNetworkCall collapses all three into one call and eliminates 60–70% of the boilerplate.
Mandatory setup order — intercept BEFORE navigate
const call = interceptNetworkCall({ url: '**/api/users' }) — this returns a Promise. The listener is now active and waiting for a matching request.await page.goto('/users') — the page fires the API request. The interceptor captures it.const { responseJson, status, requestBody } = await call — Promise resolves with structured data from the captured request and response.Spy mode vs Stub mode
// ✅ SPY — real request passes through; captured for assertion const usersCall = interceptNetworkCall({ url: '**/api/users' }); await page.goto('/dashboard'); const { responseJson, status } = await usersCall; expect(status).toBe(200); expect(responseJson).toHaveLength(3); // ✅ STUB — request intercepted; fake response returned; server never receives it const errorCall = interceptNetworkCall({ url: '**/api/orders', fulfillResponse: { status: 503, body: { error: 'Service Unavailable' } } }); await page.goto('/orders'); await errorCall; await expect(page.getByRole('alert')).toContainText('Something went wrong');
URL glob patterns
| Pattern | Matches |
|---|---|
**/api/users | Any URL ending in /api/users, any host |
**/api/{users,products} | Either /api/users or /api/products |
**/api/users?id=* | /api/users with any id query param |
https://api.example.com/** | All requests to a specific host |
Multiple intercepts in one action
// Set up ALL intercepts BEFORE triggering the action that fires them const [usersCall, rolesCall] = [ interceptNetworkCall({ url: '**/api/users' }), interceptNetworkCall({ url: '**/api/roles' }) ]; await page.goto('/admin'); // fires both requests const users = await usersCall; const roles = await rolesCall;
What it is: A utility that records network traffic to HAR (HTTP Archive) files during test runs, then replays those files offline — no live backend required. Unlike native Playwright HAR, it supports stateful CRUD during playback: a POST that creates a resource will appear in subsequent GET responses within the same test.
Why it matters: Traditional E2E tests require a live backend (slow, flaky, expensive). HAR-based testing provides deterministic, fast, offline tests — the same server responses every run, independent of network conditions or server state.
Two modes — one env variable
| Mode | Set PW_NET_MODE to | What happens | Use case |
|---|---|---|---|
| Record | record | All requests hit the real server; full traffic saved to a .har file on disk | Initial capture; update after backend changes |
| Playback | playback | All requests served from the HAR file; server is never contacted | CI without live server; deterministic regression tests |
Setup — always before page.goto()
// networkRecorder.setup(context) MUST be called before any navigation test('create and list users', async ({ page, context, networkRecorder }) => { await networkRecorder.setup(context); // ← must come first await page.goto('/users'); // record mode: real requests fired and persisted to HAR // playback mode: requests served from HAR, server never contacted });
Stateful CRUD during playback
The recorder analyzes the HAR file, detects CRUD patterns by HTTP method and URL shape, and maintains an in-memory state during playback. This means full CRUD flows work without a backend:
- NotePOST
/api/users— creates a user and adds it to the in-memory list. - NoteGET
/api/users(after the POST) — returns the list including the newly created user. - NoteDELETE
/api/users/id— removes the item so future GETs reflect the deletion. - DiffNative Playwright HAR returns static responses only — it does NOT track state changes between requests.
Cross-environment URL mapping
// HAR recorded on staging, replayed in CI against localhost await networkRecorder.setup(context, { hostMapping: { 'staging.api.example.com': 'localhost:3000' }, patterns: [ { regex: /staging\.api/, replace: 'localhost:3000' } ] });
interceptNetworkCall: Use the recorder for offline response serving and layer interceptNetworkCall on top for deterministic event-based waits. The recorder handles what responses are returned; the interceptor handles when to proceed.What it is: A fixture that automatically fails any test where a background HTTP request returns 4xx or 5xx — even when the UI appears to work correctly. Zero configuration: import the fixture and monitoring starts for every test in that file. Think of it as Sentry for your test suite.
Why it matters: Backend errors can occur silently while the UI renders a success state (cached data, optimistic updates, or incomplete error handling). Without this monitor, tests pass falsely and bugs reach production. With it, any unexpected API error surfaces immediately as a test failure.
Activation — import only
// Import the fixture — monitoring is ON for every test in this file import { test } from '@seontechnologies/playwright-utils/network-error-monitor/fixtures'; test('dashboard loads without errors', async ({ page }) => { await page.goto('/dashboard'); // If ANY background API call returns 4xx/5xx → test fails automatically await expect(page.getByText('Welcome')).toBeVisible(); });
Opt-out for intentional error scenarios
// Tests that deliberately trigger 4xx/5xx must opt out test('shows 404 message for missing resource', { annotation: [{ type: 'skipNetworkMonitoring' }] }, async ({ page }) => { await page.goto('/items/nonexistent-id'); await expect(page.getByText('Item not found')).toBeVisible(); });
Configuration options
| Option | Default | Purpose |
|---|---|---|
excludePatterns | [] | URL patterns (glob or regex) to ignore. Use for known legitimate 4xx endpoints — e.g. an identity verification service that returns 404 when the user is not found. |
maxTestsPerError | unlimited | Set to 1 to fail only the first test per unique error pattern, then emit warnings on subsequent tests. Prevents one broken endpoint from failing every test in the suite. |
Artifacts — network-errors.json
On failure the monitor writes a network-errors.json artifact alongside the HTML report. Each entry contains: url, status, method, timestamp.
maxTestsPerError: 1 when a broken backend endpoint would otherwise fail every test in the suite. The first test surfaces the root cause; subsequent tests warn instead of failing, keeping the report readable.What it is: A 6-level structured logging utility that surfaces output inside Playwright's HTML report as collapsible test steps — not just buried in the terminal. Automatically formats objects and arrays as indented JSON. Supports optional file-based logging for persistent logs across CI runs.
Why it matters: console.log() is invisible in HTML reports, creates no navigable steps, and disappears in terminal noise. Structured logging makes debugging visible exactly where it matters — inside the report, adjacent to the failing assertion.
6 log levels
| Level | Method | Use case | Report appearance |
|---|---|---|---|
| step | log.step() | Major test phases: ARRANGE / ACT / ASSERT | Collapsible section heading in HTML report |
| info | log.info() | General informational messages | Inline entry under current step |
| success | log.success() | Confirmation a key step completed | Green-tinted inline entry |
| warning | log.warning() | Non-fatal condition worth noting | Yellow-tinted inline entry |
| error | log.error() | Errors the test is deliberately catching | Red-tinted inline entry |
| debug | log.debug() | Verbose data: objects, arrays, API responses | Auto-formatted JSON block |
AAA pattern with structured logging
import { log } from '@seontechnologies/playwright-utils'; test('create user and verify', async ({ apiRequest }) => { await log.step('ARRANGE: Build test user data'); const userData = createUserData({ role: 'admin' }); await log.debug(userData); // auto-formats object as JSON in report await log.step('ACT: POST /api/users'); const { status, body } = await apiRequest({ method: 'POST', path: '/api/users', body: userData }); await log.step('ASSERT: Verify 201 and user ID'); expect(status).toBe(201); await log.success(`User created: ${body.id}`); });
Sync variants for globalSetup / globalTeardown
// Async test context is not available in globalSetup — use sync methods log.infoSync('Global setup: seeding database...'); log.successSync('Seed completed — 5 users, 3 orders'); // File logging for persistent artifacts across test runs log.configure({ fileLogging: { enabled: true, outputDir: './test-results/logs' } });
- AlwaysUse
log.step()before each major phase (ARRANGE / ACT / ASSERT) to create navigable sections in the HTML report. - AlwaysUse
log.debug(object)to inspect API responses or large data structures — it auto-formats as indented JSON. - NeverLog sensitive data — passwords, auth tokens, PII, credit card numbers. CI HTML reports may be publicly accessible or archived.
What it is: Utilities that simplify file download handling in Playwright tests — capturing the download event and reading the file's content from CSV, Excel/XLSX, PDF, or ZIP formats. Reduces ~80 lines of boilerplate (event orchestration, path resolution, parsing config, error handling) to 2–10 lines.
Why it matters: File download testing requires orchestrating the download event, waiting for the file to land on disk, then parsing it with the right library — all before writing a single assertion. These utilities automate the entire pipeline.
Capturing a download
import { handleDownload, readCSV } from '@seontechnologies/playwright-utils/file-utils'; // handleDownload orchestrates: register event → trigger click → wait → return path const filePath = await handleDownload({ page, downloadDir: './test-results/downloads', trigger: () => page.getByRole('button', { name: 'Export CSV' }).click() });
Supported formats
| Format | Function | Return shape | Notes |
|---|---|---|---|
| CSV | readCSV({ filePath }) | { data: object[], headers: string[] } | Auto-detects headers from first row |
| Excel / XLSX | readXLSX({ filePath }) | { sheets: { name, data }[] } | Multi-sheet support built-in |
readPDF({ filePath }) | { content: string, pagesCount, fileName, info } | Options: mergePages, maxPages | |
| ZIP | readZIP({ filePath }) | { entries: string[], extractedFiles: {} } | Entry listing + selective extraction |
Complete CSV export test
import * as fs from 'fs-extra'; const DOWNLOAD_DIR = './test-results/downloads'; test.afterEach(async () => fs.remove(DOWNLOAD_DIR)); // always clean up test('users CSV export contains correct headers and data', async ({ page }) => { await page.goto('/admin/users'); const filePath = await handleDownload({ page, downloadDir: DOWNLOAD_DIR, trigger: () => page.getByRole('button', { name: 'Export' }).click() }); const { data, headers } = await readCSV({ filePath }); expect(headers).toEqual(expect.arrayContaining(['email', 'role', 'createdAt'])); expect(data).toHaveLength(5); expect(data[0].email).toMatch(/.+@.+\..+/); });
afterEach. Downloaded files persist between test runs. Without cleanup, stale files from a previous run can produce false assertions or fill disk space on CI agents — especially on long-running pipelines.What it is: A pattern for combining fixtures from multiple sources — playwright-utils utilities and custom project-specific fixtures — into a single unified test object using Playwright's mergeTests(). Every test file imports from one place, updating a fixture propagates everywhere automatically.
Why it matters: Without composition, each test file must import from 4–6 different fixture modules. When a fixture's source path or API changes, every import across the project must be updated. The merged export is the single source of truth — change one file, all tests adapt.
Building the merged export
// tests/support/merged-fixtures.ts — the single import for ALL test files import { mergeTests } from '@playwright/test'; // playwright-utils fixtures import { test as apiFixture } from '@seontechnologies/playwright-utils/api-request/fixtures'; import { test as authFixture } from '@seontechnologies/playwright-utils/auth-session/fixtures'; import { test as logFixture } from '@seontechnologies/playwright-utils/log/fixtures'; import { test as monitorFixture } from '@seontechnologies/playwright-utils/network-error-monitor/fixtures'; // custom project fixtures import { test as userFixture } from './fixtures/user.fixture'; import { test as orderFixture } from './fixtures/order.fixture'; export const test = mergeTests( apiFixture, authFixture, logFixture, monitorFixture, userFixture, orderFixture ); export { expect } from '@playwright/test'; // In every test file — one clean import, all fixtures available // import { test, expect } from '../../support/merged-fixtures';
Name conflict rules
| Scenario | Rule | Recommended solution |
|---|---|---|
| Two fixtures share the same property name | Last fixture passed to mergeTests() wins | Prefix custom fixtures to avoid collision: myAppUser, myAppOrder |
| Override a utility option for one file | Use test.use() at the top of the file | test.use({ authOptions: { userIdentifier: 'admin' } }) |
| Override for one describe block | Use test.use() inside the describe | Scoped override — other tests in the file are unaffected |
Lazy evaluation — zero cost for unused fixtures
// A test that doesn't request networkRecorder pays NO setup cost for it test('fast API-only test', async ({ apiRequest, log }) => { // Only apiRequest and log fixtures are initialized — the rest are skipped const { status } = await apiRequest({ method: 'GET', path: '/api/health' }); expect(status).toBe(200); });
§6 — Testing Patterns
Files 25 – 28What it is: Patterns for direct API testing without browser overhead — REST CRUD, GraphQL, schema validation, error shape assertion, and async operation polling. API tests using apiRequest run 100× faster than E2E tests covering the same logic and produce more actionable failure messages because there is no DOM noise obscuring the root cause.
When to use over E2E: CRUD operations, business logic validation, error handling responses (4xx shapes), authentication flows, background jobs, service-to-service communication — any scenario where the browser is not the subject under test.
CRUD test scaffold
import { test, expect } from '../support/merged-fixtures'; import { createUserData } from '../support/factories/user.factory'; test.describe('Users API', () => { let userId: string; test.beforeAll(async ({ apiRequest }) => { const { body } = await apiRequest({ method: 'POST', path: '/api/users', body: createUserData() }); userId = body.id; }); test.afterAll(async ({ apiRequest }) => { await apiRequest({ method: 'DELETE', path: `/api/users/${userId}` }); }); test('GET returns created user', async ({ apiRequest }) => { const { status, body } = await apiRequest({ method: 'GET', path: `/api/users/${userId}`, validateSchema: UserSchema // Zod schema — fails if shape drifts }); expect(status).toBe(200); expect(body.id).toBe(userId); }); test('PATCH updates user role', async ({ apiRequest }) => { const { status, body } = await apiRequest({ method: 'PATCH', path: `/api/users/${userId}`, body: { role: 'admin' } }); expect(status).toBe(200); expect(body.role).toBe('admin'); }); });
Asserting error shape — not just status code
// Test that invalid input returns a structured, parseable error body test('POST rejects missing required fields', async ({ apiRequest }) => { const { status, body } = await apiRequest({ method: 'POST', path: '/api/users', body: { email: 'not-an-email' } // missing name, missing valid email }); expect(status).toBe(422); expect(body.errors).toEqual( expect.arrayContaining([ expect.objectContaining({ field: 'email' }), expect.objectContaining({ field: 'name' }) ]) ); });
Async job polling — apiRequest + recurse
// Trigger a long-running job, then poll until it completes test('bulk import job completes successfully', async ({ apiRequest }) => { const { body: job } = await apiRequest({ method: 'POST', path: '/api/jobs/bulk-import', body: { fileKey: 's3://bucket/users.csv' } }); expect(job.status).toBe('queued'); await recurse( () => apiRequest({ method: 'GET', path: `/api/jobs/${job.id}` }), (res) => res.body.status === 'completed', { timeout: 60_000, interval: 3_000 } ); const { body: result } = await apiRequest({ method: 'GET', path: `/api/jobs/${job.id}/results` }); expect(result.importedCount).toBe(100); expect(result.failedCount).toBe(0); });
API vs E2E — decision guide
| Scenario | Preferred level | Reason |
|---|---|---|
| Validate CRUD responses, error shapes, pagination | API test | No DOM, no browser — 100× faster, direct signal |
| Test that the UI calls the right endpoint on submit | Integration (intercept) | Use interceptNetworkCall spy — no full backend needed |
| Validate the full user-facing journey end to end | E2E | Cross-system flow that only makes sense through the browser |
| Test business logic inside a service class | Unit | Extract and test the pure function directly — no HTTP call |
What it is: A Test-Driven Development discipline for UI components: write a failing test first, implement the minimum code to make it pass, then refactor with passing tests as a safety net. Covers React/Vue components with Playwright Component Tests, Cypress cy.mount, or React Testing Library. Includes accessibility and keyboard navigation assertions from the start.
Why it matters: TDD for components provides immediate feedback — failing tests clarify requirements before code is written, minimal implementations prevent over-engineering, and tests act as a regression guard during refactoring. Without TDD, components grow beyond their tested scope and become untestable monoliths.
The Red-Green-Refactor loop
Provider isolation — fresh context per test
// Wrap every component with fresh provider instances to prevent state bleed function AllTheProviders({ children }: { children: ReactNode }) { // new QueryClient() per render — prevents cache contamination between parallel tests return ( <QueryClientProvider client={new QueryClient()}> <MemoryRouter> <AuthProvider>{children}</AuthProvider> </MemoryRouter> </QueryClientProvider> ); } // Pass as the wrapper option to RTL render render(<UserCard userId="usr_001" />, { wrapper: AllTheProviders }); // In Playwright Component Tests await mount(<AllTheProviders><UserCard userId="usr_001" /></AllTheProviders>);
Accessibility testing in the TDD loop
// Include a11y assertions from the very first test — not as an afterthought import { AxeBuilder } from '@axe-core/playwright'; test('UserCard has no accessibility violations', async ({ page, mount }) => { await mount(<UserCard userId="usr_001" />); const results = await new AxeBuilder({ page }).analyze(); expect(results.violations).toEqual([]); });
Keyboard navigation testing
// Test Tab, Enter, Escape flows — not just mouse interactions test('dropdown is fully keyboard navigable', async ({ page }) => { await page.getByRole('combobox', { name: 'Status' }).focus(); await page.keyboard.press('Enter'); // open dropdown await expect(page.getByRole('listbox')).toBeVisible(); await page.keyboard.press('ArrowDown'); // navigate to first option await page.keyboard.press('Enter'); // select it await expect(page.getByRole('combobox')).toHaveValue('Active'); await page.keyboard.press('Escape'); // close — listbox must hide await expect(page.getByRole('listbox')).not.toBeVisible(); });
- RuleWrite the failing test before any implementation code. If you can't write the test first, the component API is unclear — clarify requirements before coding.
- RuleEach render must use a fresh provider instance. Shared provider state between tests creates ordering dependencies and non-deterministic failures in parallel runs.
- AvoidVisual regression (screenshot comparison) as primary TDD assertions — they are expensive to maintain and break on trivial styling changes. Use them selectively for visually critical components only.
What it is: Configuration and workflows for capturing rich debugging artifacts — traces, screenshots, videos, HAR files — only on failure, plus tooling for time-travel debugging through Playwright's Trace Viewer and interactive Inspector. Reduces CI failure triage time by 80–90% by making remote debugging possible from a single trace.zip.
Why it matters: CI failures with minimal context force developers to reproduce them locally — a process that can take hours. Trace files, screenshots, and videos captured at the exact moment of failure show what the test saw, when, and why it failed.
Recommended artifact config in playwright.config.ts
export default defineConfig({ use: { trace: 'retain-on-failure-and-retries', // keeps all retry attempts screenshot: 'only-on-failure', // saved as PNG in test-results/ video: 'retain-on-failure', // only for failed tests } });
Trace Viewer — 5 panels
| Panel | What it shows | Use it to |
|---|---|---|
| Timeline | All test actions in chronological order | Find which action immediately preceded the failure |
| Snapshots | DOM state before and after each action | Inspect exact element state at any point in the test |
| Network | All HTTP requests with timing and payloads | Find a missing waitForResponse — see when requests actually fired |
| Console | Browser console output during the test | Catch unhandled JS errors and unexpected warnings |
| Source | Test code with the current line highlighted | Map each trace event back to the specific test line |
Opening and analyzing traces
# Open a specific trace file (downloaded from CI artifacts) npx playwright show-trace test-results/users-create-chromium/trace.zip # CLI trace analysis — useful inside coding agents (no GUI needed) npx playwright trace actions --grep="expect" trace.zip npx playwright trace snapshot 42 trace.zip # DOM snapshot at action #42
Interactive Inspector — local debugging
# Run in debug mode — Inspector opens automatically, pauses before first action PWDEBUG=1 npx playwright test users/create.spec.ts # Or pause at a specific point inside test code await page.pause(); // execution stops; Inspector opens for interactive exploration # Step through the test action by action, run locator queries live npx playwright test --debug users/create.spec.ts
CI artifact upload — GitHub Actions
# Upload all test artifacts even when the job fails - name: Upload test results uses: actions/upload-artifact@v4 if: always() # critical — must upload on failure with: name: playwright-report-${{ matrix.shard }} path: | playwright-report/ test-results/ retention-days: 30
- NeverUse
video: 'on'in CI — records video for every test regardless of outcome. Generates gigabytes of files and significantly slows execution. Use'retain-on-failure'. - NeverUse
trace: 'on'in CI for the same reason — trace files are large.'retain-on-failure-and-retries'captures what you need without the overhead. - AlwaysUpload test artifacts with
if: always()in CI — without this, artifacts are not saved when the job fails, which is exactly when you need them.
What it is: Opinionated patterns for playwright.config.ts: centralized multi-environment URL management via envConfigMap, standardized timeout values, reporter configuration, parallelization and sharding, and auth-state project structure. The config file is the single source of truth for all environment and execution behaviour — when it drifts, every test becomes suspect.
Multi-environment URL management — envConfigMap
import { defineConfig } from '@playwright/test'; const envConfigMap: Record<string, { baseURL: string }> = { local: { baseURL: 'http://localhost:3000' }, staging: { baseURL: 'https://staging.example.com' }, production: { baseURL: 'https://example.com' }, }; const env = process.env.TEST_ENV; if (!env || !envConfigMap[env]) { // Fail-fast: unknown environment is always a mistake, never a default throw new Error( `TEST_ENV must be one of: ${Object.keys(envConfigMap).join(', ')}. Got: "${env}"` ); } export default defineConfig({ use: { ...envConfigMap[env] }, });
Standardized timeout values
| Timeout | Value | Covers |
|---|---|---|
actionTimeout | 15 000 ms | Single click, fill, keyboard event — single user interaction |
navigationTimeout | 30 000 ms | Page load, URL change, waitForNavigation |
expect.timeout | 10 000 ms | Assertion retry window before the assertion is declared failed |
timeout (test) | 60 000 ms | Total test duration including setup, execution, and teardown |
Parallelization and sharding
import os from 'os'; export default defineConfig({ fullyParallel: true, workers: process.env.CI ? 1 : os.cpus().length - 1, // 1 worker in CI avoids contention retries: process.env.CI ? 2 : 0, // retries only in CI, never locally }); # Shard the suite across 4 CI machines — each runs 25% of tests # npx playwright test --shard=1/4 (machine 1) # npx playwright test --shard=2/4 (machine 2) ... etc
Auth-state projects — setup once, reuse everywhere
export default defineConfig({ projects: [ // Step 1: run auth setup once; saves auth state to disk { name: 'setup', testMatch: '**/auth.setup.ts' }, // Step 2: authenticated tests depend on setup project { name: 'authenticated', dependencies: ['setup'], use: { storageState: 'playwright/.auth/user.json' } }, // Step 3: unauthenticated tests run independently { name: 'unauthenticated' } ] });
Reporter configuration
export default defineConfig({ reporter: [ ['html', { outputFolder: 'playwright-report', open: 'never' }], // visual HTML report ['junit', { outputFile: 'test-results/results.xml' }], // CI/CD parsing ['list'] // terminal output ] });
TEST_ENV at config load time. If the config silently falls back to a default environment when TEST_ENV is missing or mistyped, tests may run against the wrong environment — including production. The fail-fast pattern above makes this impossible.§7 — CI & Execution Strategy
Files 29 – 31What it is: A CI pipeline architecture that runs changed test files 10× in a loop (burn-in) before allowing merge, then shards the full suite across multiple machines with fail-fast: false. Burn-in stress-tests new or modified tests to flush out flakiness before it reaches the main branch.
Why it matters: A test that passes once locally can still be flaky — it may fail 1 in 10 runs due to a race condition or timing issue. Running it 10 times in CI catches this before the test is trusted. fail-fast: false ensures all shards complete even when one fails, preserving the full evidence set for post-mortem analysis.
Pipeline stages
npm ci with cache key on package-lock.json hash. Avoids re-downloading dependencies on every run.git diff --name-only, then run them 10× in a shell loop. If any iteration fails, the stage fails and blocks merge.fail-fast: false. All shards complete before the merge gate is evaluated.Burn-in loop — detect changed specs and repeat
# Detect changed spec files relative to main branch CHANGED_SPECS=$(git diff --name-only origin/main...HEAD \ | grep -E '\.(spec|test)\.(ts|js)$' \ | tr '\n' ' ') # Run changed specs 10 times — exit 1 on first failure for i in {1..10}; do echo "Burn-in iteration $i/10" npx playwright test $CHANGED_SPECS || exit 1 done
Sharded full suite — GitHub Actions matrix
# .github/workflows/playwright.yml jobs: test: strategy: fail-fast: false # all shards run even if one fails matrix: shard: [1, 2, 3, 4] steps: - run: npx playwright test --shard=${{ matrix.shard }}/4 merge-reports: needs: test if: always() # run even when tests fail steps: - run: npx playwright merge-reports ./all-blobs --reporter html
Dependency cache
# Cache node_modules — busts when package-lock.json changes
- uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }}
- RuleAlways set
fail-fast: falsein the sharding matrix. A single failing shard should not prevent the other shards from running — you need the full failure picture to diagnose systemic issues. - NeverUse
${{ inputs.* }}directly insiderun:blocks — this is a script injection vulnerability. Assign to an env var first and reference that instead. - AlwaysUpload test artifacts with
if: always()so that trace files are available even when the job fails — which is the only time they matter.
What it is: A strategy for running only the tests that matter at each stage of the development workflow, using tags (@smoke, @p0–@p3, @regression) and changed-file detection. Tests are promoted through stages: smoke (<5 min) → PR (<10 min) → full regression (<30 min). Running the full suite on every commit wastes developer time without adding safety.
Tag taxonomy
| Tag | Scope | Time budget | When to run |
|---|---|---|---|
@smoke | Critical path only — can the product load and function at all? | <5 min | Every commit, pre-commit hook |
@p0 | Revenue, security, data integrity paths | Part of @p0+@p1 ≤10 min | Every PR before review |
@p1 | Core user journeys | Part of @p0+@p1 ≤10 min | Every PR before review |
@p2 | Secondary features, admin flows | Part of full ≤30 min | Pre-merge gate |
@p3 | Cosmetic, rarely used paths | Part of full ≤30 min | Nightly only |
@regression | Full suite | <30 min | Pre-merge to main, nightly |
Tagging tests
// Tag with priority and type — multiple tags allowed test('@smoke @p0 login flow works', async ({ page }) => { ... }); test('@p1 search returns relevant results', async ({ page }) => { ... }); test('@p2 CSV export downloads correctly', async ({ apiRequest }) => { ... }); test('@p3 button hover shows tooltip', async ({ page }) => { ... });
Executing by tag
# Run only smoke tests (every commit) npx playwright test --grep "@smoke" # Run P0 + P1 (PR check) npx playwright test --grep "@p0|@p1" # Run full regression (pre-merge) npx playwright test --grep "@regression" # Exclude a tag (skip nightly-only tests) npx playwright test --grep-invert "@p3"
Changed-file detection → tag mapping
# Map changed files to test tags automatically CHANGED=$(git diff --name-only origin/main...HEAD) if echo "$CHANGED" | grep -q "src/components/"; then TAGS="@smoke|@p0|@p1" # UI changed → run component + E2E tests elif echo "$CHANGED" | grep -q "src/api/"; then TAGS="@p0|@p1" # API changed → run integration + E2E tests else TAGS="@smoke" # other changes → smoke only fi npx playwright test --grep "$TAGS"
Test promotion — three stages
| Stage | Trigger | Tags run | Time budget | Gate |
|---|---|---|---|---|
| Pre-commit | Local git hook | @smoke | <5 min | Blocks commit if failing |
| PR check | PR open / push to branch | @p0 + @p1 + changed specs | <10 min | Required status check |
| Merge gate | PR approved, before merge to main | @regression | <30 min | Blocks merge if failing |
What it is: A structured approach to managing feature flags with TypeScript type safety, metadata tracking (owner, expiry date, dependencies), comprehensive testing of both enabled and disabled states, and automated cleanup detection. Without governance, flags accumulate as technical debt — untested variations ship broken code and forgotten flags clutter the codebase indefinitely.
Centralized flag registry with type safety
// src/feature-flags.ts — single source of truth for all flags export const FLAGS = Object.freeze({ NEW_CHECKOUT_FLOW: 'new-checkout-flow', DARK_MODE: 'dark-mode', AI_SEARCH_ENABLED: 'ai-search-enabled', } as const); // Type-safe key — typos caught at compile time, not at runtime export type FlagKey = (typeof FLAGS)[keyof typeof FLAGS]; // Metadata registry — governance information per flag export const FLAG_REGISTRY: Record<FlagKey, FlagMetadata> = { [FLAGS.NEW_CHECKOUT_FLOW]: { owner: 'payments-team', createdDate: '2024-09-01', expiryDate: '2024-11-01', // drives automated audit defaultState: false, requiresCleanup: true, }, };
Testing both flag states — mandatory for P0/P1 features
// Always test BOTH enabled and disabled — never assume one state test.describe('new-checkout-flow flag', () => { test('@p0 enabled: shows new checkout UI', async ({ page, featureFlags }) => { await featureFlags.setVariation(FLAGS.NEW_CHECKOUT_FLOW, true); await page.goto('/checkout'); await expect(page.getByTestId('new-checkout-form')).toBeVisible(); }); test('@p0 disabled: shows legacy checkout UI', async ({ page, featureFlags }) => { await featureFlags.setVariation(FLAGS.NEW_CHECKOUT_FLOW, false); await page.goto('/checkout'); await expect(page.getByTestId('legacy-checkout-form')).toBeVisible(); }); test.afterEach(async ({ featureFlags }) => { await featureFlags.reset(FLAGS.NEW_CHECKOUT_FLOW); // restore default after each test }); });
Flag lifecycle checklist
FLAGS enum), owner team, expiry date, default state, whether cleanup is required when removed.FLAGS and FLAG_REGISTRY, delete the disabled code path, and delete the disabled-state tests. Run the full regression suite to confirm nothing broke.Automated audit script
# Detect governance issues — run in CI on a schedule npm run feature-flags:audit # Reports: # - Flags past their expiry date (need cleanup) # - Flags with no owner assigned # - Flags with requiresCleanup: true but no cleanup ticket linked # - Flags present in code but missing from FLAG_REGISTRY
- RuleEvery flag must have an
expiryDateinFLAG_REGISTRY. Flags without expiry dates are never cleaned up — they become permanent dead code. - NeverTest only the enabled state. The disabled fallback is the default for most users until the flag is fully rolled out — a broken fallback is a broken product.
- NeverShare flag state between parallel tests. Use per-test
featureFlags.setVariation()withafterEachcleanup — parallel tests will interfere with each other otherwise.
§8 — Resilience & Workflow Utilities
Files 32 – 35What it is: Patterns for writing explicit error handling in tests — scoped exception catching (only ignore documented errors), retry validation (prove the retry mechanism actually fires N times before succeeding), telemetry logging with secret redaction, and graceful degradation testing (cached fallbacks, non-critical service failures).
Why it matters: Tests fail for two reasons: genuine bugs or poor error handling in the test itself. Without explicit patterns, tests become noisy (false positives from expected errors) or silent (swallowing real errors). Resilience testing also ensures the app handles real-world failures gracefully rather than exposing stack traces to users.
Scoped exception catching — only ignore documented errors
// ❌ BAD: global pageerror listener silences all JS errors page.on('pageerror', () => {}); // ✅ GOOD: scoped listener — only ignore the one known error in this test const KNOWN_ERROR = 'ResizeObserver loop limit exceeded'; page.on('pageerror', (err) => { if (err.message.includes(KNOWN_ERROR)) return; // known, ignore throw err; // unexpected — re-throw });
Retry validation — prove N failures then success
// Stub the API to fail twice, succeed on the 3rd attempt let attempts = 0; await page.route('**/api/orders', (route) => { attempts++; if (attempts <= 2) { route.fulfill({ status: 500, body: '{"error":"Server Error"}' }); } else { route.fulfill({ status: 201, body: '{"id":"ord_001"}' }); } }); await page.getByRole('button', { name: 'Place Order' }).click(); // UI must show retry indicator during attempts 1-2 await expect(page.getByTestId('retry-indicator')).toBeVisible(); // UI must show success after attempt 3 await expect(page.getByText('Order placed!')).toBeVisible(); expect(attempts).toBe(3); // confirm exactly 3 attempts were made
Telemetry logging with secret redaction
// Log error context — never log sensitive fields const REDACT_KEYS = ['password', 'token', 'creditCard', 'ssn']; function safeLog(context: Record<string, unknown>) { const safe = Object.fromEntries( Object.entries(context).map(([k, v]) => REDACT_KEYS.some(r => k.toLowerCase().includes(r)) ? [k, '[REDACTED]'] : [k, v] ) ); await log.error(safe); }
Graceful degradation — non-critical service failure
// Analytics service is down — app must continue working normally await page.route('**/analytics/**', route => route.fulfill({ status: 503 })); await page.goto('/dashboard'); // Critical features must still work despite analytics being down await expect(page.getByTestId('dashboard-content')).toBeVisible(); await expect(page.getByRole('button', { name: 'Create' })).toBeEnabled(); // No crash, no blocking modal — user is not informed of non-critical failure
- RuleTest retry behavior with a counter — assert exactly N failures before success. Without the counter, you can't confirm the retry mechanism actually fired.
- RuleTest graceful degradation for every non-critical service the app calls (analytics, feature flags, recommendations). If any of these going down breaks a core flow, it's a P0 bug.
- NeverUse a global
pageerrorhandler that swallows all errors. Scope it to the specific test that expects a known error.
What it is: Patterns for testing email-based authentication flows — magic links, registration confirmation codes, and password reset — using a real email capture service (Mailosaur recommended, Ethereal free, MailHog self-hosted). Covers session caching to minimize email consumption, link extraction, and negative test cases.
Why it matters: Email auth introduces unique testing challenges: asynchronous delivery (wait for email before clicking), quota limits (AWS Cognito: 50 emails/day in sandbox), and per-email costs in production-like environments. Session caching allows 500 tests to consume only 1 email instead of 500.
Email capture setup — Mailosaur
import { MailosaurClient } from 'mailosaur'; const mailosaur = new MailosaurClient(process.env.MAILOSAUR_API_KEY); // Generate a unique inbox per test — never hardcode email addresses const serverId = process.env.MAILOSAUR_SERVER_ID; const testEmail = `user-${crypto.randomUUID()}@${serverId}.mailosaur.net`;
Magic link extraction flow
// Step 1: Trigger the email (registration, login, password reset) await apiRequest({ method: 'POST', path: '/api/auth/magic-link', body: { email: testEmail } }); // Step 2: Wait for email and extract the link const message = await mailosaur.messages.get(serverId, { sentTo: testEmail, timeout: 30_000 // wait up to 30s for delivery }); // Method A: direct link from Mailosaur SDK const magicLink = message.html.links[0].href; // Method B: parse HTML with JSDOM for custom button selectors const { JSDOM } = await import('jsdom'); const dom = new JSDOM(message.html.body); const magicLink = dom.window.document.querySelector('#magic-link-button')?.href; // Step 3: Click the link and assert authentication succeeded await page.goto(magicLink); await expect(page).toHaveURL('/dashboard');
Session caching — 1 email for 500 tests
// globalSetup: authenticate once, save state to disk export default async function globalSetup() { // 1. Register + verify email once const email = `seed-user-${crypto.randomUUID()}@${serverId}.mailosaur.net`; await registerUserViaEmail(email); const link = await extractMagicLink(email); // 2. Navigate and save auth state (cookies + localStorage) const browser = await chromium.launch(); const page = await browser.newPage(); await page.goto(link); await page.context().storageState({ path: 'playwright/.auth/user.json' }); await browser.close(); // All 500 tests reuse playwright/.auth/user.json — 0 additional emails sent }
Negative test cases — mandatory for auth flows
| Scenario | How to test | Expected result |
|---|---|---|
| Expired link | Generate link, wait 24h+ (or manipulate expiry in test env), click it | Error page: "Link expired — request a new one" |
| Invalid token | Construct a URL with a garbage token: /auth/verify?token=abc123garbage | Error page: "Invalid or unrecognized link" |
| Already used | Click the same magic link twice | Second click shows: "Link already used" |
| Rate limiting | Request 10 magic links in rapid succession for the same email | Response after threshold: 429 Too Many Requests |
crypto.randomUUID().What it is: Lightweight, stateless shell commands that allow coding agents (like Claude Code) to interact with a browser — take screenshots, query element references, inspect network traffic — without loading the full DOM accessibility tree into the context window. Each command returns compact element references (e15, e21) rather than a full DOM dump, saving ~93% of tokens per interaction compared to Playwright MCP.
Why it matters: Playwright MCP is powerful but expensive: every interaction loads the complete accessibility tree. The CLI returns only what was asked for — a snapshot, a screenshot, a selector — keeping the agent's context window free for actual test code.
Core commands
| Command | Purpose | Returns |
|---|---|---|
open <url> | Navigate to a URL | Confirmation + page title |
snapshot | Capture accessibility snapshot of current page | Compact element reference list (e1...eN) |
screenshot | Take a visual screenshot | PNG file path |
click <ref> | Click an element by reference | Confirmation |
fill <ref> <text> | Type into an input by reference | Confirmation |
network | Show recent network requests | URL list with status codes |
tracing-start | Begin recording a Playwright trace | — |
tracing-stop | Stop and save the trace | Trace file path |
Typical agent workflow
open <url> to navigate to the page being tested.snapshot to get element references — use these to identify the correct selectors for the test code.click or fill to interact and confirm the page responds as expected.screenshot to capture visual evidence of the state for test documentation.Session scoping — avoid state leakage
# Use the -s flag to scope CLI sessions — prevents state leaking between explorations playwright-cli -s=tea-explore open https://example.com/login playwright-cli -s=tea-explore snapshot playwright-cli -s=tea-explore screenshot # Trace analysis — useful for debugging failing tests without a GUI npx playwright trace open trace.zip npx playwright trace actions --grep="expect" trace.zip # filter by keyword npx playwright trace snapshot 15 trace.zip # DOM state at action #15
playwright-utils fixtures and the confirmed selectors. Never substitute CLI interactions for test assertions.What it is: A smarter version of Playwright's --only-changed flag. Analyzes git changes, filters out irrelevant file types via skipBurnInPatterns (config files, types, docs), controls test volume with burnInTestPercentage, and supports CI sharding. Prevents the problem where a change to a shared config file would otherwise trigger hundreds of unrelated tests.
Why it matters: Playwright's native --only-changed uses the import graph to determine affected tests. A change to playwright.config.ts or a shared type file marks every test as affected — triggering the entire suite. Burn-in adds an explicit skip-pattern filter and volume cap to keep feedback fast.
Three-phase filtering pipeline
skipBurnInPatterns (config, types, docs, mocks) are removed from the changed-files list. A change to playwright.config.ts does not trigger any tests.--only-changed but applied only to the filtered list.burnInTestPercentage to cap the number of tests actually run. With 45 affected tests and 30%, run 14 tests — a statistically meaningful sample without running everything.Configuration
// playwright.config.ts — burnIn section export default defineConfig({ burnIn: { skipBurnInPatterns: [ '**/playwright.config.*', // config changes don't affect test logic '**/*types*', // type-only files: no runtime impact '**/*.md', // docs: never trigger tests '**/mocks/**', // mock data: not production code ], burnInTestPercentage: process.env.CI ? 0.2 : 0.3, // 20% CI, 30% local repeatEach: 3, // run each selected test 3× for stress testing retries: 1, // 1 retry to distinguish flaky from broken } });
Example output — 21 changed files
| Phase | Files / Tests | Result |
|---|---|---|
| Changed files detected | 21 files | Starting point from git diff |
| After skip-pattern filter | 15 files (6 skipped: config, types, docs) | Removed irrelevant files |
| After dependency analysis | 45 affected tests | Import graph traced from 15 files |
| After volume control (30%) | 14 tests run | Fast, targeted, meaningful sample |
0.2) where compute is shared, and a higher percentage locally (0.3) where a developer is iterating. The process.env.CI check handles this automatically.§9 — Webhook Testing
Files 36 – 37What it is: Patterns for testing webhook delivery in event-driven architectures. Webhook delivery is eventually consistent — the application fires HTTP callbacks asynchronously after events. Tests must poll until the webhook arrives or time out gracefully. The module provides deterministic polling via recurse, typed matchers, rich timeout diagnostics, and parallel-safe isolation via startedAt scoping.
Why it fails without these patterns: Webhook tests fail for 4 structural reasons — eventual inconsistency (can't await directly), parallel journal pollution (worker A captures worker B's webhook), opaque timeouts (no context about what arrived vs what was expected), and cleanup leakage (registered expectations persist between tests).
Core polling pattern
import { webhookRegistry } from '../support/merged-fixtures'; test('order.created webhook delivered after checkout', async ({ page, webhookRegistry }) => { // Scope to this test's start time — ignores webhooks from prior tests const registry = webhookRegistry.forTest(); // Trigger the event await page.goto('/checkout'); await page.getByRole('button', { name: 'Place Order' }).click(); // Poll until the webhook arrives — or throw WebhookTimeoutError const webhook = await registry.waitFor( orderCreatedTemplate(orderId) // scoped by orderId for parallel safety ); expect(webhook.body.event).toBe('order.created'); expect(webhook.body.data.id).toBe(orderId); });
Three matcher types — AND semantics
| Matcher | Syntax | Use case |
|---|---|---|
| matchField | matchField('data.id', orderId) | Exact match on a dot-notation path. Most precise — use for IDs. |
| matchPartial | matchPartial({ event: 'order.created' }) | Deep subset match — extra payload fields are ignored. Use for event type checks. |
| matchPredicate | matchPredicate('has amount', w => w.amount > 0) | Arbitrary predicate with a human-readable label (required). Use for computed assertions. |
AND semantics: All matchers on a template must pass — a webhook matches only if every matcher in the template succeeds simultaneously.
startedAt isolation — prevents cross-test pollution
// Each registry instance records the timestamp of creation // waitFor() only considers webhooks received AFTER that timestamp const registry = webhookRegistry.forTest(); // stamps current time // → webhooks from previous tests are automatically excluded // Template factories MUST be scoped by entity ID for parallel test safety const orderCreatedTemplate = (orderId: string) => webhookTemplate('order.created') .matchField('data.id', orderId) // scoped by ID — worker A won't match worker B's webhook .matchPartial({ event: 'order.created' }) .build();
WebhookTimeoutError — rich diagnostics
// When waitFor() times out, the error carries full diagnostic context try { await registry.waitFor(orderCreatedTemplate(orderId)); } catch (err) { if (err instanceof WebhookTimeoutError) { console.log(err.totalReceived); // how many webhooks arrived at all console.log(err.receivedWebhooks); // last ≤10 webhooks (inspect actual payloads) console.log(err.matcherDetails); // which matchers were configured // totalReceived: 0 → webhook never delivered — check app event publishing // totalReceived > 0 but no match → matcher misconfigured — inspect receivedWebhooks[0] } }
Drain pattern — sequential events
// When testing delete, first drain the create webhook to avoid ambiguity // Otherwise: the cleanup matcher might match the create webhook, not the delete await registry.waitFor(movieCreatedTemplate(movieId)); // drain create first await deleteMovie(movieId); // then trigger delete await registry.waitFor(movieDeletedTemplate(movieId)); // now safe to assert delete
What it is: Risk classification and complete test coverage requirements for systems that publish webhooks. Webhook integration boundaries are high-risk by default because external consumers depend on payload shape, delivery timing, and event ordering — all of which are invisible to unit and component tests.
Default risk score: P2 × I3 = Score 6 (High — must mitigate). Probability 2 (Possible: webhook delivery failure occurs occasionally) × Impact 3 (Severe: external consumers miss transactions, integrations break silently). This means webhook delivery must have dedicated integration test coverage unless explicitly downgraded with documented justification.
Why score 6 is the baseline
| Factor | Assessment |
|---|---|
| Probability = 2 (Possible) | Network failures, payload serialization bugs, event ordering issues — all occur under normal production conditions. Not rare (P1), not certain (P3). |
| Impact = 3 (Severe) | Missed webhook = missed transaction for external consumer. A payment processor not receiving order.paid does not fulfil the order. Silent breakage, no UI feedback to users. |
| Not visible in unit tests | The event publishing logic and serialization can look correct in unit tests while silently breaking the HTTP delivery or payload shape. |
Complete test coverage requirements — Score 6
- Happy path: Action triggers event → webhook is delivered → payload matches expected shape. Must cover every event type the system publishes.
- Sequential drain pattern: For systems that emit create then delete events, consume create webhook before asserting delete webhook. Prevents matcher ambiguity.
- Parallel isolation: Multiple tests run simultaneously — each must use entity-ID-scoped templates. Worker A must never match Worker B's webhooks.
- Timeout error shape: When webhook is not delivered (e.g. event publishing is disabled), assert the correct
WebhookTimeoutErroris thrown with meaningfultotalReceivedcount. - Cleanup verification: After test teardown, confirm no unprocessed webhooks remain in the mock server journal that could pollute the next test.
Downgrading from Score 6 — requires justification
| Downgrade condition | New score | Required evidence |
|---|---|---|
| Webhook delivery failure has a retry mechanism with DLQ and alerting | P2 × I2 = 4 | Retry config, DLQ config, alerting dashboard link |
| Consumer can tolerate missed events (idempotent polling as fallback) | P2 × I2 = 4 | Architecture doc showing polling fallback |
| Webhook is for non-critical analytics only — no transactional impact | P1 × I1 = 1 | Documented in risk register with product owner sign-off |
Timeout values — match your pipeline latency
| Pipeline | Recommended timeout | Reason |
|---|---|---|
| Direct HTTP (synchronous dispatch) | 5 000 ms | Should arrive within seconds |
| Message queue (SQS, RabbitMQ) | 10 000 ms | Queue processing adds seconds of latency |
| Kafka / event streaming | 15 000–30 000 ms | Kafka consumer lag can be significant in test environments |
Specialized Tier — 14 Files
Loaded only for deep-dive contract testing and webhook infrastructure work. Covers the full @seontechnologies/pactjs-utils library (§10) and the playwright-utils webhook subsystem (§11).
§10 — Pact.js Utils
Files 38–46 · 9 files · SpecializedWhat it is: Production-ready utilities from @seontechnologies/pactjs-utils that eliminate boilerplate in consumer-driven contract testing. Wraps @pact-foundation/pact with type-safe helpers: provider state creation, PactV4 JSON interaction builder, verifier config assembly, and request filter auth injection. Works for both HTTP and message (async/Kafka) contracts.
Exposed Utilities
| Utility | Purpose |
|---|---|
| createProviderState | Build [stateName, JsonMap] tuple from typed input |
| toJsonMap | Convert object → Pact-compatible (null→"null", Date→ISO, nested→JSON string) |
| setJsonContent / setJsonBody | Curried callback helper for PactV4 builder lambdas |
| buildVerifierOptions | Assemble HTTP VerifierOptions; reads broker URL / token env vars |
| zodToPactMatchers | Zod schema → Pact V3 matchers — single source of truth |
| createRequestFilter | Auth injection plugin; prevents double-Bearer bug |
Decision Tree — Which Flow to Use
buildVerifierOptionsWhy Use It Over Raw Pact
- Raw Pact requires manual
JsonMapcasting — complex params (Date, null, nested) need manual serialization - Eliminates 30+ line verifier config scattered with env var logic
- Prevents double-prefix token bug (
Authorization: Bearer Bearer token) - Automated CI version tagging from GitHub Actions / GitLab CI env vars
What it is: zodToPactMatchers(schema, example) converts a Zod schema directly into Pact V3 matchers so you never maintain two representations of the same response shape. The schema is the single source of truth; the example value (or .openapi({ example }) metadata) provides concrete data.
Zod → Pact Mapping
| Zod Type | Pact V3 Matcher |
|---|---|
| z.string() | string(example) |
| z.number() | decimal(example) |
| z.number().int() | integer(example) |
| z.boolean() | boolean(example) |
| z.object({}) | recursive object (each field becomes a matcher) |
| z.array() | eachLike(itemMatcher) |
Example precedence
// Priority: arg > .openapi({ example }) > type default
const schema = z.object({
id: z.number().int().openapi({ example: 42 }),
name: z.string(),
});
// explicit example wins for name; openapi example wins for id
const matchers = zodToPactMatchers(schema, { name: 'Alice' });
// → { id: integer(42), name: string('Alice') }
Critical Rules
- Consumer-curated schema only — include only the fields the consumer actually reads. Never import the full provider schema.
- Do NOT wrap the result in
like()— each field is already a matcher; wrapping adds a redundant outer layer. - For array responses: the entire body becomes
eachLike(zodToPactMatchers(itemSchema, example)) - Silent drift risk with hand-written matchers: schema changes must be applied in two places.
zodToPactMatchersremoves this entirely.
What it is: createProviderState, toJsonMap, setJsonContent, and setJsonBody build type-safe provider state tuples and reusable PactV4 JSON callbacks — eliminating manual JsonMap casting and repetitive inline builder lambdas.
Core Helpers
| Helper | What it does |
|---|---|
| createProviderState({ name, params }) | Returns [string, JsonMap] tuple, spread directly into .given(...) |
| toJsonMap(obj) | null→"null", Date→ISO, nested→JSON string, array→CSV |
| setJsonBody({ matchers }) | Shorthand for .willRespondWith(200, setJsonBody(...)) |
| setJsonContent({ matchers }) | Sets JSON request/response content on PactV4 interaction builder |
Usage Pattern
const state = createProviderState({
name: 'a movie exists',
params: { id: 42, createdAt: new Date('2024-01-01') },
});
// state = ['a movie exists', { id: 42, createdAt: '2024-01-01T00:00:00.000Z' }]
pact.addInteraction(
new PactV4.HttpInteraction()
.given(...state) // spread the tuple
.uponReceiving('GET /movie/42')
.withRequest('GET', '/movie/42')
.willRespondWith(200, setJsonBody({ body: matchers }))
);
Mandatory Vitest Config
fileParallelism: falseNo parallel test filespool: 'forks'Isolated process per suitesingleFork: trueOne fork, no concurrency- Exactly one
pact.addInteraction()perit()block — FFI non-deterministically drops interactions when multiple are added - One
.pacttest.tsfile per consumer+provider pair — prevents FFI collision across files for the same pair
What it is: buildVerifierOptions, buildMessageVerifierOptions, handlePactBrokerUrlAndSelectors, and getProviderVersionTags assemble complete provider verification config in a single call — handling local/remote flow, broker URL resolution, consumer version selector strategy, and CI-aware version tagging.
Env Vars Read Automatically
PACT_BROKER_BASE_URLBroker / PactFlow URLPACT_BROKER_TOKENPactFlow auth tokenPACT_PAYLOAD_URLWebhook-triggered run (single pact)PACT_BREAKING_CHANGECoordination mode flagGITHUB_SHAProvider version tag in CIGITHUB_REF_NAMEBranch name for selectorConsumer Version Selector Strategy
| Mode | Selector | When to use |
|---|---|---|
includeMainAndDeployed: true | matchingBranch + mainBranch + deployedOrReleased | Normal verification (default) |
includeMainAndDeployed: false | matchingBranch only | Breaking-change coordination — isolate to current branch |
Mandatory Vitest Config
pool: 'forks'+poolOptions.forks.singleFork: true— Pact Rust FFI holds process-wide state; parallel verification corrupts results- Use
buildMessageVerifierOptionsfor Kafka / async message provider verification (separate entry point)
What it is: createRequestFilter and noOpRequestFilter inject an authentication header into every request during provider verification. A pluggable token-generator pattern prevents the double-Bearer bug (Authorization: Bearer Bearer token) and keeps auth concern separate from verifier config.
API
// Synchronous generator — pre-resolve async tokens before calling this
const requestFilter = createRequestFilter({
tokenGenerator: () => myPreFetchedToken, // returns raw value, NO "Bearer " prefix
});
// Provider that needs no auth
const requestFilter = noOpRequestFilter; // (req, res, next) => next()
Bearer Prefix Contract
tokenGeneratorReturns raw token stringcreateRequestFilterPrepends "Bearer " onceResultAuthorization: Bearer <token>Async Token Pattern
// tokenGenerator must be synchronous — pre-fetch async tokens first
async function setupVerifierOptions() {
const token = await fetchServiceAccountToken(); // async, done before filter
return buildVerifierOptions({
...config,
requestFilter: createRequestFilter({ tokenGenerator: () => token }),
});
}
tokenGeneratormust be synchronous — resolve async tokens before creating the filter, not inside it- Use
noOpRequestFilterwhen the provider endpoint is unauthenticated — avoids conditional logic in verifier setup - Never prefix the token manually in
tokenGenerator— the filter adds "Bearer " automatically, adding it yourself causes the double-prefix bug
What it is: SmartBear's MCP server that lets AI agents interact with PactFlow / Pact Broker inside the contract testing workflow. Provides 8 tools covering test generation, provider state fetching, quality review, and deployment safety checks — all accessible via Model Context Protocol.
8 Available Tools
| Tool | What it does | TEA Skill |
|---|---|---|
| Generate Pact Tests | Generate consumer/provider test from code, OpenAPI, or template | *automate |
| Fetch Provider States | Query live broker for existing provider states | *test-design |
| Review Pact Tests | Automated quality check against best practices | *test-review |
| Can I Deploy | Check deployment safety via broker matrix | *ci |
| Matrix | View full consumer/provider verification matrix | *ci |
| PactFlow AI Status | PactFlow AI feature availability and config | – |
| Metrics All | Broker-wide contract testing metrics | – |
| Metrics Team | Per-team metrics breakdown | – |
Installation (Claude Code)
# PactFlow (cloud)
claude mcp add-json -s user smartbear \
'{"command":"npx","args":["-y","@smartbear/pact-mcp"]}'
# Required env vars (PactFlow)
PACT_BROKER_BASE_URL=https://<org>.pactflow.io
PACT_BROKER_TOKEN=<your-pactflow-token>
# Self-hosted broker (instead of token)
PACT_BROKER_USERNAME=<user>
PACT_BROKER_PASSWORD=<pass>
- Requires Node.js 20+
- Install per-project (
-s userscopes to current user, not global) — different projects may use different PactFlow tenants - Live broker queries mean the agent sees real provider states, not stale documentation
What it is: Opinionated scaffolding and battle-tested conventions for a Pact.js consumer CDC project — directory structure, Vitest config, package.json scripts, shell scripts, and CI workflow layout. Codifies the pactjs-utils reference implementation so new projects are CI-portable from day one.
Directory Structure
tests/
contract/
support/ ← shared helpers, NOT consumer tests
pact-setup.ts
test-context.ts
movie-service/
get-movie.pacttest.ts ← one file per consumer+provider pair
create-movie.pacttest.ts
pacts/ ← generated pact files (gitignored in CI)
vitest.config.pact.ts ← separate config, never share with unit tests
Required Vitest Config (vitest.config.pact.ts)
export default defineConfig({
test: {
include: ['tests/contract/**/*.pacttest.ts'],
fileParallelism: false, // ← mandatory
pool: 'forks', // ← mandatory
poolOptions: { forks: { singleFork: true } }, // ← mandatory
},
});
NPM Scripts
test:pact:consumerRun consumer pact testspublish:pactPublish pacts to broker via shell scriptcan:i:deploy:consumerCheck deployment safetyrecord:consumer:deploymentRecord deploy in broker after release- Use
.pacttest.tsextension — never.pact.spec.tsor.contract.ts; keeps pact tests excluded from unit test runs by default - One
.pacttest.tsper consumer+provider pair — splitting interactions for the same pair across files causes FFI collision - Normalize interaction order before publishing: run
jq -S 'sort_by(.)'on generated pact JSON for byte-stable broker publishes (avoids "Cannot change pact content for already published pact" errors) - Support files live in
tests/contract/support/— never mix them with consumer test files
What it is: Configuring PactFlow webhooks to automatically trigger provider verification in GitHub Actions when a consumer publishes a new pact. Covers webhook URL and body setup, PAT management, secret rotation runbook, and staleness monitoring to detect silent webhook failures before they block deployments.
Webhook Setup Steps
pactflow-<org>) — never use a personal accountrepo scope; set expiration to "No expiration" for long-lived machine-user tokens${user.githubToken} in the webhook Authorization headerhttps://api.github.com/repos/<org>/<repo>/dispatches with event_type: "contract_requiring_verification_published"Webhook Event Choice
| Event | When it fires | Recommended? |
|---|---|---|
| contract_requiring_verification_published | Only when the new pact needs verification | ✓ Yes — avoids redundant CI runs |
| contract_published | Every publish, even if pact unchanged | ✗ No — triggers unnecessary provider runs |
can-i-deploy silently times out after 900 s. Webhook failures are silent — the team only discovers the broken pipeline when PRs are blocked days later. Staleness monitoring is essential.What it is: Inject the Pact mock server URL into the real consumer HTTP client via an optional baseUrl field on the API context type. This ensures contract tests exercise the actual consumer code — including retry logic, header assembly, timeout config, and error handling — rather than hand-crafted fetch() calls.
The Problem with Raw fetch()
fetch() in test callbacks only proves that Pact returns what you told it to — the real consumer client's retry logic, custom headers, timeout handling, and error mapping are never exercised. Contract mismatches hidden by raw fetch surface in production.Implementation
// 1. Add optional baseUrl to your API context type
interface ApiContext {
apiKey: string;
baseUrl?: string; // ← one-line change
}
// 2. Use nullish coalescing in the client
const client = axios.create({
baseURL: context.baseUrl ?? API_BASE_URL, // Pact URL wins in tests
});
// 3. Shared test context helper
function createTestContext(mockServerUrl: string): ApiContext {
return { apiKey: 'test-key', baseUrl: mockServerUrl };
}
// 4. In the pact test executeTest() callback
const api = createApiClient(createTestContext(mockServer.url));
const result = await api.getMovie(42);
expect(result.id).toBe(42); // assert on parsed objects, not HTTP status
- Assert on return values and parsed objects — not raw HTTP status codes. The contract already covers status codes; the consumer assertion should cover business logic.
- When the real client reveals a contract mismatch → fix the contract. The mismatch is a real bug that raw-fetch tests were silently hiding.
- Migrate existing consumer tests from raw
fetch()to the DI pattern incrementally — each migrated test strengthens the contract's real-world coverage.
§11 — Webhook Infrastructure
Files 47–51 · 5 files · SpecializedWhat it is: Central fixture wiring using webhookProviderFixture + webhookFixture + mergeTests, with cleanup strategy selection (matched-only vs. full-reset) per mock server type and parallelization model.
Fixture Composition Pattern
// fixtures.ts — one central definition
const webhookProvider = base.extend<{ webhookProvider: WireMockWebhookProvider }>({
webhookProvider: async ({}, use) => {
const provider = new WireMockWebhookProvider('http://localhost:8080');
await use(provider);
},
});
export const test = mergeTests(base, webhookFixture, webhookProviderFixture);
export { expect } from '@playwright/test';
Cleanup Strategy by Provider
| Provider | Cleanup Strategy | Parallelism |
|---|---|---|
| WireMock | matched-only | fullyParallel: true ✓ |
| MockServer | full-reset | workers: 1 or isolated provider per worker |
| Mockoon | full-reset | workers: 1 or isolated provider per worker |
- Lazy Playwright fixture evaluation — tests that don't request
webhookRegistrypay zero setup cost - Lifecycle: optional
setup()→ tests run →cleanup()→ optionalteardown() - Set project-wide strategy via
test.use({ webhookConfig: { cleanupStrategy: 'matched-only' } })
What it is: Build typed webhook templates with webhookTemplate() and compose matchers (matchField, matchPartial, matchPredicate) using AND semantics, with per-template timeout/interval overrides and clone() for base-template variations.
Matcher Types
| Matcher | Behaviour |
|---|---|
| matchField('data.id', value) | Dot-notation exact match on a single field |
| matchPartial({ ... }) | Deep subset check — extra payload fields are ignored |
| matchPredicate('desc', fn) | Arbitrary predicate; description string is required |
Template Factory Pattern
// Always scope by entity ID for parallel isolation
const movieCreated = (movieId: number) =>
webhookTemplate('movieCreated')
.matchField('data.id', movieId) // AND
.matchPartial({ event: 'movie.created' }) // AND
.withTimeout(5000)
.build();
// Variation from base
const movieCreatedVip = (id: number) =>
movieCreated(id).clone().matchField('data.tier', 'vip').build();
- All matchers apply AND semantics — every matcher must pass for a webhook to match
- Always scope template factories by entity ID — prevents parallel workers from claiming each other's webhooks
matchPredicatedescription is mandatory — it appears inWebhookTimeoutError.matcherDetailsfor debugging- Use
.withTimeout(ms)and.withInterval(ms)for slow pipelines (e.g. Kafka delivery at 15 s+)
What it is: Three registry methods covering all webhook assertion patterns: waitFor() polls until the first match (throws WebhookTimeoutError on timeout), waitForCount() collects N matching webhooks, getReceived() queries the journal without polling.
Method Comparison
| Method | Returns | Polling? | Use when |
|---|---|---|---|
| waitFor(template) | ReceivedWebhook<T> | Yes | Waiting for a single event to arrive |
| waitForCount(template, n) | ReceivedWebhook<T>[] | Yes | Batch ops — collect exactly N deliveries |
| getReceived(filter?) | ReceivedWebhook[] | No | Snapshot query; combine with manual since |
Drain Pattern for Sequential Events
// Test: create movie, then delete it — assert both webhooks
await movieApi.createMovie({ id: 42, title: 'Dune' });
// Drain the create webhook FIRST, then proceed to delete
const created = await webhookRegistry.waitFor(movieCreated(42));
expect(created.body.event).toBe('movie.created');
await movieApi.deleteMovie(42);
const deleted = await webhookRegistry.waitFor(movieDeleted(42));
expect(deleted.body.event).toBe('movie.deleted');
waitFor/waitForCountautomatically apply astartedAtsince-filter — only webhooks received after the registry was initialized are matchedgetReceiveddoes not apply a since-filter — pass{ since: myTimestamp }manually to avoid matching old entries- For parallel tests with
waitForCount: usematchPredicatewith all expected IDs to prevent cross-worker matching
What it is: Describes WebhookTimeoutError's properties (templateName, timeoutMs, totalReceived, receivedWebhooks, matcherDetails), how to read its error message, and how to map each failure pattern to the correct fix.
Error Properties
templateNameName of the template that timed outtimeoutMsConfigured timeout in millisecondstotalReceivedTotal webhooks received (any template)receivedWebhooksLast ≤10 received entries for inspectionmatcherDetailsFormatted matcher config that rantoJSON()Serialize all fields for CI log inspectionFailure Pattern → Fix
| totalReceived | Diagnosis | Fix |
|---|---|---|
| 0 | Webhook never delivered — wrong URL or event not published | Check app event publishing and mock server URL |
| > 0, no match | Webhooks arrived but matchers didn't match | Print error.toJSON() and inspect receivedWebhooks[0].body |
| 0 + matched-only cleanup | Another worker claimed & deleted the webhook first | Scope template factory by entity ID |
// In test failure handler or catch block
} catch (err) {
if (err instanceof WebhookTimeoutError) {
console.log(JSON.stringify(err.toJSON(), null, 2));
// matcherDetails: field(data.id=42), partial({"event":"movie.created"})
// receivedWebhooks[0].body → compare with your matcher expectations
}
throw err;
}
What it is: Documents the three built-in webhook providers (WireMockWebhookProvider, MockServerWebhookProvider, MockoonWebhookProvider) and the WebhookProvider interface for custom backends. Each wraps a different mock server API with different cleanup and parallelism characteristics.
Provider Comparison
| Provider | Fetch logs | deleteById | Best for |
|---|---|---|---|
| WireMock | GET /__admin/requests | ✓ Supported | Parallel tests — matched-only cleanup |
| MockServer | PUT /mockserver/retrieve | ✗ no-op | Serial runs or isolated provider per worker |
| Mockoon | GET /mockoon-admin/logs | ✗ no-op | Serial runs; default log limit 100 (raise with --max-transaction-logs N) |
Custom Provider Interface
interface WebhookProvider {
getReceivedWebhooks(filter?: WebhookFilter): Promise<ReceivedWebhook[]>;
resetJournal(): Promise<void>;
deleteById(id: string): Promise<void>;
getCount(): Promise<number>;
setup?(): Promise<void>; // optional — called once before tests
teardown?(): Promise<void>; // optional — called once after all tests
}
- WireMock is the recommended default for teams running webhook tests in parallel —
matched-onlycleanup +deleteByIdsupport enables safefullyParallel: true - MockServer and Mockoon require
full-reset— run serially (workers: 1) or spin up an isolated provider instance per worker - Custom providers must implement all four required methods;
setup()andteardown()are optional lifecycle hooks
Knowledge Base — Summary Map
51 of 51 files · 3 tiers · completeTier 1 — Core
15 files · Always loaded| # | File | Section | One-line purpose | Key concept |
|---|---|---|---|---|
| 01 | overview.md | §1 | 9 shared utilities — never build these from scratch | apiRequest · auth-session · recurse · log · file-utils · intercept · recorder · error-monitor · burn-in |
| 02 | fixture-architecture.md | §1 | 3-layer pattern: pure function → fixture wrapper → merged export | mergeTests() composition · cleanup in afterEach · no POM inheritance |
| 03 | network-first.md | §1 | Register interceptor BEFORE navigate — eliminates race conditions | Spy mode vs Stub mode · waitForResponse · HAR record / playback |
| 04 | data-factories.md | §1 | Faker factories + cleanup tracking; API setup 10–50× faster than UI | createUserData() with overrides · afterEach cleanup · parallel-safe UUIDs |
| 05 | test-levels-framework.md | §2 | Unit / Integration / E2E — when each level is appropriate | Test pyramid · anti-patterns · decision guide per scenario |
| 06 | test-priorities-matrix.md | §2 | P0–P3 priorities with minimum coverage thresholds per level | Business impact drives priority · P0 blocks release · @p0–@p3 tags |
| 07 | risk-governance.md | §2 | Risk = P × I · 6 categories · traceability from risk to tests | Score 9 = BLOCK · 6–8 = mitigation plan required · risk register format |
| 08 | adr-quality-readiness-checklist.md | §2 | 29-criterion readiness checklist across 8 architecture categories | ≥24/29 = PASS · CONCERNS · FAIL · gate decision model |
| 09 | probability-impact.md | §2 | 3×3 P×I matrix with colour-coded action tiers and worked examples | Score 1–3 = DOCUMENT · 4–5 = MONITOR · 6–8 = MITIGATE · 9 = BLOCK |
| 10 | nfr-criteria.md | §3 | 4 NFR domains: Security / Performance / Reliability / Maintainability | Tool + threshold + PASS criteria per domain · WAIVED ≠ SKIP |
| 11 | selector-resilience.md | §3 | Selector hierarchy: testid > ARIA role > label > text > CSS | filter() over nth() · migration table · XPath = never |
| 12 | timing-debugging.md | §3 | Anti-patterns → event-based waits · 5-step debug workflow | waitForResponse · waitFor state · never waitForTimeout · Trace Viewer |
| 13 | test-quality.md | §3 | 8-point quality checklist — all 8 criteria required for production | No hard waits · no conditionals · self-cleaning · unique data · parallel-safe |
| 14 | test-healing-patterns.md | §3 | 5 repair patterns: stale selector, race condition, dynamic data, timeout, hard wait | Symptom → root cause → fix steps · 3-strike rule → test.fixme() |
| 15 | contract-testing.md | §4 | Pact.js consumer contract → provider verification → can-i-deploy gate | like() / regex() matchers · provider state handlers · Postel's Law |
Tier 2 — Extended
22 files · Loaded on demand| # | File | Section | One-line purpose | Key concept |
|---|---|---|---|---|
| 16 | auth-session.md | §5 | Token persistence to disk; multi-user; worker-specific; ephemeral auth | AuthProvider interface · 4 auth modes · isTokenExpired auto-refresh |
| 17 | api-request.md | §5 | Typed HTTP client; schema validation; auto-retry 5xx; 4-tier URL resolution | validateSchema (Zod / JSON Schema / OpenAPI) · GraphQL body.errors check |
| 18 | recurse.md | §5 | Smart polling: command + predicate + options; 3 typed error shapes | RecurseTimeoutError · CommandError · PredicateError · post callback |
| 19 | intercept-network-call.md | §5 | Declarative spy / stub; register BEFORE navigate; glob URL patterns | fulfillResponse = stub · no fulfillResponse = spy · Promise.all for multiple |
| 20 | network-recorder.md | §5 | HAR record / playback; stateful CRUD simulation during offline replay | PW_NET_MODE=record|playback · hostMapping cross-env · CRUD-aware state |
| 21 | network-error-monitor.md | §5 | Auto-fail on 4xx / 5xx; import only; per-test opt-out via annotation | skipNetworkMonitoring · excludePatterns · maxTestsPerError: 1 |
| 22 | log.md | §5 | 6-level structured logging; surfaces in HTML report as collapsible steps | log.step() / debug() / success() · infoSync for globalSetup · never log secrets |
| 23 | file-utils.md | §5 | Download capture + CSV / XLSX / PDF / ZIP parsing in 2–10 lines | handleDownload() · readCSV / XLSX / PDF / ZIP · afterEach fs.remove cleanup |
| 24 | fixtures-composition.md | §5 | mergeTests() — one import file for all fixtures across the project | Last fixture wins on name conflict · test.use() overrides · lazy evaluation |
| 25 | api-testing-patterns.md | §6 | Direct API tests: CRUD, error shapes, GraphQL, async job polling | apiRequest + recurse combo · assert body.errors for GraphQL · API vs E2E guide |
| 26 | component-tdd.md | §6 | Red-Green-Refactor TDD loop for UI components with a11y from the start | AllTheProviders fresh per test · AxeBuilder · keyboard Tab / Enter / Escape flows |
| 27 | visual-debugging.md | §6 | Traces + screenshots on failure; Trace Viewer 5 panels; interactive Inspector | retain-on-failure config · if: always() CI upload · page.pause() local debug |
| 28 | playwright-config.md | §6 | envConfigMap fail-fast; standardized timeouts; sharding; auth-state projects | TEST_ENV validation · workers CI=1 / local=n-1 · reporter setup |
| 29 | ci-burn-in.md | §7 | Run changed specs 10× in CI; shard full suite; fail-fast: false | git diff → burn-in loop → matrix sharding → merge-reports |
| 30 | selective-testing.md | §7 | @smoke / @p0–@p3 / @regression tags; 3-stage promotion; time budgets | Pre-commit <5 min · PR <10 min · merge gate <30 min · file-change → tag map |
| 31 | feature-flags.md | §7 | Type-safe flag registry; metadata; test both enabled and disabled states | FLAGS enum · FLAG_REGISTRY expiryDate · afterEach reset · audit script |
| 32 | error-handling.md | §8 | Scoped exception catching; retry counter validation; secret redaction | Scoped pageerror listener · attempt counter · graceful degradation stubs |
| 33 | email-auth.md | §8 | Magic link extraction; session caching (1 email for 500 tests); negative flows | Mailosaur · crypto.randomUUID() per test · globalSetup storageState cache |
| 34 | playwright-cli.md | §8 | Lightweight CLI for coding agents; ~93% token saving vs Playwright MCP | -s session scope · open / snapshot / click / fill · trace CLI analysis |
| 35 | burn-in.md | §8 | Git-diff-aware runner; 3-phase filter; volume control via percentage cap | skipBurnInPatterns · burnInTestPercentage · skip → deps → volume phases |
| 36 | webhook-testing-fundamentals.md | §9 | Eventually-consistent polling; typed matchers; startedAt isolation | matchField / Partial / Predicate AND semantics · drain pattern · WebhookTimeoutError |
| 37 | webhook-risk-guidance.md | §9 | Default P2×I3 = Score 6; 5 mandatory coverage requirements | Happy path + drain + parallel + timeout shape + cleanup · downgrade needs sign-off |
Tier 3 — Specialized
16 files · To be updatedTier 3 will be documented in the next update. It covers two specialist groups:
- 38 pactjs-utils-overview — production-ready contract utilities
- 39 pactjs-utils-zod-to-pact — Zod schema → Pact V3 matchers
- 40 pactjs-utils-consumer-helpers — provider state + PactV4 builders
- 41 pactjs-utils-provider-verifier — buildVerifierOptions
- 42 pactjs-utils-request-filter — Bearer auth injection
- 43 pact-mcp — SmartBear MCP server for AI agents
- 44 pact-consumer-framework-setup — scaffolding & conventions
- 45 pact-broker-webhooks — PactFlow → GitHub Actions trigger
- 46 pact-consumer-di — baseUrl DI pattern for real consumer code
- 47 webhook-module-setup — fixture wiring + cleanup strategy
- 48 webhook-template-matchers — typed templates + AND semantics
- 49 webhook-waiting-querying — waitFor / waitForCount / getReceived
- 50 webhook-timeout-error — WebhookTimeoutError structure & debug
- 51 webhook-providers — WireMock / MockServer / Mockoon / custom
- 52–53 additional specialized topics (TBD)
*automate (Pact consumer generation) · *ci (contract CI + can-i-deploy gate) · *test-design (fetch provider states via Pact MCP) · *test-review (pact test quality review) · *framework (webhook fixture wiring, cleanup strategy selection).