DEV Community

Cover image for Your Playwright Tests Will Need Refactoring. Here's How to Make It Painless
Dmitry
Dmitry

Posted on • Originally published at bdr-methodology.dev

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

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

  1. Never instantiate Page Objects with new inside tests — use fixtures
  2. Use getters instead of constructor assignments in Page Objects
  3. Seed your test data with a combination of testId + RUN_ID + repeatEachIndex for reproducibility
  4. Split fixtures by domain when the file gets large — use mergeTests
  5. 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();
});
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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
};
Enter fullscreen mode Exit fullscreen mode

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');
  }
}
Enter fullscreen mode Exit fullscreen mode

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
  })();
}
Enter fullscreen mode Exit fullscreen mode

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();
  }
}
Enter fullscreen mode Exit fullscreen mode

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));
  },
});
Enter fullscreen mode Exit fullscreen mode

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,
  };
}
Enter fullscreen mode Exit fullscreen mode
// 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();
});
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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);
  });
}
Enter fullscreen mode Exit fullscreen mode

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.',
          },
        ],
      },
    },
  ],
};
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)