A test that hardcodes alice@example.com creates a uniqueness conflict on the second run if the previous test's cleanup failed. Faker.js solves this with generated data, but calling faker.internet.email() twice (once to fill the form, once to assert it appears) produces two different values: generate once, store in a variable, use everywhere. This article covers the factory function pattern that generates all fields once and accepts overrides for what the test actually cares about, UUID-embedded emails that guarantee no collisions, API-based seeding that bypasses the registration UI entirely, and Playwright fixtures that run cleanup unconditionally whether the test passed or failed.

The hardcoded data problem

Hardcoded data breaks in three ways, and each one compounds the others.

The first is uniqueness conflicts. Most real applications enforce unique emails, unique usernames, unique order numbers. If your test uses alice@example.com and the test ran yesterday and left that row in the database, today's run fails at the point of creation, not because the feature is broken, but because cleanup never happened.

The second is shared state. Five tests that all operate on item-id-42 are five tests waiting to interfere with each other. In parallel runs, the collisions happen constantly. In sequential runs, they happen just often enough to seem random.

The third is brittleness in the data itself. A hardcoded date of 2024-01-15 that was "in the future" when you wrote the test is now two years in the past. A status of "pending" that made sense for a workflow has been renamed to "awaiting_approval". Every hardcoded value is a future maintenance burden.

// What you want to stop doing
test('user can update their profile', async ({ page }) => {
  // These will eventually conflict or go stale
  await loginAs(page, 'alice@example.com', 'password123');
  await page.goto('/profile');
  await page.getByLabel('Display name').fill('Alice Smith');
  await page.getByRole('button', { name: 'Save' }).click();
  await expect(page.getByText('Profile updated')).toBeVisible();
});

The fix isn't to be more careful with hardcoded values. It's to stop hardcoding them.

Faker.js: installation and the basics

Faker.js is a library for generating realistic fake data. Names, emails, addresses, UUIDs, dates, phone numbers, product names: all random, all plausible.

npm install --save-dev @faker-js/faker

The API groups generators by category. Here are the ones you'll use most in test suites:

import { faker } from '@faker-js/faker';

// Identity
faker.person.firstName()           // 'Marcus'
faker.person.lastName()            // 'Holloway'
faker.internet.email()             // 'marcus.holloway@gmail.com'
faker.internet.username()          // 'marcus_holloway42'
faker.internet.password({ length: 12 }) // 'Kd9$mXp2vLqR'

// IDs and references
faker.string.uuid()                // 'e2d4f6a8-...'
faker.number.int({ min: 1, max: 9999 }) // 7342

// Dates
faker.date.future()                // Date object in the future
faker.date.past({ years: 2 })      // Date object in the past 2 years
faker.date.between({ from: '2025-01-01', to: '2025-12-31' }) // Date in range

// Content
faker.lorem.sentence()             // 'Voluptas et dolorem rerum.'
faker.commerce.productName()       // 'Sleek Rubber Shoes'
faker.commerce.price()             // '42.99'

Seed Faker with faker.seed(12345) at the top of a test file to make generation deterministic. The same seed produces the same sequence of values every time. This is useful for debugging a flaky test that depends on specific generated values: run it with a seed, capture the data, reproduce the failure reliably.

One important habit: generate the value once and store it in a variable. Don't call faker.internet.email() in two places expecting the same result.

// Wrong — two different emails
await page.getByLabel('Email').fill(faker.internet.email());
await expect(page.getByText(faker.internet.email())).toBeVisible(); // different value!

// Right — generate once, use everywhere
const email = faker.internet.email();
await page.getByLabel('Email').fill(email);
await expect(page.getByText(email)).toBeVisible();

Factory functions: buildUser() and buildOrder()

A factory function is a regular TypeScript function that returns a complete data object. It uses Faker for defaults but accepts overrides so tests can specify what actually matters to them.

// factories/user.factory.ts
import { faker } from '@faker-js/faker';

export interface User {
  firstName: string;
  lastName: string;
  email: string;
  password: string;
  role: 'admin' | 'editor' | 'viewer';
  createdAt: Date;
}

export function buildUser(overrides: Partial<User> = {}): User {
  return {
    firstName: faker.person.firstName(),
    lastName: faker.person.lastName(),
    email: faker.internet.email(),
    password: faker.internet.password({ length: 12 }),
    role: 'viewer',
    createdAt: faker.date.past({ years: 1 }),
    ...overrides,
  };
}

// factories/order.factory.ts
import { faker } from '@faker-js/faker';

export interface Order {
  id: string;
  customerId: string;
  items: { productId: string; quantity: number; price: number }[];
  status: 'pending' | 'processing' | 'shipped' | 'delivered';
  total: number;
  placedAt: Date;
}

export function buildOrder(overrides: Partial<Order> = {}): Order {
  const items = Array.from({ length: faker.number.int({ min: 1, max: 4 }) }, () => ({
    productId: faker.string.uuid(),
    quantity: faker.number.int({ min: 1, max: 5 }),
    price: parseFloat(faker.commerce.price({ min: 5, max: 200 })),
  }));

  const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0);

  return {
    id: faker.string.uuid(),
    customerId: faker.string.uuid(),
    items,
    status: 'pending',
    total: parseFloat(total.toFixed(2)),
    placedAt: new Date(),
    ...overrides,
  };
}

In tests, you only specify what the test cares about. Everything else fills in automatically:

// A test that only cares about the user's role
const adminUser = buildUser({ role: 'admin' });

// A test that only cares about order status
const shippedOrder = buildOrder({ status: 'shipped' });

// A test that needs a specific email (for login)
const user = buildUser({ email: 'known-test-user@example.com' });

This keeps tests expressive. When you see buildUser({ role: 'admin' }), you immediately know the role is what matters for this test. The 10 other fields are irrelevant noise that Faker handled for you.

Builder pattern for complex test objects

For objects with many interdependent fields (where setting one property should probably change another), a builder class with a fluent API is more readable than a factory function with a large overrides object.

// builders/UserBuilder.ts
import { faker } from '@faker-js/faker';
import { User } from '../factories/user.factory';

export class UserBuilder {
  private user: User;

  constructor() {
    this.user = {
      firstName: faker.person.firstName(),
      lastName: faker.person.lastName(),
      email: faker.internet.email(),
      password: faker.internet.password({ length: 12 }),
      role: 'viewer',
      createdAt: new Date(),
    };
  }

  withRole(role: User['role']): UserBuilder {
    this.user.role = role;
    return this;
  }

  withEmail(email: string): UserBuilder {
    this.user.email = email;
    return this;
  }

  withName(firstName: string, lastName: string): UserBuilder {
    this.user.firstName = firstName;
    this.user.lastName = lastName;
    return this;
  }

  asAdmin(): UserBuilder {
    this.user.role = 'admin';
    this.user.email = `admin-${faker.string.uuid().slice(0, 8)}@company.com`;
    return this;
  }

  createdDaysAgo(days: number): UserBuilder {
    const date = new Date();
    date.setDate(date.getDate() - days);
    this.user.createdAt = date;
    return this;
  }

  build(): User {
    return { ...this.user };
  }
}

The call site reads almost like plain English:

const admin = new UserBuilder().asAdmin().build();

const recentUser = new UserBuilder()
  .withRole('editor')
  .createdDaysAgo(3)
  .build();

const namedUser = new UserBuilder()
  .withName('Jordan', 'Reeves')
  .withRole('viewer')
  .build();

The builder shines when asAdmin() needs to set multiple fields together (role, email domain, and maybe an isVerified flag) and you'd rather not scatter that logic across every test that creates an admin user.

Use factory functions for simple objects and builder classes for anything where preset combinations of fields are common. They compose well too: a factory function can internally use a builder if the object is complex.

API-based data seeding

Creating test data through the UI is slow and fragile. A registration flow that takes 8 seconds in the browser takes 80 milliseconds through the API. More importantly, UI-based setup couples your test to two features at once: if the registration form breaks, every test that uses it as setup also breaks, even if those tests are about something completely different.

Playwright's request fixture gives you an API context you can use directly in setup code.

// helpers/api.helpers.ts
import { APIRequestContext } from '@playwright/test';
import { buildUser, User } from '../factories/user.factory';

const BASE_URL = process.env.API_BASE_URL ?? 'https://lab.becomeqa.com/api';

export async function createUserViaApi(
  request: APIRequestContext,
  overrides: Partial<User> = {}
): Promise<User & { id: string }> {
  const userData = buildUser(overrides);

  const response = await request.post(`${BASE_URL}/users`, {
    data: userData,
    headers: {
      Authorization: `Bearer ${process.env.API_SEED_TOKEN}`,
      'Content-Type': 'application/json',
    },
  });

  if (!response.ok()) {
    throw new Error(`Failed to create user: ${response.status()} ${await response.text()}`);
  }

  const created = await response.json();
  return { ...userData, id: created.id };
}

export async function deleteUserViaApi(
  request: APIRequestContext,
  userId: string
): Promise<void> {
  await request.delete(`${BASE_URL}/users/${userId}`, {
    headers: { Authorization: `Bearer ${process.env.API_SEED_TOKEN}` },
  });
}

Tests that need a user just call the helper and get back a real database record instantly:

test('admin can deactivate a user account', async ({ page, request }) => {
  const user = await createUserViaApi(request, { role: 'editor' });

  await page.goto('/admin/users');
  await page.getByTestId(`user-row-${user.id}`).getByRole('button', { name: 'Deactivate' }).click();
  await page.getByRole('button', { name: 'Confirm' }).click();

  await expect(page.getByTestId(`user-row-${user.id}`).getByText('Inactive')).toBeVisible();

  await deleteUserViaApi(request, user.id); // explicit cleanup
});

This test verifies the admin deactivation UI without depending on the registration UI, the login UI, or any other path that could fail for unrelated reasons.

Never use production credentials or a production API token in tests, even for a "quick check." Use a dedicated test environment with its own credentials. Seed tokens belong in environment variables, not in committed code.

Data fixture with automatic cleanup

The test above handles cleanup manually. That works, but if the test throws before reaching deleteUserViaApi, the cleanup never runs and the user stays in the database. A Playwright fixture solves this by making cleanup unconditional.

// fixtures/data.fixture.ts
import { test as base, APIRequestContext } from '@playwright/test';
import { buildUser, User } from '../factories/user.factory';
import { createUserViaApi, deleteUserViaApi } from '../helpers/api.helpers';

type DataFixtures = {
  testUser: User & { id: string };
  testAdminUser: User & { id: string };
};

export const test = base.extend<DataFixtures>({
  testUser: async ({ request }, use) => {
    const user = await createUserViaApi(request);

    await use(user);

    // Runs after the test — pass or fail
    try {
      await deleteUserViaApi(request, user.id);
    } catch (error) {
      console.warn(`Cleanup failed for user ${user.id}:`, error);
    }
  },

  testAdminUser: async ({ request }, use) => {
    const user = await createUserViaApi(request, { role: 'admin' });

    await use(user);

    try {
      await deleteUserViaApi(request, user.id);
    } catch (error) {
      console.warn(`Cleanup failed for admin user ${user.id}:`, error);
    }
  },
});

export { expect } from '@playwright/test';

Tests now receive a fully created user and never touch cleanup themselves:

// tests/profile.spec.ts
import { test, expect } from '../fixtures/data.fixture';

test('user can update their display name', async ({ page, testUser }) => {
  await loginAs(page, testUser.email, testUser.password);
  await page.goto('/profile');

  await page.getByLabel('Display name').fill('New Display Name');
  await page.getByRole('button', { name: 'Save' }).click();

  await expect(page.getByText('Profile updated')).toBeVisible();
  // testUser is deleted after this line — automatically
});

test('admin can view user details', async ({ page, testAdminUser, testUser }) => {
  await loginAs(page, testAdminUser.email, testAdminUser.password);
  await page.goto(`/admin/users/${testUser.id}`);

  await expect(page.getByText(testUser.email)).toBeVisible();
  // Both users deleted after this test
});

The try/catch in the teardown block is intentional. If cleanup throws and you don't catch it, Playwright can surface a confusing secondary failure that obscures what actually went wrong in the test itself. Log the warning, but don't re-throw.

Worker-scoped data for read-only reference data

Some data doesn't change between tests: a catalog of product categories, a list of countries, a set of permission definitions. Creating and deleting this data for every test is wasteful when the data is never modified.

Worker-scoped fixtures create the data once per worker process and share it across every test in that worker. The fixture type moves to the second generic parameter of extend().

// fixtures/worker-data.fixture.ts
import { test as base } from '@playwright/test';

interface Category {
  id: string;
  name: string;
  slug: string;
}

type WorkerFixtures = {
  productCategories: Category[];
};

export const test = base.extend<{}, WorkerFixtures>({
  productCategories: [
    async ({ request }, use) => {
      // Seed the categories once per worker
      const created: Category[] = [];

      for (const name of ['Electronics', 'Books', 'Clothing']) {
        const response = await request.post('/api/categories', {
          data: { name, slug: name.toLowerCase() },
          headers: { Authorization: `Bearer ${process.env.API_SEED_TOKEN}` },
        });
        const category = await response.json();
        created.push(category);
      }

      await use(created);

      // Cleanup after all tests in the worker finish
      for (const category of created) {
        try {
          await request.delete(`/api/categories/${category.id}`, {
            headers: { Authorization: `Bearer ${process.env.API_SEED_TOKEN}` },
          });
        } catch (error) {
          console.warn(`Category cleanup failed for ${category.id}:`, error);
        }
      }
    },
    { scope: 'worker' },
  ],
});

export { expect } from '@playwright/test';

Tests in the same worker receive the same productCategories array. Because they only read from it, there's no interference between tests. If tests ran in parallel across multiple workers, each worker creates its own set, which is fine for reference data.

Never use worker scope for data that tests modify. If one test changes a shared piece of state, the tests running after it in the same worker will see the modified version, and you have subtle order-dependent failures that are hard to debug.

Handling uniqueness: UUID-based emails and collision-proof IDs

Even with Faker, uniqueness collisions can happen. Faker's internet.email() pulls from a pool of names and common domains, so marcus.holloway@gmail.com could appear twice in a long test run. For any field the database enforces as unique, you need a strategy that guarantees no repeats.

The most reliable approach is embedding a UUID in the value:

// helpers/unique.ts
import { faker } from '@faker-js/faker';

export function uniqueEmail(prefix = 'test'): string {
  const id = faker.string.uuid().slice(0, 8);
  return `${prefix}+${id}@test-suite.local`;
}

export function uniqueUsername(): string {
  const id = faker.string.uuid().slice(0, 8);
  return `user_${id}`;
}

export function uniqueSlug(base: string): string {
  const id = faker.string.uuid().slice(0, 6);
  return `${base}-${id}`.toLowerCase().replace(/\s+/g, '-');
}

These produce values like test+a3f9c2b1@test-suite.local and user_a3f9c2b1. Eight characters of UUID is 16^8 = 4.3 billion possible values, so collision probability in a test suite is effectively zero. Using a test-suite.local domain also makes it trivial to identify and bulk-delete test data if cleanup ever falls behind.

Update your factory to use these helpers:

// factories/user.factory.ts (updated)
import { faker } from '@faker-js/faker';
import { uniqueEmail, uniqueUsername } from '../helpers/unique';

export function buildUser(overrides: Partial<User> = {}): User {
  return {
    firstName: faker.person.firstName(),
    lastName: faker.person.lastName(),
    email: uniqueEmail(),          // guaranteed unique
    username: uniqueUsername(),    // guaranteed unique
    password: faker.internet.password({ length: 12 }),
    role: 'viewer',
    createdAt: faker.date.past({ years: 1 }),
    ...overrides,
  };
}

If your application validates email domains strictly, replace test-suite.local with a real domain you control, or configure a special test domain in your staging environment's allowed list. Some apps also reject + in email addresses, in which case use a UUID-prefixed subdomain format: a3f9c2b1.test@yourdomain.com.

For numeric IDs generated by a database sequence, the sequence handles uniqueness for you, so you don't need to pre-generate IDs at all. Just let the API return the created ID and use that in your test. Only generate IDs client-side when you're testing systems that accept client-provided IDs, like UUIDs stored directly in the database.

FAQ

Should I use a factory function or a builder class?

Factory functions are simpler and work for most cases. Use builders when you have several meaningful presets (like asAdmin(), asUnverified(), asSuspended()) that combine multiple field values. If you find yourself passing the same override object repeatedly, that's a sign a named builder method would be cleaner.

What if my API requires authentication to create test data?

Store the seed token in an environment variable and load it with process.env. For CI, inject the variable through your pipeline secrets (GitHub Actions: secrets.API_SEED_TOKEN). Never hardcode credentials in source files.

Can I compose multiple data fixtures in one test?

Yes. Request as many fixtures as the test needs: async ({ testUser, testAdminUser, productCategories }). Playwright resolves and creates all of them before the test runs, then tears all of them down afterward, in reverse order of creation.

How do I handle data cleanup if the test leaves the app in a broken state?

The fixture teardown runs regardless of test outcome. The try/catch pattern in the teardown block ensures that if the deletion fails (maybe the test already deleted the resource as part of the flow being tested), the error is logged but doesn't produce a false failure. If a resource was intentionally deleted by the test, check before deleting: if (response.status() !== 404) await deleteViaApi(id).

Is Faker.js production-safe to have as a devDependency only?

Yes. Install with --save-dev and import it only in test files and factory/helper files. It never ships to production. If you somehow use a factory in production code, TypeScript's module boundaries and tree-shaking will catch it, or you can enforce it with an ESLint rule.

→ See also: Test Data Management in Playwright: Strategies and Patterns | Custom Fixtures in Playwright: The Pattern That Makes Tests Read Like Prose | Test Isolation: Why Each Playwright Test Should Be Stateless