Back to Home

Playwright: DI and isolation for 1000 tests

The article describes the architecture for running 1000+ Playwright tests: getters in POM to avoid state traps, DI through fixtures with mergeTests, data isolation with RUN_ID seeding of Faker and factories. Suitable for middle/senior developers.

1000 parallel tests in Playwright without fails
Advertisement 728x90

Scaling Playwright Tests: From Page Objects to DI and Data Isolation

In SPAs with dynamic rendering, Page Object constructors create risks due to asynchronicity. Attempting to initialize state in the constructor via an IIFE leads to race conditions: the test accesses data before it's ready, which manifests in CI during parallel runs.

// Incorrect: state in constructor
constructor(page: Page) {
  (async () => {
    this.initialCount = await page.locator('.items').count();
  })();
}

Standard synchronous locator initialization is also suboptimal—the object captures a DOM snapshot at creation time. Getters solve the problem with late binding:

// Correct: getters for current DOM
get submitButton() {
  return this.page.getByRole('button', { name: 'Place Order' });
}

This ensures stateless behavior: each call sees the current DOM, without being tied to the initial state.

Google AdInline article slot

Dependency Injection via Playwright Fixtures

Direct object creation in tests (new LoginPage(page)) complicates refactoring: changing the constructor requires edits throughout the project. Fixtures act as a DI container with instance caching.

export const test = base.extend({
  cartPage: async ({ page }, use) => {
    await use(new CartPage(page));
  },
  checkoutFlow: async ({ cartPage, checkoutPage }, use) => {
    await use(new CheckoutFlow(cartPage, checkoutPage));
  }
});

test('place order', async ({ authFlow, checkoutFlow }) => {
  await authFlow.loginAs(user);
  await checkoutFlow.submitOrder();
});

Tests focus on specification, independent of implementation.

Scaling Fixtures: mergeTests and Namespacing

With 20+ fixtures, a monolithic file becomes unmanageable. Split by domain with mergeTests:

Google AdInline article slot
// auth.fixtures.ts
export const authTest = base.extend({ ... });

// fixtures.ts
import { mergeTests } from '@playwright/test';
export const test = mergeTests(authTest, cartTest);

Avoid naming conflicts via namespacing:

export const test = base.extend({
  auth: async ({ page }, use) => {
    await use({ admin: new Admin(page), user: new User(page) });
  }
});

test('Admin can log in', async ({ auth }) => {
  await auth.admin.loginAs();
});

Data Isolation in Parallel Runs

workerIndex is insufficient for CI with shards. Use a combination:

  • RUN_ID from CI (e.g., GITHUB_RUN_ID)
  • testId—hash of the test path
  • repeatEachIndex for retries

Seeding Faker with this composite key ensures reproducibility:

Google AdInline article slot
export function seedFaker(testInfo: TestInfo) {
  const RUN_ID = process.env.RUN_ID || 'local';
  const seed = hashCode(`${testInfo.testId}-${RUN_ID}`);
  faker.seed(seed);
  return faker;
}

export const test = base.extend({
  faker: async ({}, use, testInfo) => {
    await use(seedFaker(testInfo));
  }
});

Local runs with CI-RUN_ID reproduce exact data from failures.

Data Layers: Factories, Overrides, Datasets

  • Faker: noise for non-critical fields (names, email)
  • Factories: structure with defaults
  • Overrides: critical changes in tests
  • Datasets: recurring business cases
export function createUser(overrides?: Partial<User>, f = faker): User {
  return {
    id: f.string.uuid(),
    name: f.person.fullName(),
    role: 'customer',
    ...overrides
  };
}

export const VIP_USER = { role: 'vip', discount: 0.15 };

Test: const vipUser = createUser({ ...VIP_USER }, faker);

Step Semantics and ESLint Guards

Separate technical clicks from business logic with @Step:

@Step('Placing order #${orderId}')
async submitOrder(orderId: string) {
  await this.checkoutPage.fillDetails(orderId);
  await this.checkoutPage.submit();
}

Business steps ('Login') are more resilient to UI changes than technical ones ('Click Login button').

ESLint prohibits direct new PageObject:

{
  "no-restricted-syntax": ["error", {
    "selector": "NewExpression[callee.name=/.*Page$/]",
    "message": "Use fixtures instead of new"
  }]
}

Key Takeaways

  • Getters in POM ensure current DOM without state traps
  • Fixtures as DI minimize refactoring with 1000+ tests
  • RUN_ID + testId seeds Faker for stability in CI shards
  • Factories + datasets isolate data without redundant code
  • Namespacing fixtures prevents collisions in mergeTests

— Editorial Team

Advertisement 728x90

Read Next