g;
tenantId: string;
amountCents: number;
currency: string;
state: InvoiceState;
dueDate: string;
}
### Step 2: Implement a Composable Builder Engine
Instead of scattering factory functions across files, centralize generation logic using a generic builder pattern. This approach enforces defaults, manages sequence generation, and supports nested composition.
```typescript
// support/builders/base-builder.ts
export abstract class FixtureBuilder<T> {
protected sequence: number = 0;
protected abstract defaults: Partial<T>;
public build(overrides: Partial<T> = {}): T {
this.sequence += 1;
return {
...this.defaults,
...overrides,
} as T;
}
protected generateId(prefix: string): string {
return `${prefix}_${this.sequence}_${Date.now()}`;
}
}
Now implement domain-specific builders that extend the base class. Notice how the BillingRecord builder composes the TenantAccount builder to maintain referential integrity without duplicating logic.
// support/builders/tenant-builder.ts
import { FixtureBuilder } from './base-builder';
import { TenantAccount, TenantStatus } from '../contracts';
export class TenantBuilder extends FixtureBuilder<TenantAccount> {
protected defaults: Partial<TenantAccount> = {
tenantId: '',
organizationName: 'Acme Corp',
primaryEmail: '',
status: 'trial',
maxSeats: 10,
};
public build(overrides: Partial<TenantAccount> = {}): TenantAccount {
const base = super.build(overrides);
const id = base.tenantId || this.generateId('tenant');
const email = base.primaryEmail || `admin_${this.sequence}@acme.test`;
return { ...base, tenantId: id, primaryEmail: email };
}
public withSuspendedStatus(overrides: Partial<TenantAccount> = {}): TenantAccount {
return this.build({ status: 'suspended', ...overrides });
}
}
// support/builders/invoice-builder.ts
import { FixtureBuilder } from './base-builder';
import { BillingRecord, InvoiceState } from '../contracts';
import { TenantBuilder } from './tenant-builder';
export class InvoiceBuilder extends FixtureBuilder<BillingRecord> {
private tenantBuilder = new TenantBuilder();
protected defaults: Partial<BillingRecord> = {
invoiceId: '',
tenantId: '',
amountCents: 4999,
currency: 'USD',
state: 'pending',
dueDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
};
public build(overrides: Partial<BillingRecord> = {}): BillingRecord {
const base = super.build(overrides);
const id = base.invoiceId || this.generateId('inv');
// Resolve tenant reference if not explicitly provided
const resolvedTenantId = base.tenantId || this.tenantBuilder.build().tenantId;
return { ...base, invoiceId: id, tenantId: resolvedTenantId };
}
public asOverdue(overrides: Partial<BillingRecord> = {}): BillingRecord {
return this.build({
state: 'overdue',
dueDate: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
...overrides
});
}
}
Step 3: Integrate API Seeding for State Isolation
UI-driven setup introduces latency and fragility. Playwright's APIRequestContext allows direct state injection, bypassing the browser layer while maintaining test isolation.
// support/api-seeder.ts
import { APIRequestContext } from '@playwright/test';
import { TenantAccount, BillingRecord } from './contracts';
export class StateSeeder {
constructor(private request: APIRequestContext) {}
public async provisionTenant(payload: TenantAccount): Promise<TenantAccount> {
const res = await this.request.post('/api/v1/tenants', { data: payload });
if (!res.ok()) {
throw new Error(`Tenant provisioning failed: ${await res.text()}`);
}
return res.json();
}
public async registerInvoice(payload: BillingRecord): Promise<BillingRecord> {
const res = await this.request.post('/api/v1/invoices', { data: payload });
if (!res.ok()) {
throw new Error(`Invoice registration failed: ${await res.text()}`);
}
return res.json();
}
}
Step 4: Execute in Playwright Specs
The final layer consumes the builders and seeder within isolated test contexts. The spec remains focused on behavioral verification.
// tests/billing-portal.spec.ts
import { test, expect } from '@playwright/test';
import { TenantBuilder } from '../support/builders/tenant-builder';
import { InvoiceBuilder } from '../support/builders/invoice-builder';
import { StateSeeder } from '../support/api-seeder';
test('suspended tenant cannot access invoice download', async ({ page, request }) => {
const seeder = new StateSeeder(request);
const tenantBuilder = new TenantBuilder();
const invoiceBuilder = new InvoiceBuilder();
const tenant = tenantBuilder.withSuspendedStatus();
const invoice = invoiceBuilder.build({ tenantId: tenant.tenantId });
await seeder.provisionTenant(tenant);
await seeder.registerInvoice(invoice);
await page.goto(`/auth/login`);
await page.getByLabel('Email').fill(tenant.primaryEmail);
await page.getByLabel('Password').fill('SecurePass_99!');
await page.getByRole('button', { name: 'Authenticate' }).click();
await page.goto(`/billing/${invoice.invoiceId}`);
await expect(page.getByRole('heading', { name: 'Access Restricted' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Download PDF' })).toBeHidden();
});
Architecture Rationale:
- Generic Base Class: Eliminates repetitive sequence management and enforces a consistent
build(overrides) contract across all domains.
- Explicit ID Resolution: Prevents orphaned records by ensuring child entities inherit parent identifiers unless explicitly overridden.
- API-First Seeding: Reduces test execution time by ~70% compared to UI navigation, while guaranteeing database consistency before browser interaction begins.
- Type-Safe Overrides:
Partial<T> ensures developers only specify relevant fields, reducing boilerplate and preventing accidental mutation of unrelated properties.
Pitfall Guide
1. Exporting Singleton Fixtures
Explanation: Developers often export a single pre-configured object (e.g., export const defaultUser = buildUser()) to avoid repetition. This creates shared mutable state across parallel tests, causing race conditions and assertion failures.
Fix: Always invoke the builder function inside the test scope. Never export instantiated objects. If you need reusable templates, export factory functions or configuration objects, not resolved data.
2. Over-Abstracting Business Logic
Explanation: Factories sometimes evolve into mini-application layers, embedding complex validation, conditional branching, or external service calls. This obscures test intent and makes failures harder to trace.
Fix: Keep builders deterministic and synchronous. They should only construct data shapes. Move validation, transformation, or async operations into dedicated service helpers or test hooks.
3. Unseeded Randomness
Explanation: Using Math.random() or uncontrolled UUID generation without tracking makes failures non-reproducible. When a test fails, you cannot reconstruct the exact input that triggered the bug.
Fix: Use deterministic counters, timestamps, or seeded PRNGs. Log generated IDs in test reports so failures can be replayed with identical payloads.
4. Tight Coupling to UI Selectors in Setup
Explanation: Embedding Playwright locator logic inside factory builders (e.g., page.fill() calls) violates separation of concerns. Factories should remain framework-agnostic and focused purely on data generation.
Fix: Restrict builders to pure TypeScript functions. Keep all browser interactions, navigation, and DOM manipulation strictly within the test spec or dedicated page object methods.
5. Ignoring Referential Integrity
Explanation: Creating child records without resolving parent dependencies leads to foreign key violations or orphaned data. This is especially problematic when tests run in parallel against a shared test database.
Fix: Implement composition patterns where child builders accept parent IDs or automatically resolve them via nested builder instances. Always validate ID propagation before API calls.
6. Bypassing Backend Validation Rules
Explanation: Factories that generate structurally valid but semantically invalid data (e.g., negative prices, expired dates, invalid email formats) may pass UI checks but fail at the API layer, masking real validation bugs.
Fix: Align factory defaults with your backend's validation schema. Use the same validation libraries or constraints in your test builders to ensure generated data reflects production boundaries.
7. Neglecting Cleanup Strategies
Explanation: Seeding data without teardown leaves residual records that accumulate over time, eventually exhausting database quotas or causing ID collisions in long-running CI environments.
Fix: Implement deterministic cleanup hooks using Playwright's test.afterEach or database transaction rollbacks. Prefer ephemeral test databases or schema-scoped prefixes that can be truncated safely.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Single-test validation | UI-only setup | Simplest for isolated checks; no API dependencies required | Low dev time, high execution latency |
| Parallel CI pipeline | Factory + API seeding | Guarantees isolation, prevents collisions, reduces runtime by 60-80% | Moderate initial setup, high ROI in CI |
| Legacy monolith with no API | Mocked state injection | Bypasses missing endpoints; useful when backend is inaccessible | High maintenance, limited real-world fidelity |
| Compliance/Security testing | Deterministic factory + audit logging | Ensures reproducible edge cases; meets audit trail requirements | Low cost, high reliability |
Configuration Template
// support/fixtures.ts
import { test as base, APIRequestContext } from '@playwright/test';
import { StateSeeder } from './api-seeder';
import { TenantBuilder } from './builders/tenant-builder';
import { InvoiceBuilder } from './builders/invoice-builder';
type TestFixtures = {
seeder: StateSeeder;
tenantBuilder: TenantBuilder;
invoiceBuilder: InvoiceBuilder;
};
export const test = base.extend<TestFixtures>({
seeder: async ({ request }, use) => {
await use(new StateSeeder(request));
},
tenantBuilder: async ({}, use) => {
await use(new TenantBuilder());
},
invoiceBuilder: async ({}, use) => {
await use(new InvoiceBuilder());
},
});
export { expect } from '@playwright/test';
Quick Start Guide
- Initialize the builder structure: Create
support/builders/ and support/contracts.ts. Define your domain interfaces and extend the FixtureBuilder base class for each entity.
- Configure Playwright fixtures: Register your builders and
StateSeeder in a custom test.extend() block to inject them automatically into every spec.
- Replace setup code: Locate tests with manual UI navigation or hardcoded objects. Swap them with builder calls and
seeder.provision*() methods.
- Run in parallel: Execute
npx playwright test --workers=4. Verify that tests complete without state collisions and that generated IDs remain unique per run.
- Add teardown: Implement a
test.afterEach hook that calls your database cleanup utility or wraps tests in a transaction to ensure zero residual data.