level 04 / design-patterns

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' }],
});
Key insight: Interview shorthand: Builder for test data, Factory for environment switching, Strategy for variant flows, fixtures for DI, and Singleton is a red flag in parallel test frameworks.

Try it: Builder + Strategy in pure TypeScript

Interview questions