You write 50 tests. Everything works. Six months later the team grows, tests become 300, and someone changes a constructor — and you spend two days updating imports across the entire project. Sound familiar?
This isn’t a discipline problem. It’s an architecture problem. And it’s fixable before it happens.
Code examples are simplified for clarity — focus on the idea, not the boilerplate.
Before we dive in — this article uses the term Flow, which might be unfamiliar.
In a well-structured Playwright project, tests are built in three layers:
Page Object (POM) — knows how to interact with elements on a specific page: find a button, fill a field, click a link
Flow — knows how to complete a business scenario: “checkout”, “register a user”, “reset a password”. It orchestrates Page Objects in the right sequence so tests don’t have to
Test — just calls the Flow and checks the result
So when you see checkoutFlow.submitOrder() in a test, that one line is hiding a sequence of page navigations, form fills, and button clicks — all managed by the Flow. The test doesn’t need to know the details.
The Problem: Architecture That Fights You at Scale
At 50 tests, messy architecture is invisible. At 300 tests, it becomes expensive. Two separate problems compound each other:
Data isolation breaks in parallel runs. Two workers create a user named “Ivan”, one test reads the other’s data, both fail. You spend an hour debugging something that has nothing to do with your application. This is a data seeding problem — solved in Rule #3.
Refactoring takes days instead of hours. Someone changes a constructor signature. Now you have 150 files to update. With modern tools this is still risky — you might miss one. This is a dependency management problem — solved in Rule #1.
Tests are impossible to read. Ten lines of setup before the actual test logic. New team members can’t tell what’s being tested and what’s just noise. This too is a dependency management problem — when setup lives in fixtures, tests read like specifications.
If CartPage needs a new dependency tomorrow — a logger, a config object, an API client — you update every single test that creates it. That’s your two days of refactoring.
The fix: fixtures as a DI container
// fixtures.ts — one place to manage all object creation
When CartPage constructor changes — you update fixtures.ts. One file. Done.
Why fixtures even when Flow seems stateless today:
Your CheckoutFlow might be pure today — no state, no side effects. But requirements change. Tomorrow it needs to track an order ID. Next month it opens a WebSocket connection that needs to be closed after the test.
If Flow is created via new in every test, adding teardown means updating hundreds of files. If it’s in a fixture, you add after use cleanup in one place:
await flow.cleanup(); // added in one place, applies everywhere
};
The upfront investment is real — a few hours to set up fixtures properly. The cost of refactoring later: days, proportional to how many tests you have.
A note on pragmatism: Fixtures are for managing state and lifecycle. If you have a stateless utility function — like formatDate or a math helper — don’t wrap it in a fixture. A simple ES6 import is faster and less complex. Use fixtures for things that hold a page context or require setup/teardown. Everything else is just a function.
Rule #2: Use Getters in Page Objects, Not Constructor Assignments
This is subtle but important. Most tutorials show this:
// Locator computed once at construction time
classCartPage {
private submitButton:Locator;
constructor(page:Page) {
this.submitButton= page.locator('button#submit');
}
}
This looks fine. Playwright locators are lazy — they don’t query the DOM at construction time, they query it when you interact with them. So assigning a locator in the constructor is technically safe.
The real danger is what this pattern enables — the temptation to capture actual state in the constructor:
This creates an unmanaged race condition. Your test might read itemCount before the async function inside the constructor has resolved. This causes random CI failures that are nearly impossible to reproduce locally.
The fix: lazy getters
Getters are the architectural solution — not because they prevent stale locators (Playwright handles that), but because they make it structurally impossible to capture state at construction time. A getter can’t be async, so you physically can’t write this.itemCount = await something inside one.
// Fresh locator on every access, stateless by design
When you run 1000 tests in parallel across multiple CI shards, data collisions are inevitable — unless you design against them.
The common mistake is using workerIndex as a seed for test data. It seems logical: each worker gets a unique number, so data should be unique. The problem is that workerIndex resets per shard. On 10 parallel CI agents, each has its own “Worker 0”. Collisions are guaranteed.
The fix: combine test identity with CI build ID — not worker index
testId — unique hash of the test file path and test name
RUN_ID — the CI build ID (e.g. GITHUB_RUN_ID), so different builds get different data
repeatEachIndex — handles retries correctly
Note:RUN_ID is an environment variable provided by your CI system — for example, GITHUB_RUN_ID in GitHub Actions. If it’s missing, the code falls back to 'local', so everything works on your machine without any extra setup.
The payoff: when a test fails in CI, grab the RUN_ID from the pipeline logs, run the test locally with the same ID, and you get the exact same names, emails, and UUIDs that were generated in CI. Reproducible failures instead of “I can’t reproduce this locally.”
Rule #4: Structure Test Data With Factories and Overrides
test('VIP discount applies at checkout', async({ checkoutFlow, faker })=> {
const user = createUser({ role: 'vip', discount: 0.15 }, faker);
await checkoutFlow.asUser(user).applyPromo();
});
The test declares intent, not implementation. When you read it, you know exactly what’s being tested: VIP role and discount. Everything else — name, email, UUID — is noise that the factory handles.
For data that represents specific business cases and appears repeatedly, extract it as a named dataset:
Pro tip: Use the satisfies operator (TypeScript 4.9+) instead of as const for datasets. It ensures your data matches the User type without losing the specific literal values — catching type errors before you even run the test:
export const VIP_USER = {
role: 'vip',
discount: 0.15,
} satisfies Partial<User>;
If someone adds a required field to User and forgets to update the dataset, TypeScript will tell you at compile time, not at runtime.
Rule #5: Scale Fixtures With mergeTests and Namespacing
export const test = mergeTests(authTest, cartTest);
Tests don’t change at all — they still import from fixtures.ts. The split is purely organizational.
Watch out for name collisions:
If auth.fixtures.ts and cart.fixtures.ts both define a fixture called user, Playwright won’t warn you. The last one wins silently. This creates subtle bugs that are very hard to track down.
The fix is namespacing — group fixtures by domain:
The first version breaks when you rename the button. The second version remains valid even if the entire login mechanism changes from a form to SSO. The report reads like a scenario, not a DOM manipulation log.
In BDR methodology we use a @Step decorator instead of wrapping every method manually — same result, cleaner syntax. If you’re interested in that approach, check it out.
This architecture handles the object lifecycle and data isolation. The next layer is async reliability — expect.poll, idempotency keys for parallel API calls, and cleaning up test data without relying on afterEach.