Playwright Fixtures as a Dependency Injection Container: The Architecture That Scales

Playwright Fixtures as a Dependency Injection Container: The Architecture That Scales PRO IMPLEMENTATION
Section titled “Playwright Fixtures as a Dependency Injection Container: The Architecture That Scales ”New to Playwright architecture? Start with the fundamentals: Your Playwright Tests Will Need Refactoring. Here’s How to Make It Painless — the same concepts with more explanation.
Most Playwright codebases start the same way: Page Objects instantiated with new inside tests, fixtures as an afterthought, test data seeded with workerIndex. This works at 50 tests. At 500, the maintenance cost becomes visible. At 1000, it becomes the primary engineering problem.
This article is about the architectural decisions that prevent that progression — specifically, treating Playwright’s fixture system as a proper DI container, not just a convenience wrapper around beforeEach.
Code examples are intentionally simplified — focus on the architectural pattern.
Three-Layer Architecture: POM, Flow, and Tests
Section titled “Three-Layer Architecture: POM, Flow, and Tests”##TL;DR
Fixtures are a DI container with lifecycle management — not just a beforeEach wrapper. Getters enforce statelessness architecturally, not stylistically. Seed from testId + RUN_ID + repeatEachIndex — workerIndex breaks across shards. Domain-split fixtures with namespacing eliminate silent collisions. Builder pattern when factory overrides get unwieldy. test.step() for business-intent reporting.
Before diving into fixtures, it’s worth establishing the architectural model this article assumes. Most Playwright codebases that scale well use three layers:
- Page Object (POM) — responsible for interacting with elements on a specific page: locators, clicks, form fills. Knows nothing about business logic or test scenarios.
- Flow — describes complete business scenarios: “checkout”, “user registration”, “password reset”. Orchestrates Page Objects in the right sequence. The test calls
checkoutFlow.submitOrder()and Flow handles which pages to visit, in what order, and what data to fill. - Test — declares intent. Reads like a specification: given this user, when this action, then this result.
This separation matters because changes are isolated: UI changes only touch Page Objects, process changes only touch Flows, tests remain stable. Fixtures are what make this architecture work — they manage the lifecycle of all three layers.
Why new Inside Tests Is a Scaling Problem
Section titled “Why new Inside Tests Is a Scaling Problem”The naive approach looks like this:
test('checkout flow', async ({ page }) => { const cartPage = new CartPage(page); const checkoutPage = new CheckoutPage(page); const checkoutFlow = new CheckoutFlow(cartPage, checkoutPage);
await checkoutFlow.submitOrder();});At first glance this is fine — explicit, readable, no magic. The problem surfaces when CartPage needs a new dependency. Now every test that constructs CartPage needs updating. In a 500-test suite, that’s a multi-day refactor with non-trivial regression risk.
The deeper issue: this pattern makes the test responsible for dependency resolution. That’s not the test’s job.
Fixtures as a DI Container
Section titled “Fixtures as a DI Container”Playwright’s fixture system is, architecturally, a dependency injection container with lifecycle management. The key insight is that fixtures compose:
export const test = base.extend({ cartPage: async ({ page }, use) => { await use(new CartPage(page)); },
checkoutPage: async ({ page }, use) => { await use(new CheckoutPage(page)); },
// Playwright resolves dependencies automatically checkoutFlow: async ({ cartPage, checkoutPage }, use) => { const flow = new CheckoutFlow(cartPage, checkoutPage); await use(flow); await flow.cleanup(); // teardown guaranteed regardless of test outcome },});Playwright builds the dependency graph, resolves it in the correct order, and handles teardown. If five tests depend on cartPage, Playwright creates one instance per test — not five, not one shared instance. The isolation is automatic.
The caching behavior matters: when multiple fixtures in the same test depend on the same fixture (e.g., both checkoutFlow and analyticsFlow depend on cartPage), Playwright creates exactly one cartPage instance for that test. This isn’t just an optimization — it means the two flows share state correctly, as they would in a real user session.
The Lifecycle Argument for Fixtures
Section titled “The Lifecycle Argument for Fixtures”Here’s the argument that matters for long-lived codebases: use fixtures even when the object seems stateless today.
CheckoutFlow might be a pure orchestrator right now — no state, no side effects, no external connections. But requirements change:
- Next sprint: Flow needs to track an order ID for verification
- Month after: Flow opens a WebSocket for real-time updates
- Quarter later: Flow acquires a distributed lock that must be released
Each of these changes requires teardown. If CheckoutFlow is created with new in 300 tests, adding teardown means touching 300 files. If it’s in a fixture, you add one after use block:
checkoutFlow: async ({ cartPage, checkoutPage }, use) => { const flow = new CheckoutFlow(cartPage, checkoutPage); await use(flow); await flow.releaseLock(); // added once, applies everywhere await flow.closeConnection();};The fixture system gives you lifecycle management for free. The upfront investment is real — a few hours to set up the pattern properly. The cost of retrofitting it later: proportional to the number of tests.
The Pragmatic Rule: When Fixtures Are Overkill
Section titled “The Pragmatic Rule: When Fixtures Are Overkill”Everything above is an argument for fixtures. Here’s the counterargument, because a good architecture isn’t about dogma.
Fixtures make sense when an object needs one or more of the following:
- Lifecycle management — setup before the test, teardown after
- Shared dependencies — the object depends on
page,request, or another fixture - Potential for state — today stateless, but realistically might not be tomorrow
When none of these apply, a fixture is unnecessary indirection. A pure utility function — one that takes inputs and returns outputs with no side effects and no browser context — doesn’t belong in a fixture system. It belongs in a module:
// Just a function — no fixture neededexport function formatOrderId(id: string): string { return `ORD-${id.toUpperCase()}`;}
// Factory function — pure, no browser contextexport function createUser(overrides?: Partial<User>): User { return { role: 'customer', discount: 0, ...overrides };}
// Fixture — depends on page, has implicit lifecyclecartPage: async ({ page }, use) => { await use(new CartPage(page));};The decision rule: if an object touches the browser context or has any chance of needing teardown as the codebase evolves — fixture. If it’s a pure function or a data factory with no external dependencies — just export it and call it directly.
The cost of using fixtures when you don’t strictly need it: near zero. The cost of not using them when you should have: proportional to the number of tests you need to update.
Putting everything into fixtures because “it might need lifecycle someday” is the same mistake as premature optimization. It adds indirection without value and makes the codebase harder to read. The goal is judgment, not consistency for its own sake.
Lazy POM: Why Getters Beat Constructor Assignments
Section titled “Lazy POM: Why Getters Beat Constructor Assignments”The standard Page Object pattern assigns locators in the constructor:
// Technically safe, architecturally suboptimalclass CartPage { private readonly submitButton: Locator;
constructor(private page: Page) { this.submitButton = page.locator('button#submit'); }}Playwright locators are lazy — they don’t query the DOM at construction time, they query it at the moment of interaction. So a locator assigned in the constructor won’t go stale: even if the DOM re-renders between construction and use, Playwright finds the element fresh when you call .click() or .isVisible(). This is technically fine.
The problem is what this pattern enables: the temptation to compute actual state in the constructor.
// This is a race condition bombconstructor(page: Page) { (async () => { this.initialItemCount = await page.locator('.item').count(); })();}The IIFE fires and is forgotten. The test accesses initialItemCount before the promise resolves. In a fast local environment this usually works. Under CI load with multiple workers competing for resources, it fails intermittently and is nearly impossible to reproduce.
The architectural fix: getters enforce statelessness
class CartPage { constructor(private page: Page) {}
// Evaluated fresh on every access — no state, no race conditions get submitButton() { return this.page.getByRole('button', { name: 'Place order' }); }
get items() { return this.page.locator('.cart-item'); }
// For computed state, return a promise explicitly async getItemCount(): Promise<number> { return this.items.count(); }}Getters make it structurally impossible to cache state at construction time. The Page Object is forced to be stateless — it can only describe how to find elements and interact with them, not what their current state is. Reading state is always an explicit async operation.
This is the State Trap pattern in reverse: instead of accidentally capturing a DOM snapshot at construction time, you’re architecturally prevented from doing so.
Deterministic Test Data at Scale
Section titled “Deterministic Test Data at Scale”workerIndex as a faker seed is the most common data isolation mistake. The reasoning seems sound: each worker gets a unique number, so data is unique. The failure mode is subtle.
On 10 parallel CI shards, each shard has its own “Worker 0”, “Worker 1”, etc. The workerIndex namespace is shard-local. If the same test runs on Shard 1 Worker 0 and Shard 2 Worker 0 — during a retry, or due to shard misconfiguration — both generate identical data for the same testId. In a shared database, this means collisions — and the kind of intermittent failures that look like application bugs.
The correct seed: combine test identity with CI build ID
import { TestInfo } from '@playwright/test';import { faker } from '@faker-js/faker';
function hashCode(str: string): number { return str.split('').reduce((acc, char) => { return (Math.imul(31, acc) + char.charCodeAt(0)) | 0; }, 0); // Note: not cryptographically secure, but collision probability is negligible // for the number of tests in any realistic suite — fine for faker seeding}
export function seedFaker(testInfo: TestInfo): typeof faker { const RUN_ID = process.env.RUN_ID ?? 'local';
// Three components: // testId: hash of file path + test name — unique per test, stable across runs // RUN_ID: CI build ID — different builds get different data // repeatEachIndex: handles retries — same test run gets same data on retry const seed = hashCode(`${testInfo.testId}-${RUN_ID}-${testInfo.repeatEachIndex}`);
faker.seed(seed); return faker;}export const test = base.extend({ faker: async ({}, use, testInfo) => { await use(seedFaker(testInfo)); },});The repeatEachIndex component is worth explaining: when a test retries, it runs on potentially a different worker. Without repeatEachIndex in the seed, a retry would generate different data than the original run. If the failure was data-dependent, you can’t reproduce it. With repeatEachIndex, retries are deterministic — same seed, same data, reproducible failure.
The debugging payoff: when a test fails in CI, take the RUN_ID from the pipeline logs and run the test locally with RUN_ID=<value> npx playwright test <test-name>. You get the exact data that was generated in CI. This transforms “I can’t reproduce this” into a reproducible failure in under a minute.
Factory Pattern: Separating Structure From Noise
Section titled “Factory Pattern: Separating Structure From Noise”Random data everywhere obscures test intent. If a field doesn’t affect the outcome, it shouldn’t be visible in the test.
export interface User { id: string; email: string; name: string; role: 'customer' | 'vip' | 'admin'; discount: number;}
export function createUser(overrides?: Partial<User>, f: typeof faker = faker): User { return { id: f.string.uuid(), email: f.internet.email(), name: f.person.fullName(), role: 'customer', discount: 0, ...overrides, };}The factory provides structure and defaults. Overrides express what the test actually cares about:
// Only the meaningful fields are visibletest('VIP discount applied at checkout', async ({ checkoutFlow, faker }) => { const user = createUser({ role: 'vip', discount: 0.15 }, faker); const order = await checkoutFlow.asUser(user).checkout();
expect(order.total).toBe(order.subtotal * 0.85);});For business scenarios that repeat across multiple tests, extract named datasets rather than duplicating overrides:
export const VIP_USER = { role: 'vip', discount: 0.15,} as const satisfies Partial<User>;
export const ADMIN_USER = { role: 'admin', discount: 0,} as const satisfies Partial<User>;// In tests — intent is immediately clearconst user = createUser({ ...VIP_USER }, faker);The satisfies operator here is doing real work: it validates that the dataset fields match the User type without widening the type. If someone adds a required field to User and forgets to update the dataset, TypeScript catches it at compile time.
When to consider the Builder pattern instead
The factory + overrides approach works well when objects are simple and combinations are limited. When complexity grows — a user with a role, subscription tier, notification preferences, and order history — the override object becomes unwieldy:
// Hard to read at a glanceconst user = createUser( { role: 'vip', subscription: 'premium', notifications: { email: true, sms: false }, orderCount: 3, }, faker,);A Builder makes the same intent readable:
// Builder — reads like a specificationconst user = new UserBuilder(faker) .asVip() .withPremiumSubscription() .withNotifications({ email: true, sms: false }) .withOrderHistory(3) .build();class UserBuilder { private overrides: Partial<User> = {};
constructor(private f: typeof faker) {}
asVip() { this.overrides.role = 'vip'; this.overrides.discount = 0.15; return this; }
withPremiumSubscription() { this.overrides.subscription = 'premium'; return this; }
withOrderHistory(count: number) { this.overrides.orderCount = count; return this; }
build(): User { return createUser(this.overrides, this.f); }}The Builder delegates to the factory at the end — so you keep one source of truth for defaults, and the Builder just provides a fluent API for complex combinations. Use it when you have more than 3–4 meaningful combinations that appear repeatedly across tests. For simpler cases, the factory with overrides is less code and just as clear.
Scaling Fixtures: mergeTests and Namespacing
Section titled “Scaling Fixtures: mergeTests and Namespacing”A single fixtures.ts file works until it doesn’t. The inflection point is usually around 15–20 fixtures, when multiple engineers are editing the same file simultaneously and merge conflicts become routine.
Domain-driven fixture splitting:
import { test as base } from '@playwright/test';import { LoginPage, AdminPage } from '../pages';
type AuthFixtures = { loginPage: LoginPage; adminPage: AdminPage };
export const authTest = base.extend<AuthFixtures>({ loginPage: async ({ page }, use) => { await use(new LoginPage(page)); }, adminPage: async ({ page }, use) => { await use(new AdminPage(page)); }});
// cart.fixtures.tstype CartFixtures = { cartPage: CartPage; checkoutPage: CheckoutPage };
export const cartTest = base.extend<CartFixtures>({ ... });
// fixtures.ts — composition pointimport { mergeTests } from '@playwright/test';import { authTest } from './auth.fixtures';import { cartTest } from './cart.fixtures';
export const test = mergeTests(authTest, cartTest);export { expect } from '@playwright/test';Tests import from fixtures.ts and see nothing change. The split is organizational, not behavioral.
The silent collision problem:
mergeTests doesn’t check for fixture name conflicts. If auth.fixtures.ts and billing.fixtures.ts both export a user fixture, the last one registered wins — silently. Tests that worked before mergeTests may start using a different user object without any error.
Namespacing eliminates this class of bug:
type AuthFixtures = { auth: { admin: Admin; user: User; guest: Guest; };};
export const authTest = base.extend<AuthFixtures>({ auth: async ({ page }, use) => { await use({ admin: new Admin(page), user: new User(page), guest: new Guest(page), }); },});
// Collision is now structurally impossible// auth.user vs billing.user — different namespaces, different objectstest('admin manages billing', async ({ auth, billing }) => { await auth.admin.login(); await billing.user.subscribe();});The namespace also makes test code self-documenting: auth.admin vs billing.user is unambiguous in a way that two separate admin and user fixtures are not.
Business Steps: test.step and BDR
Section titled “Business Steps: test.step and BDR”The quality of step descriptions in your reports determines how useful they are for debugging. The native Playwright tool is test.step():
// Technical log — breaks when implementation changesasync login() { await test.step('Click the login button', async () => { await this.page.getByRole('button', { name: 'Login' }).click(); });}
// Business intent — survives refactoringasync loginAs(user: User) { await test.step(`Authenticate as "${user.username}"`, async () => { await this.loginPage.login(user.username, user.password); });}The second version remains valid even if the login mechanism changes from a form to SSO. The report reads like a scenario, not a sequence of DOM operations.
In BDR methodology, this pattern is formalized with a @Step decorator that wraps methods automatically — eliminating the manual test.step() wrapping. If you’re building at scale and want cleaner syntax, it’s worth exploring.
ESLint: Architectural Enforcement
Section titled “ESLint: Architectural Enforcement”The fixture architecture only works if objects aren’t created with new inside tests. Document the rule in code:
module.exports = { overrides: [ { // Scoped to test files only — won't flag Pagination or other non-POM classes files: ['tests/**/*.ts', '**/*.spec.ts'], rules: { 'no-restricted-syntax': [ 'error', { selector: 'NewExpression[callee.name=/.*Page$/]', message: 'Instantiate Page Objects via fixtures, not new. See fixtures.ts.', }, { selector: 'NewExpression[callee.name=/.*Flow$/]', message: 'Instantiate Flow objects via fixtures, not new. See fixtures.ts.', }, ], }, }, ],};When a genuine exception exists — a factory function that creates a Page Object for testing purposes, for instance — the escape hatch is // eslint-disable-next-line with a mandatory comment:
// eslint-disable-next-line no-restricted-syntax// Factory function — not a test file, constructing for unit testing POM behaviorconst page = new LoginPage(mockPage);The comment makes the exception visible and reviewable. Blanket disables without explanation are a red flag in code review.
The Architecture in Summary
Section titled “The Architecture in Summary”| Decision | Wrong | Right | Why |
|---|---|---|---|
| Object creation | new PageObject() in tests | Fixtures | Single update point when constructor changes |
| Locator definition | Constructor assignments | Getters | Prevents state capture, enforces statelessness |
| Faker seed | workerIndex | testId + RUN_ID + repeatEachIndex | Stable across shards and retries |
| Fixture organization | One monolithic file | Domain files + mergeTests | Parallel editing, clear ownership |
| Fixture naming | Flat namespace | Domain namespacing | Eliminates silent collisions |
| Architecture enforcement | Code review comments | ESLint rules scoped to tests/** | Automated, consistent, zero overhead |
| Step reporting | Technical descriptions | test.step() with business intent | Report reads like a scenario, not a DOM log |
What This Architecture Actually Solves
Section titled “What This Architecture Actually Solves”None of this is complex to implement. The fixture DI pattern is an afternoon. Seeded faker is 20 lines. Namespacing is a refactor you can do incrementally.
What it solves is the compounding cost of the alternative. Every new PageObject() in a test is a future refactoring touchpoint. Every workerIndex seed is a potential data collision waiting for sufficient parallelism to trigger. Every flat fixture namespace is a silent collision waiting for the second engineer to add a fixture with the same name.
The architecture described here doesn’t make tests faster or more readable in the short term. It makes the codebase cheaper to maintain as it grows — which is the only metric that matters at scale.
Reference implementation: Playwright BDR Template