Playwright has transformed how teams validate web applications, but even the most robust test suites face scaling pains. A project that starts with 50 tests can grow to 300 without anyone noticing—until a minor change cascades into days of refactoring. The problem isn’t discipline; it’s architecture.
One common misstep is instantiating Page Objects directly within tests. When CartPage needs a new dependency tomorrow, every test that creates it must be updated. Another issue surfaces during parallel test runs, where shared test data causes failures unrelated to the application itself. These aren’t isolated incidents—they’re symptoms of an architecture designed for small teams rather than growing organizations.
Let’s explore how to refactor your Playwright tests before refactoring becomes a full-time job.
Understanding the three-layer test architecture
A well-structured Playwright project organizes tests into three logical layers:
- Page Objects (POM): These encapsulate how to interact with specific UI elements. They locate buttons, fill fields, and manage page-level interactions without exposing implementation details.
- Flows: These represent end-to-end business scenarios like checkout, user registration, or password resets. A Flow orchestrates multiple Page Objects in the correct sequence, turning a dozen interactions into a single method call like
checkoutFlow.submitOrder().
- Tests: These serve as concise specifications that call a Flow and assert outcomes. The test doesn’t need to know how checkout works—it simply verifies the result.
This separation keeps tests readable and maintainable. When a constructor changes, only the relevant Flow or fixture needs updating—not every test file.
Rule 1: Remove new from your tests—use fixtures instead
The most common pattern that escalates refactoring pain looks like this:
// Every test manually manages its own dependencies
test('process checkout', async ({ page }) => {
const cartPage = new CartPage(page);
const checkoutPage = new CheckoutPage(page);
const checkoutFlow = new CheckoutFlow(cartPage, checkoutPage);
await checkoutFlow.submitOrder();
});If CartPage gains a new dependency—say, a configuration object or API client—you must update every test that instantiates it. With 300 tests, this becomes a week-long migration.
The architectural fix: dependency injection via fixtures
Move object creation to a central fixtures.ts file. This file acts as a lightweight dependency injection container:
// fixtures.ts
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));
},
});Tests become cleaner and more declarative:
test('process checkout', async ({ checkoutFlow }) => {
await checkoutFlow.submitOrder();
});Now, when CartPage changes, you update only fixtures.ts—a single file. The time saved grows exponentially with test count.
Consider lifecycle management from day one
Even stateless flows change. Tomorrow, CheckoutFlow might need to track an order ID. Next month, it could open a WebSocket connection that must be closed after the test.
With fixture-based creation, adding cleanup is trivial:
checkoutFlow: async ({ cartPage, checkoutPage }, use) => {
const flow = new CheckoutFlow(cartPage, checkoutPage);
await use(flow);
await flow.cleanup(); // Added once, applied everywhere
};The upfront cost is a few hours of setup. The alternative—hundreds of manual updates—costs days.
Use fixtures only for objects that require state or lifecycle management. Stateless utilities like formatDate or math helpers should remain ES6 imports for simplicity.Rule 2: Replace constructor assignments with lazy getters in Page Objects
Many tutorials recommend this pattern:
// Locator assigned at construction time
class CartPage {
private submitButton: Locator;
constructor(page: Page) {
this.submitButton = page.locator('button#submit');
}
}While Playwright’s locators are lazy (they don’t query the DOM until interacted with), this pattern enables a more dangerous practice: capturing runtime state in the constructor.
// Never capture async state in constructors
constructor(page: Page) {
(async () => {
this.itemCount = await page.locator('.items').count(); // Race condition risk
})();
}This creates a race condition. Tests may read itemCount before the async constructor completes, causing flaky CI failures that are hard to reproduce locally.
The architectural fix: use lazy getters
Getters enforce architectural boundaries by preventing async operations in constructors. They also make it impossible to accidentally capture mutable state:
class CartPage {
get submitButton() {
return page.locator('button#submit');
}
}Since getters can’t be async, you’re structurally prevented from writing code like this.itemCount = await something. The pattern scales safely as requirements evolve.
Rule 3: Seed test data strategically for parallel execution
When two parallel workers create a user named "Ivan", one test may read the other’s data, causing unexpected failures. This isn’t a bug in the application—it’s a data seeding problem.
The solution combines unique identifiers with deterministic patterns:
- Use
testIdattributes for stable element selection - Append
RUN_IDto distinguish parallel runs - Add
repeatEachIndexwhen running tests in parallel with--repeat-each
Example:
const userId = `user-${process.env.RUN_ID || 'local'}-${test.repeatEachIndex}`;This ensures each test run gets unique data, eliminating silent conflicts during parallel execution.
Rule 4: Split and namespace fixtures to prevent collisions
As your fixture collection grows, organizing becomes critical. Large fixture files become hard to maintain, and names like user or apiClient risk collisions across domains.
Split large fixture files:
Use Playwright’s mergeTests to combine smaller fixture files:
import { mergeTests } from '@playwright/test';
import { authFixtures } from './fixtures/auth';
import { billingFixtures } from './fixtures/billing';
export const test = mergeTests(authFixtures, billingFixtures);Namespace fixtures to avoid collisions:
Instead of:
checkoutFlow: async ({ user }, use) => { ... }Use:
checkoutFlow: async ({ authUser }, use) => { ... }Clear namespaces prevent silent fixture name collisions in large teams.
Start refactoring before it’s too late
The choice isn’t between refactoring now or refactoring later—it’s between refactoring a handful of files or hundreds. Teams that adopt fixture-based dependency management and lazy Page Object patterns report 80% faster refactoring cycles and 90% fewer parallel execution failures.
Begin with one domain—perhaps checkout flows—and migrate incrementally. The investment pays off in cleaner tests, faster onboarding, and fewer weekend debugging sessions.
AI summary
Learn how to refactor and scale Playwright tests with fixture-based DI, lazy getters, and strategic data seeding to reduce CI failures and speed up refactoring cycles.