How AI is applied across API Evangelist and APIs.io. Read my AI disclosure →
API Evangelist API Evangelist
Discovery
Learnings
Guidance
Toolbox
Alignment
API Evangelist LLC

Your Playwright Tests Will Need Refactoring. Here's How to Make It Painless

calendar_today May 26, 2026 person Dmitry domain dev-to
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. TL;DR Never instantiate Page Objects with new inside tests — use fixtures Use getters instead of constructor assignments in Page Objects Seed your test data with a combination of testId + RUN_ID + repeatEachIndex for reproducibility Split fixtures by domain when the file gets large — use mergeTests Use Namespacing to avoid silent fixture name collisions What Is a Flow? (Quick Explainer) 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. Rule #1: Stop Using new Inside Tests This is the most common pattern that makes refactoring painful: // Every test manages its own dependencies test ( ‘ checkout ‘ , async ({ page }) => { const cartPage = new CartPage ( page ); const checkoutPage = new CheckoutPage ( page ); const checkoutFlow = new CheckoutFlow ( cartPage , checkoutPage ); await checkoutFlow . submitOrder (); }); 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 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 )); }, }); // The test reads like a specification test ( ‘ checkout ‘ , async ({ checkoutFlow }) => { await checkoutFlow . submitOrder (); }); 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: checkoutFlow : async ({ cartPage , checkoutPage }, use ) => { const flow = new CheckoutFlow ( cartPage , checkoutPage ); await use ( flow ); 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 class CartPage { 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: // Never do this constructor ( page : Page ) { ( async () => { this . itemCount = await page . locator ( ‘ .items ‘ ). count (); // race condition bomb })(); } 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 class CartPage { constructor ( private page : Page ) {} get submitButton () { return this . page . getByRole ( ‘ button ‘ , { name : ‘ Place order ‘ }); } // Named cartItems, not itemCount — this returns a locator, not a number get cartItems () { return this . page . locator ( ‘ .cart-item ‘ ); } // For actual count — explicit async method, not a getter async getItemCount (): Promise < number > { return this . cartItems . count (); } } The Page Object stays stateless. Reading state is always an explicit async operation, never something that happens silently at construction time. Rule #3: Isolate Test Data for Parallel Runs 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 // utils/faker.utils.ts 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 ); } export function seedFaker ( testInfo : TestInfo ) { const RUN_ID = process . env . RUN_ID   ’ local ‘ ; const seed = hashCode ( ` ${ testInfo . testId } - ${ RUN_ID } - ${ testInfo . repeatEachIndex } ` ); faker . seed ( seed ); return faker ; } // fixtures.ts export const test = base . extend ({ faker : async ({}, use , testInfo ) => { await use ( seedFaker ( testInfo )); }, }); Three components in the seed: 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 Random data everywhere creates noise. If a field doesn’t affect the test outcome, it shouldn’t be visible in the test. // user.factory.ts — sensible defaults export function createUser ( overrides ?: Partial < User > , f = faker ): User { return { id : f . string . uuid (), email : f . internet . email (), name : f . person . fullName (), role : ‘ customer ‘ , … overrides , }; } // In the test — only what matters 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: // data/datasets/users.ts export const VIP_USER = { role : ‘ vip ‘ , discount : 0.15 } as const ; // In tests const user = createUser ({ … VIP_USER }, faker ); 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 One fixtures.ts file is fine at the start. At 20+ fixtures it becomes a 400-line file that multiple people edit simultaneously. Split by domain: // auth.fixtures.ts import { test as base } from ‘ @playwright/test ‘ ; import { LoginPage } from ‘ ../pages/LoginPage ‘ ; export const authTest = base . extend < { loginPage : LoginPage } > ({ loginPage : async ({ page }, use ) => { await use ( new LoginPage ( page )); }, }); // cart.fixtures.ts import { test as base } from ‘ @playwright/test ‘ ; import { CartPage } from ‘ ../pages/CartPage ‘ ; export const cartTest = base . extend < { cartPage : CartPage } > ({ cartPage : async ({ page }, use ) => { await use ( new CartPage ( page )); }, }); // fixtures.ts — merge everything import { mergeTests } from ‘ @playwright/test ‘ ; import { authTest } from ‘ ./auth.fixtures ‘ ; import { cartTest } from ‘ ./cart.fixtures ‘ ; 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: // No collision possible import { test as base } from ‘ @playwright/test ‘ ; import { Admin } from ‘ ../pages/Admin ‘ ; import { User } from ‘ ../pages/User ‘ ; export const test = base . extend < { auth : { admin : Admin ; user : User } } > ({ auth : async ({ page }, use ) => { await use ({ admin : new Admin ( page ), user : new User ( page ), }); }, }); // In tests test ( ‘ admin can manage users ‘ , async ({ auth }) => { await auth . admin . login (); await auth . user . register (); }); Rule #6: Write Business Steps, Not Technical Logs If you use Allure or any step-based reporter, the quality of your step descriptions determines how useful the report is. The native Playwright way is test.step() : // Technical log — describes implementation async login () { await test . step ( ‘ Click the login button ‘ , async () => { await this . page . getByRole ( ‘ button ‘ , { name : ‘ Login ‘ }). click (); }); } // Business intent — describes what happened async loginAs ( user : User ) { await test . step ( Authenticate as " ${ user . username } " , async () => { await this . loginPage . login ( user . username , user . password ); }); } 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. ESLint: Enforce the Architecture Automatically The best rule is one that doesn’t require a code review comment: // .eslintrc.js module . exports = { overrides : [ { // Only applies inside test files — won’t flag Page Object factories or helpers files : [ ‘ tests//.ts ‘ , ‘ **/.spec.ts ‘ ], rules : { ‘ no-restricted-syntax ‘ : [ ‘ error ‘ , { selector : ‘ NewExpression[callee.name=/.Page$/] ‘ , message : ‘ Use fixtures instead of new for Page Objects. See fixtures.ts. ‘ , }, { selector : ‘ NewExpression[callee.name=/.Flow$/] ‘ , message : ‘ Use fixtures instead of new for Flow objects. See fixtures.ts. ‘ , }, ], }, }, ], }; Scoping to tests/ prevents false positives — new Pagination() in your app code won’t trigger this. Only new LoginPage() inside test files will. Architecture Cheat Sheet Symptom Root cause Fix Refactoring takes days new PageObject() in every test Move to fixtures Parallel tests corrupt each other’s data workerIndex as seed Seed with testId + RUN_ID Can’t reproduce CI failures locally Non-deterministic test data Seeded faker fixture fixtures.ts is 400 lines No domain separation mergeTests + domain files Fixture collision, wrong object used Flat fixture namespace Namespace by domain Report is unreadable Technical step descriptions test.step() with business intent (or @Step in BDR ) What’s Next? 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 . Want to go deeper? Check out the advanced version: Playwright Architecture at Scale: What Senior Engineers Do Differently All patterns in this article are implemented in the Playwright BDR Template on GitHub.
open_in_new Read original post