Design Patterns for Test Frameworks
Singleton, Factory, Builder, Strategy, and Dependency Injection — applied to the problems test frameworks actually have.
Builder — test data without noise
The most used pattern in test frameworks. Defaults plus targeted overrides:
interface User {
username: string;
email: string;
role: 'admin' | 'customer';
balance: number;
}
export function buildUser(overrides: Partial<User> = {}): User {
return {
username: 'standard_user',
email: 'user@test.example',
role: 'customer',
balance: 100,
...overrides,
};
}
// Each test states ONLY what matters to it
const broke = buildUser({ balance: 0 });
const admin = buildUser({ role: 'admin' });
For multi-step construction, a class builder:
export class OrderBuilder {
private items: { sku: string; qty: number }[] = [];
private coupon?: string;
withItem(sku: string, qty = 1): this {
this.items.push({ sku, qty });
return this;
}
withCoupon(code: string): this {
this.coupon = code;
return this;
}
build(): Order {
return { items: this.items, coupon: this.coupon, createdAt: new Date() };
}
}
const order = new OrderBuilder().withItem('BACKPACK').withCoupon('SAVE10').build();
Factory — create by name
When tests need “a page object for X” or “a client for environment Y” decided at runtime:
type Env = 'local' | 'staging' | 'production';
export function createApiClient(env: Env): ApiClient {
const baseURLs: Record<Env, string> = {
local: 'http://localhost:3000',
staging: 'https://staging.api.example.com',
production: 'https://api.example.com',
};
return new ApiClient(baseURLs[env], { retries: env === 'local' ? 0 : 2 });
}
Strategy — swap behaviour, keep the flow
One checkout flow, several payment methods. The flow stays identical; the payment step is a pluggable strategy:
interface PaymentStrategy {
pay(page: Page, amount: number): Promise<void>;
}
const cardPayment: PaymentStrategy = {
async pay(page, amount) {
await page.getByLabel('Card number').fill('4111111111111111');
await page.getByRole('button', { name: 'Pay' }).click();
},
};
const paypalPayment: PaymentStrategy = {
async pay(page, amount) {
const [popup] = await Promise.all([
page.waitForEvent('popup'),
page.getByRole('button', { name: 'PayPal' }).click(),
]);
// ...handle PayPal popup...
},
};
async function checkoutWith(strategy: PaymentStrategy, page: Page) {
// identical flow before and after — only payment varies
await strategy.pay(page, 42.99);
}
Dependency Injection — fixtures ARE your DI container
Playwright’s test.extend is a typed DI container with lifecycle management:
type Services = {
apiClient: ApiClient;
testUser: User;
};
export const test = base.extend<Services>({
apiClient: async ({}, use) => {
const client = createApiClient(process.env.TEST_ENV as Env ?? 'staging');
await use(client);
await client.dispose(); // teardown after the test
},
testUser: async ({ apiClient }, use) => { // depends on apiClient
const user = await apiClient.createUser(buildUser());
await use(user);
await apiClient.deleteUser(user.username); // cleanup guaranteed
},
});
Declaration order doesn’t matter; the dependency graph does. Playwright
resolves testUser → apiClient automatically and tears down in reverse.
Singleton — the pattern to avoid
// ❌ Classic singleton config — hidden global state
export class Config {
private static instance: Config;
static get(): Config {
if (!Config.instance) Config.instance = new Config();
return Config.instance;
}
}
Singletons and parallel workers don’t mix: each worker is a separate process,
so the “single” instance silently becomes N instances — fine until it holds a
port, a file lock, or mutable state. Playwright already gives you the right
scopes: test-scoped fixtures (per test) and worker-scoped fixtures (per
process). Use those instead.
// ✅ Worker-scoped fixture — explicit, typed, parallel-safe
export const test = base.extend<{}, { dbPool: DbPool }>({
dbPool: [async ({}, use) => {
const pool = await DbPool.connect(process.env.DB_URL!);
await use(pool);
await pool.close();
}, { scope: 'worker' }],
});