DEV Community

Rizwan Saleem
Rizwan Saleem

Posted on

How to Build a Reliable Test Data Factory for Playwright QA

How to Build a Reliable Test Data Factory for Playwright QA

How to Build a Reliable Test Data Factory for Playwright QA

A test data factory gives your QA suite consistent, realistic, and easy-to-change data without littering tests with fragile setup code. It is especially useful when you want clean browser isolation, maintainable fixtures, and repeatable end-to-end flows in Playwright.

Why a factory helps

Most flaky tests do not fail because the browser is broken; they fail because the data is messy, shared, or created inconsistently. Playwright already gives each test an isolated browser context, which is a strong base for repeatable tests. A test data factory extends that idea to your application state: instead of hand-writing users, orders, or products in every test, you generate them through a small set of reusable builders. That keeps your tests focused on behavior, not setup noise.

A good factory also makes negative testing easier. You can create a valid user by default, then override just one field to test an error path, which is the main advantage of test data builders described in testing practice guides.

What you will build

In this tutorial, you will create a small TypeScript test data factory for a fictional app with users and orders. The same pattern works for any Playwright project because Playwright tests are designed around isolated fixtures and reusable setup. You will end with:

  • A UserFactory and OrderFactory.
  • Default data with targeted overrides.
  • A helper for creating API-backed test data.
  • Playwright tests that stay short and readable.

Project shape

Use a simple structure like this:

tests/
  checkout.spec.ts
  account.spec.ts
support/
  factories/
    user-factory.ts
    order-factory.ts
  api-client.ts
  types.ts
Enter fullscreen mode Exit fullscreen mode

This keeps business objects separate from test scripts, which aligns with Playwright’s guidance to keep reusable setup isolated from individual tests. It also makes it easy to swap implementation details later without rewriting all your specs.

Define your types

Start by describing the data you create. Strong types help your factory stay honest and make overrides safer.

// support/types.ts
export type UserRole = 'customer' | 'admin';

export interface User {
  id: string;
  email: string;
  firstName: string;
  lastName: string;
  role: UserRole;
  active: boolean;
}

export interface Order {
  id: string;
  userId: string;
  items: Array<{
    sku: string;
    name: string;
    quantity: number;
    price: number;
  }>;
  status: 'draft' | 'paid' | 'shipped';
}
Enter fullscreen mode Exit fullscreen mode

This is intentionally minimal. Your factory should model only the fields that matter to the test, then let the rest default.

Build a base factory

A factory usually needs three things: defaults, overrides, and a way to create unique values. Test-data-builder guidance strongly favors defaults so each test only specifies the fields relevant to the scenario.

// support/factories/user-factory.ts
import { User, UserRole } from '../types';

let userSeq = 0;

function nextId() {
  userSeq += 1;
  return `user_${userSeq}`;
}

function uniqueEmail(firstName: string, lastName: string) {
  return `${firstName.toLowerCase()}.${lastName.toLowerCase()}.${userSeq}@example.test`;
}

export function buildUser(overrides: Partial<User> = {}): User {
  const id = overrides.id ?? nextId();
  const firstName = overrides.firstName ?? 'Alex';
  const lastName = overrides.lastName ?? 'Taylor';

  return {
    id,
    email: overrides.email ?? uniqueEmail(firstName, lastName),
    firstName,
    lastName,
    role: overrides.role ?? 'customer',
    active: overrides.active ?? true,
  };
}

export function buildAdmin(overrides: Partial<User> = {}): User {
  return buildUser({ role: 'admin', ...overrides });
}
Enter fullscreen mode Exit fullscreen mode

Notice the pattern: defaults first, then selective overrides. That gives you a predictable object shape while still making edge cases easy to express.

Add nested object support

Orders usually depend on a user and contain items, so the order factory should compose the user factory instead of duplicating user setup.

// support/factories/order-factory.ts
import { Order } from '../types';
import { buildUser } from './user-factory';

let orderSeq = 0;

function nextOrderId() {
  orderSeq += 1;
  return `order_${orderSeq}`;
}

export function buildOrder(overrides: Partial<Order> = {}): Order {
  const user = buildUser();

  return {
    id: overrides.id ?? nextOrderId(),
    userId: overrides.userId ?? user.id,
    items: overrides.items ?? [
      {
        sku: 'SKU-001',
        name: 'Test Product',
        quantity: 1,
        price: 19.99,
      },
    ],
    status: overrides.status ?? 'draft',
  };
}
Enter fullscreen mode Exit fullscreen mode

This keeps related data consistent by default. If a test needs a specific user-to-order relationship, it can still override userId explicitly.

Use the factory in a test

Here is a straightforward Playwright test that creates one user and one order, then checks the checkout flow. Playwright’s isolated browser contexts help ensure this test does not inherit state from another test.

// tests/checkout.spec.ts
import { test, expect } from '@playwright/test';
import { buildUser } from '../support/factories/user-factory';
import { buildOrder } from '../support/factories/order-factory';

test('customer can see their draft order', async ({ page }) => {
  const user = buildUser({ firstName: 'Mina', lastName: 'Patel' });
  const order = buildOrder({ userId: user.id });

  await page.goto('/login');
  await page.getByLabel('Email').fill(user.email);
  await page.getByLabel('Password').fill('Password123!');
  await page.getByRole('button', { name: 'Sign in' }).click();

  await page.goto(`/orders/${order.id}`);
  await expect(page.getByRole('heading', { name: 'Order details' })).toBeVisible();
  await expect(page.getByText(order.id)).toBeVisible();
});
Enter fullscreen mode Exit fullscreen mode

The important part is that the test reads like a user story, not a setup script. The factory hides the object construction, which makes the spec easier to scan and maintain.

Create data through the API

For many QA suites, UI-only setup is too slow. A better pattern is to create state through an API helper, then verify the behavior in the browser. This keeps tests faster and closer to how real systems are used, while still benefiting from isolated test data.

// support/api-client.ts
import { APIRequestContext } from '@playwright/test';
import { User, Order } from './types';

export class ApiClient {
  constructor(private request: APIRequestContext) {}

  async createUser(user: User) {
    const response = await this.request.post('/api/users', { data: user });
    if (!response.ok()) throw new Error('Failed to create user');
    return response.json();
  }

  async createOrder(order: Order) {
    const response = await this.request.post('/api/orders', { data: order });
    if (!response.ok()) throw new Error('Failed to create order');
    return response.json();
  }
}
Enter fullscreen mode Exit fullscreen mode

Then use it in a test:

// tests/account.spec.ts
import { test, expect } from '@playwright/test';
import { ApiClient } from '../support/api-client';
import { buildAdmin } from '../support/factories/user-factory';

test('admin can view the dashboard', async ({ page, request }) => {
  const api = new ApiClient(request);
  const admin = buildAdmin({ firstName: 'Rita', lastName: 'Chen' });

  await api.createUser(admin);

  await page.goto('/login');
  await page.getByLabel('Email').fill(admin.email);
  await page.getByLabel('Password').fill('Password123!');
  await page.getByRole('button', { name: 'Sign in' }).click();

  await page.goto('/admin');
  await expect(page.getByRole('heading', { name: 'Admin dashboard' })).toBeVisible();
});
Enter fullscreen mode Exit fullscreen mode

This pattern is especially useful when you need authenticated users or specific role-based scenarios, which security testing guidance also emphasizes as part of authorization checks.

Handle edge cases cleanly

A factory becomes most valuable when it makes difficult scenarios easy to express. Instead of hand-building invalid objects, create small helper variants.

export function buildInactiveUser(overrides: Partial<User> = {}): User {
  return buildUser({ active: false, ...overrides });
}

export function buildPaidOrder(overrides: Partial<Order> = {}): Order {
  return buildOrder({ status: 'paid', ...overrides });
}
Enter fullscreen mode Exit fullscreen mode

Now a test for account lockout or a paid-order confirmation can be written with one line of setup. That is the practical payoff of test data builders: fewer moving parts and clearer intent.

Avoid common mistakes

The biggest mistake is letting factories become mini applications. Keep them small, deterministic, and easy to override. Do not hide too much logic inside a factory, or you will trade test clutter for debugging pain.

Also avoid sharing mutable objects across tests. Playwright’s browser contexts are isolated, but your JavaScript objects are not automatically protected if you reuse them carelessly. Return fresh objects from every factory call, and avoid exporting a single shared “default user” object.

Another mistake is relying on random data without control. Random values can be useful, but your tests should still be reproducible. Prefer a simple sequence or seeded generator so failures can be recreated reliably.

A practical workflow

Use this workflow when introducing factories into an existing QA codebase:

  1. Identify the three most repeated setup objects in your tests.
  2. Extract each object into a factory with safe defaults.
  3. Replace raw object literals in tests with factory calls.
  4. Add one or two named variants for common scenarios like admin, inactive, or paid.
  5. Move slow setup into API helpers where possible.

This incremental approach works well because it improves test readability without forcing a big rewrite. It also aligns with Playwright’s emphasis on reusable test structure and isolated execution.

Example factory checklist

Before you merge a new factory, check these items:

  • It returns a fresh object each time.
  • It has sensible defaults.
  • It supports partial overrides.
  • It can create common variants with short helpers.
  • It does not depend on hidden global state.
  • It keeps test files shorter, not longer.

If a factory passes those checks, it is usually helping more than hurting.

Final pattern

The simplest mental model is: factory for shape, test for behavior, API helper for state, and Playwright for isolation. That combination gives you faster setup, clearer tests, and fewer brittle assumptions about what data already exists. Used consistently, it turns test data from a source of flakiness into one of the most maintainable parts of your QA suite.

-

Rizwan Saleem | https://rizwansaleem.co

Top comments (0)