const flow = new ProvisioningFlow(page, dashboard);
await use(flow);
// Teardown hook applies automatically to every test using this fixture
await flow.releaseResources();
},
});
**Rationale:**
When `ProvisioningFlow` requires a new dependency (e.g., an API client or event listener), you modify `core.fixtures.ts` once. Tests remain untouched. The `await use(flow)` pattern guarantees teardown executes after the test completes, preventing resource leaks in parallel runs.
### Step 2: Enforce Stateless Page Objects with Lazy Getters
Constructor assignments for locators appear safe because Playwright evaluates them lazily. However, this pattern encourages developers to capture async state during initialization, creating unmanaged race conditions.
```typescript
// pages/DashboardPage.ts
import { Page, Locator } from '@playwright/test';
export class DashboardPage {
constructor(private readonly page: Page) {}
// Lazy getter: evaluates DOM query at access time, never at construction
get userTable(): Locator {
return this.page.getByRole('table', { name: 'Active Users' });
}
get exportButton(): Locator {
return this.page.getByRole('button', { name: 'Export CSV' });
}
// Explicit async method for state retrieval
async getVisibleRowCount(): Promise<number> {
return this.userTable.locator('tr').count();
}
}
Rationale:
Getters cannot be async, which structurally prevents constructor-side state capture. Locators remain fresh across navigation events. State retrieval is explicitly async, making timing dependencies visible in test code rather than hidden in initialization logic.
Step 3: Orchestrate Scenarios with Business Flows
Flows consume Page Objects and expose high-level business actions. Tests should never interact with pages directly.
// flows/ProvisioningFlow.ts
import { Page } from '@playwright/test';
import { DashboardPage } from '../pages/DashboardPage';
export class ProvisioningFlow {
constructor(
private readonly page: Page,
private readonly dashboard: DashboardPage
) {}
async provisionUser(payload: { email: string; role: string }): Promise<void> {
await this.page.goto('/admin/provision');
await this.page.getByLabel('Email').fill(payload.email);
await this.page.getByLabel('Role').selectOption(payload.role);
await this.page.getByRole('button', { name: 'Create' }).click();
await this.dashboard.userTable.waitFor({ state: 'visible' });
}
}
Rationale:
Flows encapsulate navigation sequences, form interactions, and waiting strategies. Tests become declarative specifications. When UI flows change (e.g., a new confirmation modal), only the Flow class updates.
Step 4: Implement Deterministic Data Seeding
Parallel CI shards break workerIndex-based seeding. Each shard maintains its own worker counter, causing identical seeds across agents. Combine test identity, CI build ID, and retry index for cross-shard uniqueness.
// utils/seedGenerator.ts
import { TestInfo } from '@playwright/test';
import { faker } from '@faker-js/faker';
function computeHash(input: string): number {
let hash = 0;
for (let i = 0; i < input.length; i++) {
hash = (Math.imul(31, hash) + input.charCodeAt(i)) | 0;
}
return hash;
}
export function generateDeterministicSeed(testInfo: TestInfo): number {
const buildId = process.env.CI_BUILD_ID || 'local-run';
const identity = `${testInfo.testId}-${buildId}-${testInfo.repeatEachIndex}`;
return computeHash(identity);
}
export function seedFaker(testInfo: TestInfo) {
const seed = generateDeterministicSeed(testInfo);
faker.seed(seed);
return faker;
}
// fixtures/data.fixtures.ts
import { coreTest } from './core.fixtures';
import { seedFaker } from '../utils/seedGenerator';
export const dataTest = coreTest.extend<{ faker: typeof import('@faker-js/faker') }>({
faker: async ({}, use, testInfo) => {
await use(seedFaker(testInfo));
},
});
Rationale:
testId guarantees uniqueness per test file/name. CI_BUILD_ID isolates data across pipeline runs. repeatEachIndex ensures retries generate identical data, making flaky test reproduction deterministic. When a CI run fails, extract the build ID and run locally with the same environment variable to replicate the exact dataset.
Step 5: Structure Test Data with Factories and Type Safety
Random data obscures test intent. Use factory functions with override patterns to surface only relevant fields.
// factories/userFactory.ts
import type { Faker } from '@faker-js/faker';
export interface UserPayload {
id: string;
email: string;
displayName: string;
tier: 'free' | 'pro' | 'enterprise';
}
export function createUser(faker: Faker, overrides?: Partial<UserPayload>): UserPayload {
return {
id: faker.string.uuid(),
email: faker.internet.email(),
displayName: faker.person.fullName(),
tier: 'free',
...overrides,
};
}
// Type-safe dataset definition
export const ENTERPRISE_BENCHMARK = {
tier: 'enterprise',
displayName: 'Benchmark Corp',
} satisfies Partial<UserPayload>;
Rationale:
Factories reduce test noise. The satisfies operator validates datasets against the interface without widening literal types, catching schema drift at compile time. Tests declare business conditions, not implementation details.
Step 6: Scale Fixtures with mergeTests and Namespacing
A single fixture file becomes unmanageable past twenty definitions. Split by domain and merge at the test level.
// fixtures/auth.fixtures.ts
import { dataTest } from './data.fixtures';
import { LoginPage } from '../pages/LoginPage';
export const authTest = dataTest.extend<{ loginPage: LoginPage }>({
loginPage: async ({ page }, use) => {
await use(new LoginPage(page));
},
});
// fixtures/billing.fixtures.ts
import { dataTest } from './data.fixtures';
import { InvoicePage } from '../pages/InvoicePage';
export const billingTest = dataTest.extend<{ invoicePage: InvoicePage }>({
invoicePage: async ({ page }, use) => {
await use(new InvoicePage(page));
},
});
// tests/enterprise.spec.ts
import { mergeTests } from '@playwright/test';
import { authTest } from '../fixtures/auth.fixtures';
import { billingTest } from '../fixtures/billing.fixtures';
const enterpriseTest = mergeTests(authTest, billingTest);
enterpriseTest('enterprise billing workflow', async ({ loginPage, invoicePage, faker }) => {
// Test implementation
});
Rationale:
mergeTests combines fixture graphs without naming collisions. Namespacing isolates domain-specific dependencies. Parallel file edits no longer conflict, and CI memory usage drops because unused fixtures are never instantiated.
Pitfall Guide
1. Constructor State Capture
Explanation: Developers assign async values or DOM queries to instance properties inside constructors. Playwright's lazy evaluation masks the issue until parallel runs expose race conditions.
Fix: Use getters for locators. Move state retrieval to explicit async methods. Never perform I/O or DOM counting during initialization.
2. Shard-Blind Seeding
Explanation: Relying on workerIndex for data uniqueness fails because each CI shard maintains an independent worker counter. Identical seeds generate across agents, causing data collisions.
Fix: Combine testId, CI_BUILD_ID, and repeatEachIndex. Hash the composite string to produce a deterministic seed that remains unique across shards and retries.
3. Fixture Over-Engineering
Explanation: Wrapping pure utility functions (e.g., date formatters, math helpers, string parsers) in fixtures adds unnecessary async overhead and complicates imports.
Fix: Reserve fixtures for objects holding page context, requiring setup/teardown, or managing shared state. Import stateless utilities directly via ES6 modules.
4. Silent Name Collisions
Explanation: Merging fixtures without namespacing causes silent overrides. If auth.fixtures.ts and billing.fixtures.ts both define page, the last merge wins, breaking dependent fixtures.
Fix: Use mergeTests explicitly. Prefix domain-specific fixtures (e.g., authPage, billingPage). Audit merged graphs before scaling.
5. Flow-Page Coupling
Explanation: Flows directly manipulate DOM elements instead of delegating to Page Objects. This duplicates locator logic and breaks when UI structure changes.
Fix: Enforce strict layer boundaries. Flows call Page Object methods. Page Objects encapsulate locators and interactions. Tests call Flows.
6. Ignoring Teardown Hooks
Explanation: Tests create resources (users, API tokens, WebSocket connections) but never clean them up. Parallel runs exhaust rate limits or leave orphaned data.
Fix: Use the await use(resource) pattern in fixtures. Add cleanup logic after use to guarantee execution regardless of test outcome.
7. Hardcoded Test Data
Explanation: Embedding static emails, passwords, or IDs in tests creates brittle assertions. CI environments often reject duplicate records or enforce stricter validation.
Fix: Use factory functions with deterministic seeding. Override only fields relevant to the test case. Let factories handle noise data.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| < 50 tests, single developer | Inline fixtures in test file | Low overhead, fast iteration | Minimal setup time |
| 50β300 tests, growing team | Domain-split fixtures + mergeTests | Prevents merge conflicts, isolates dependencies | Moderate initial investment, high long-term ROI |
| > 300 tests, multi-shard CI | Full DI layer + deterministic seeding + Flow orchestration | Eliminates collisions, enables reproducible failures | High upfront cost, near-zero refactoring debt |
| Stateless utility needed | ES6 import, not fixture | Avoids async overhead and fixture graph complexity | Zero architectural cost |
| Cross-domain test required | mergeTests with explicit namespacing | Prevents silent overrides, maintains type safety | Low maintenance overhead |
Configuration Template
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 4 : undefined,
reporter: process.env.CI ? 'github' : 'list',
use: {
baseURL: process.env.BASE_URL || 'http://localhost:3000',
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { browserName: 'chromium' },
},
],
});
// fixtures/index.ts
import { mergeTests } from '@playwright/test';
import { coreTest } from './core.fixtures';
import { authTest } from './auth.fixtures';
import { dataTest } from './data.fixtures';
export const appTest = mergeTests(coreTest, authTest, dataTest);
Quick Start Guide
- Initialize fixture base: Create
fixtures/core.fixtures.ts and extend Playwright's base test. Define Page Object and Flow fixtures using async ({ page }, use) => { await use(new Class(page)); }.
- Add deterministic seeding: Implement
seedGenerator.ts with testId, CI_BUILD_ID, and repeatEachIndex. Attach to a faker fixture in data.fixtures.ts.
- Refactor existing tests: Replace
new Page() and new Flow() calls with fixture destructuring. Extract setup logic into Flow methods.
- Split and merge: When fixture files exceed 150 lines, extract domain-specific fixtures. Merge them in test files using
mergeTests.
- Validate CI reproducibility: Run a failing test locally with
CI_BUILD_ID=<failed-run-id> npx playwright test. Verify identical data generation and assertion behavior.