SurfSense/.cursor/skills/playwright-testing/test-data-management.md
2026-05-04 13:54:13 +05:30

35 KiB
Executable file

Test Data Management

When to use: Every test that interacts with data — user accounts, form inputs, entities, or any state that must exist before assertions run.

Quick Reference

Strategy Speed Isolation Complexity Best For
Inline data Instant Perfect None Simple value checks, form inputs
Factory functions Instant Perfect Low Unique identifiers, consistent shapes
Faker/random data Instant Perfect Low Realistic fields, edge-case discovery
Builder pattern Instant Perfect Medium Complex objects with many optional fields
API seeding Fast Perfect Medium Creating entities the app depends on
Database seeding Fast Good High Complex relational data, bulk setup
Storage state Fast Good Low Reusing authenticated sessions
Fixture-based setup Fast Perfect Medium Encapsulating setup + guaranteed teardown

Core principle: Every test creates its own data and cleans up after itself. No test should depend on data left behind by another test or pre-existing in the environment.

Patterns

Inline Test Data

Use when: The data is simple, unique to one test, and essential for understanding the assertion.

Avoid when: The same data shape repeats across many tests — extract a factory instead.

Inline data makes tests self-documenting. The reader sees exactly what matters without chasing imports.

TypeScript

// tests/contact-form.spec.ts
import { test, expect } from '@playwright/test';

test('submits contact form with valid data', async ({ page }) => {
  const name = 'Ada Lovelace';
  const email = `ada-${Date.now()}@example.com`;
  const message = 'Inquiry about analytics engine';

  await page.goto('/contact');
  await page.getByLabel('Name').fill(name);
  await page.getByLabel('Email').fill(email);
  await page.getByLabel('Message').fill(message);
  await page.getByRole('button', { name: 'Send' }).click();

  await expect(page.getByText('Thank you, Ada Lovelace')).toBeVisible();
});

JavaScript

// tests/contact-form.spec.js
const { test, expect } = require('@playwright/test');

test('submits contact form with valid data', async ({ page }) => {
  const name = 'Ada Lovelace';
  const email = `ada-${Date.now()}@example.com`;
  const message = 'Inquiry about analytics engine';

  await page.goto('/contact');
  await page.getByLabel('Name').fill(name);
  await page.getByLabel('Email').fill(email);
  await page.getByLabel('Message').fill(message);
  await page.getByRole('button', { name: 'Send' }).click();

  await expect(page.getByText('Thank you, Ada Lovelace')).toBeVisible();
});

Use Date.now() or crypto.randomUUID() for fields that must be unique (emails, usernames). This prevents collisions during parallel execution.


Factory Functions

Use when: Multiple tests need the same data shape with different values, or you need guaranteed uniqueness.

Avoid when: The data is trivial and only used once — inline it instead.

Factories centralize data creation logic. When the data shape changes, you update one function, not dozens of tests.

TypeScript

// tests/factories/user.factory.ts
export interface UserData {
  firstName: string;
  lastName: string;
  email: string;
  password: string;
}

let counter = 0;

export function createUserData(overrides: Partial<UserData> = {}): UserData {
  counter++;
  const id = `${Date.now()}-${counter}`;
  return {
    firstName: `Test`,
    lastName: `User${id}`,
    email: `testuser-${id}@example.com`,
    password: 'SecureP@ss123!',
    ...overrides,
  };
}
// tests/registration.spec.ts
import { test, expect } from '@playwright/test';
import { createUserData } from './factories/user.factory';

test('registers a new user', async ({ page }) => {
  const user = createUserData();

  await page.goto('/register');
  await page.getByLabel('First name').fill(user.firstName);
  await page.getByLabel('Last name').fill(user.lastName);
  await page.getByLabel('Email').fill(user.email);
  await page.getByLabel('Password').fill(user.password);
  await page.getByRole('button', { name: 'Create account' }).click();

  await expect(page.getByText(`Welcome, ${user.firstName}`)).toBeVisible();
});

test('rejects duplicate email', async ({ page }) => {
  const user = createUserData({ email: 'duplicate@example.com' });

  await page.goto('/register');
  await page.getByLabel('Email').fill(user.email);
  await page.getByLabel('Password').fill(user.password);
  await page.getByRole('button', { name: 'Create account' }).click();

  await expect(page.getByText('Email already registered')).toBeVisible();
});

JavaScript

// tests/factories/user.factory.js
let counter = 0;

function createUserData(overrides = {}) {
  counter++;
  const id = `${Date.now()}-${counter}`;
  return {
    firstName: 'Test',
    lastName: `User${id}`,
    email: `testuser-${id}@example.com`,
    password: 'SecureP@ss123!',
    ...overrides,
  };
}

module.exports = { createUserData };
// tests/registration.spec.js
const { test, expect } = require('@playwright/test');
const { createUserData } = require('./factories/user.factory');

test('registers a new user', async ({ page }) => {
  const user = createUserData();

  await page.goto('/register');
  await page.getByLabel('First name').fill(user.firstName);
  await page.getByLabel('Last name').fill(user.lastName);
  await page.getByLabel('Email').fill(user.email);
  await page.getByLabel('Password').fill(user.password);
  await page.getByRole('button', { name: 'Create account' }).click();

  await expect(page.getByText(`Welcome, ${user.firstName}`)).toBeVisible();
});

Faker / Random Data

Use when: You need realistic-looking data (names, addresses, phone numbers) or want to discover edge cases through randomness.

Avoid when: Debugging a failure — use a fixed seed to make it reproducible.

Always seed faker so failures are reproducible. Use testInfo.testId or a fixed seed per test file.

TypeScript

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

export function createFakerUser(seed?: number) {
  if (seed !== undefined) {
    faker.seed(seed);
  }
  return {
    firstName: faker.person.firstName(),
    lastName: faker.person.lastName(),
    email: faker.internet.email({ provider: 'testmail.example.com' }),
    phone: faker.phone.number(),
    address: {
      street: faker.location.streetAddress(),
      city: faker.location.city(),
      state: faker.location.state({ abbreviated: true }),
      zip: faker.location.zipCode(),
    },
  };
}
// tests/checkout.spec.ts
import { test, expect } from '@playwright/test';
import { createFakerUser } from './factories/faker-user.factory';

test('completes checkout with shipping address', async ({ page }, testInfo) => {
  // Seed with a stable value so re-runs produce the same data
  const user = createFakerUser(testInfo.workerIndex);

  await page.goto('/checkout');
  await page.getByLabel('Street address').fill(user.address.street);
  await page.getByLabel('City').fill(user.address.city);
  await page.getByLabel('State').fill(user.address.state);
  await page.getByLabel('ZIP code').fill(user.address.zip);
  await page.getByRole('button', { name: 'Place order' }).click();

  await expect(page.getByText('Order confirmed')).toBeVisible();
});

JavaScript

// tests/factories/faker-user.factory.js
const { faker } = require('@faker-js/faker');

function createFakerUser(seed) {
  if (seed !== undefined) {
    faker.seed(seed);
  }
  return {
    firstName: faker.person.firstName(),
    lastName: faker.person.lastName(),
    email: faker.internet.email({ provider: 'testmail.example.com' }),
    phone: faker.phone.number(),
    address: {
      street: faker.location.streetAddress(),
      city: faker.location.city(),
      state: faker.location.state({ abbreviated: true }),
      zip: faker.location.zipCode(),
    },
  };
}

module.exports = { createFakerUser };
// tests/checkout.spec.js
const { test, expect } = require('@playwright/test');
const { createFakerUser } = require('./factories/faker-user.factory');

test('completes checkout with shipping address', async ({ page }, testInfo) => {
  const user = createFakerUser(testInfo.workerIndex);

  await page.goto('/checkout');
  await page.getByLabel('Street address').fill(user.address.street);
  await page.getByLabel('City').fill(user.address.city);
  await page.getByLabel('State').fill(user.address.state);
  await page.getByLabel('ZIP code').fill(user.address.zip);
  await page.getByRole('button', { name: 'Place order' }).click();

  await expect(page.getByText('Order confirmed')).toBeVisible();
});

Always use a test-specific email domain (e.g., testmail.example.com) so faker-generated emails never hit real inboxes. The example.com domain is reserved by RFC 2606 and is safe for testing.


Builder Pattern

Use when: Objects have many optional fields, conditional logic, or multiple valid configurations. Common for product listings, user profiles, or form payloads with nested data.

Avoid when: The object has fewer than 5 fields — a factory with overrides is simpler.

TypeScript

// tests/builders/product.builder.ts
export interface Product {
  name: string;
  price: number;
  currency: string;
  category: string;
  description: string;
  inStock: boolean;
  tags: string[];
  variants: { size: string; color: string }[];
}

export class ProductBuilder {
  private product: Product = {
    name: `Product-${Date.now()}`,
    price: 29.99,
    currency: 'USD',
    category: 'Electronics',
    description: 'A test product',
    inStock: true,
    tags: [],
    variants: [],
  };

  withName(name: string): this {
    this.product.name = name;
    return this;
  }

  withPrice(price: number, currency = 'USD'): this {
    this.product.price = price;
    this.product.currency = currency;
    return this;
  }

  withCategory(category: string): this {
    this.product.category = category;
    return this;
  }

  outOfStock(): this {
    this.product.inStock = false;
    return this;
  }

  withTags(...tags: string[]): this {
    this.product.tags = tags;
    return this;
  }

  withVariant(size: string, color: string): this {
    this.product.variants.push({ size, color });
    return this;
  }

  build(): Product {
    return { ...this.product };
  }
}
// tests/product-catalog.spec.ts
import { test, expect } from '@playwright/test';
import { ProductBuilder } from './builders/product.builder';

test('displays out-of-stock badge for unavailable products', async ({ page, request }) => {
  const product = new ProductBuilder()
    .withName('Wireless Keyboard')
    .withCategory('Accessories')
    .outOfStock()
    .build();

  // Seed via API (see API Seeding pattern below)
  await request.post('/api/products', { data: product });

  await page.goto('/products');
  const card = page.getByRole('listitem').filter({ hasText: product.name });
  await expect(card.getByText('Out of Stock')).toBeVisible();
});

JavaScript

// tests/builders/product.builder.js
class ProductBuilder {
  constructor() {
    this.product = {
      name: `Product-${Date.now()}`,
      price: 29.99,
      currency: 'USD',
      category: 'Electronics',
      description: 'A test product',
      inStock: true,
      tags: [],
      variants: [],
    };
  }

  withName(name) {
    this.product.name = name;
    return this;
  }

  withPrice(price, currency = 'USD') {
    this.product.price = price;
    this.product.currency = currency;
    return this;
  }

  withCategory(category) {
    this.product.category = category;
    return this;
  }

  outOfStock() {
    this.product.inStock = false;
    return this;
  }

  withTags(...tags) {
    this.product.tags = tags;
    return this;
  }

  withVariant(size, color) {
    this.product.variants.push({ size, color });
    return this;
  }

  build() {
    return { ...this.product };
  }
}

module.exports = { ProductBuilder };
// tests/product-catalog.spec.js
const { test, expect } = require('@playwright/test');
const { ProductBuilder } = require('./builders/product.builder');

test('displays out-of-stock badge for unavailable products', async ({ page, request }) => {
  const product = new ProductBuilder()
    .withName('Wireless Keyboard')
    .withCategory('Accessories')
    .outOfStock()
    .build();

  await request.post('/api/products', { data: product });

  await page.goto('/products');
  const card = page.getByRole('listitem').filter({ hasText: product.name });
  await expect(card.getByText('Out of Stock')).toBeVisible();
});

API Seeding

Use when: Tests need entities to already exist (users, products, orders) and the app exposes APIs to create them. This is the default strategy for test data setup.

Avoid when: No API exists for the entity, or the API itself is what you are testing (use the UI or database instead).

API seeding is faster than UI-based setup, more maintainable than database seeding, and exercises real application logic. Prefer it over all other approaches when an API is available.

TypeScript

// tests/fixtures/api-data.fixture.ts
import { test as base, APIRequestContext } from '@playwright/test';
import { createUserData, UserData } from '../factories/user.factory';

type ApiDataFixtures = {
  apiUser: UserData & { id: string };
};

export const test = base.extend<ApiDataFixtures>({
  apiUser: async ({ request }, use) => {
    const userData = createUserData();

    // Create
    const response = await request.post('/api/users', { data: userData });
    const created = await response.json();
    const user = { ...userData, id: created.id };

    // Provide to test
    await use(user);

    // Cleanup — runs even if test fails
    await request.delete(`/api/users/${user.id}`);
  },
});

export { expect } from '@playwright/test';
// tests/user-profile.spec.ts
import { test, expect } from './fixtures/api-data.fixture';

test('edits user profile name', async ({ page, apiUser }) => {
  await page.goto(`/users/${apiUser.id}/profile`);
  await page.getByLabel('First name').fill('Updated');
  await page.getByRole('button', { name: 'Save' }).click();

  await expect(page.getByText('Profile updated')).toBeVisible();
});

JavaScript

// tests/fixtures/api-data.fixture.js
const { test: base } = require('@playwright/test');
const { createUserData } = require('../factories/user.factory');

const test = base.extend({
  apiUser: async ({ request }, use) => {
    const userData = createUserData();

    const response = await request.post('/api/users', { data: userData });
    const created = await response.json();
    const user = { ...userData, id: created.id };

    await use(user);

    await request.delete(`/api/users/${user.id}`);
  },
});

module.exports = { test, expect: require('@playwright/test').expect };
// tests/user-profile.spec.js
const { test, expect } = require('./fixtures/api-data.fixture');

test('edits user profile name', async ({ page, apiUser }) => {
  await page.goto(`/users/${apiUser.id}/profile`);
  await page.getByLabel('First name').fill('Updated');
  await page.getByRole('button', { name: 'Save' }).click();

  await expect(page.getByText('Profile updated')).toBeVisible();
});

For multi-entity seeding, compose fixtures:

TypeScript

// tests/fixtures/order-data.fixture.ts
import { test as base } from './api-data.fixture';

export const test = base.extend({
  apiOrder: async ({ request, apiUser }, use) => {
    const orderResponse = await request.post('/api/orders', {
      data: { userId: apiUser.id, items: [{ sku: 'WIDGET-001', qty: 2 }] },
    });
    const order = await orderResponse.json();

    await use(order);

    await request.delete(`/api/orders/${order.id}`);
  },
});

Database Seeding

Use when: No API exists for the data you need, you need bulk data, or you need to set up complex relational state that is cumbersome via API calls.

Avoid when: An API exists — API seeding is more maintainable and exercises real application logic. Database seeding couples tests to schema details.

TypeScript

// tests/fixtures/db.fixture.ts
import { test as base } from '@playwright/test';
import { Pool } from 'pg';

type DbFixtures = {
  db: Pool;
  seededOrg: { id: string; name: string };
};

export const test = base.extend<DbFixtures>({
  db: async ({}, use) => {
    const pool = new Pool({
      connectionString: process.env.TEST_DATABASE_URL,
    });
    await use(pool);
    await pool.end();
  },

  seededOrg: async ({ db }, use) => {
    const orgName = `TestOrg-${Date.now()}`;
    const result = await db.query(
      'INSERT INTO organizations (name, plan) VALUES ($1, $2) RETURNING id',
      [orgName, 'enterprise']
    );
    const orgId = result.rows[0].id;

    await use({ id: orgId, name: orgName });

    // Cascade delete cleans up related data
    await db.query('DELETE FROM organizations WHERE id = $1', [orgId]);
  },
});

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

JavaScript

// tests/fixtures/db.fixture.js
const { test: base } = require('@playwright/test');
const { Pool } = require('pg');

const test = base.extend({
  db: async ({}, use) => {
    const pool = new Pool({
      connectionString: process.env.TEST_DATABASE_URL,
    });
    await use(pool);
    await pool.end();
  },

  seededOrg: async ({ db }, use) => {
    const orgName = `TestOrg-${Date.now()}`;
    const result = await db.query(
      'INSERT INTO organizations (name, plan) VALUES ($1, $2) RETURNING id',
      [orgName, 'enterprise']
    );
    const orgId = result.rows[0].id;

    await use({ id: orgId, name: orgName });

    await db.query('DELETE FROM organizations WHERE id = $1', [orgId]);
  },
});

module.exports = { test, expect: require('@playwright/test').expect };

Always use parameterized queries ($1, $2) to prevent SQL injection — even in tests. It is a good habit and prevents breakage from special characters in generated data.


Storage State

Use when: Multiple tests need an authenticated session and you want to avoid logging in via the UI in every test.

Avoid when: The test is specifically testing the login flow itself.

Generate storage state once in a setup project, then reuse it across all tests that need authentication.

TypeScript

// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  projects: [
    {
      name: 'auth-setup',
      testMatch: /auth\.setup\.ts/,
    },
    {
      name: 'authenticated-tests',
      dependencies: ['auth-setup'],
      use: {
        storageState: '.auth/user.json',
      },
    },
  ],
});
// tests/auth.setup.ts
import { test as setup, expect } from '@playwright/test';
import path from 'node:path';

const authFile = path.join(__dirname, '..', '.auth', 'user.json');

setup('authenticate as standard user', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill(process.env.TEST_USER_EMAIL!);
  await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD!);
  await page.getByRole('button', { name: 'Sign in' }).click();

  await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();

  await page.context().storageState({ path: authFile });
});

JavaScript

// playwright.config.js
const { defineConfig } = require('@playwright/test');

module.exports = defineConfig({
  projects: [
    {
      name: 'auth-setup',
      testMatch: /auth\.setup\.js/,
    },
    {
      name: 'authenticated-tests',
      dependencies: ['auth-setup'],
      use: {
        storageState: '.auth/user.json',
      },
    },
  ],
});
// tests/auth.setup.js
const { test: setup, expect } = require('@playwright/test');
const path = require('node:path');

const authFile = path.join(__dirname, '..', '.auth', 'user.json');

setup('authenticate as standard user', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill(process.env.TEST_USER_EMAIL);
  await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD);
  await page.getByRole('button', { name: 'Sign in' }).click();

  await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();

  await page.context().storageState({ path: authFile });
});

For multiple roles, create separate setup files and storage state files:

// tests/admin-auth.setup.ts
import { test as setup } from '@playwright/test';
import path from 'node:path';

const adminAuthFile = path.join(__dirname, '..', '.auth', 'admin.json');

setup('authenticate as admin', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill(process.env.ADMIN_EMAIL!);
  await page.getByLabel('Password').fill(process.env.ADMIN_PASSWORD!);
  await page.getByRole('button', { name: 'Sign in' }).click();
  await page.context().storageState({ path: adminAuthFile });
});

Add .auth/ to .gitignore — storage state files contain session tokens.


Test Data Cleanup

Use when: Always. Every test that creates data must clean it up.

Avoid when: Never. Skipping cleanup causes cascading failures in subsequent runs.

Strategy 1: Fixture teardown (preferred)

Put cleanup in the fixture's teardown block. Runs even if the test throws.

// Already shown in API Seeding — the cleanup runs after `use()`
apiUser: async ({ request }, use) => {
  const user = await createViaApi(request);
  await use(user);
  // This ALWAYS runs — even on test failure
  await request.delete(`/api/users/${user.id}`);
},

Strategy 2: Batch cleanup by timestamp prefix

Tag all test-created data with a recognizable prefix, then sweep it in global teardown.

TypeScript

// global-teardown.ts
import { request } from '@playwright/test';

export default async function globalTeardown() {
  const context = await request.newContext({
    baseURL: process.env.BASE_URL,
  });

  // Delete all test entities created in this run
  // Assumes entities created with the "test-" prefix
  const response = await context.delete('/api/test-data/cleanup', {
    data: { prefix: 'test-', olderThanMinutes: 60 },
  });

  if (!response.ok()) {
    console.warn(`Cleanup returned ${response.status()}`);
  }

  await context.dispose();
}

JavaScript

// global-teardown.js
const { request } = require('@playwright/test');

module.exports = async function globalTeardown() {
  const context = await request.newContext({
    baseURL: process.env.BASE_URL,
  });

  const response = await context.delete('/api/test-data/cleanup', {
    data: { prefix: 'test-', olderThanMinutes: 60 },
  });

  if (!response.ok()) {
    console.warn(`Cleanup returned ${response.status()}`);
  }

  await context.dispose();
};

Strategy 3: Isolated tenant per worker

Each Playwright worker gets its own tenant/organization. All data is scoped to that tenant. Teardown deletes the entire tenant.

// tests/fixtures/tenant.fixture.ts
import { test as base } from '@playwright/test';

export const test = base.extend<{}, { workerTenant: { id: string; apiKey: string } }>({
  workerTenant: [async ({ request }, use) => {
    const res = await request.post('/api/tenants', {
      data: { name: `test-worker-${Date.now()}` },
    });
    const tenant = await res.json();

    await use(tenant);

    await request.delete(`/api/tenants/${tenant.id}`);
  }, { scope: 'worker' }],
});

Environment-Specific Data

Use when: Tests run against multiple environments (dev, staging, production-mirror) with different base URLs, credentials, or data constraints.

Avoid when: You only have one test environment.

Never hardcode environment-specific values in test files. Use .env files and playwright.config to inject them.

TypeScript

// playwright.config.ts
import { defineConfig } from '@playwright/test';
import dotenv from 'dotenv';
import path from 'node:path';

// Load environment-specific .env file
const envFile = process.env.TEST_ENV || 'local';
dotenv.config({ path: path.resolve(__dirname, `.env.${envFile}`) });

export default defineConfig({
  use: {
    baseURL: process.env.BASE_URL,
  },
});
# .env.local
BASE_URL=http://localhost:3000
TEST_USER_EMAIL=testuser@localhost.test
TEST_USER_PASSWORD=localpassword123

# .env.staging
BASE_URL=https://staging.example.com
TEST_USER_EMAIL=e2e-bot@staging.example.com
TEST_USER_PASSWORD=staging-secret-from-vault
// tests/fixtures/env-data.fixture.ts
import { test as base } from '@playwright/test';

type EnvConfig = {
  testCredentials: { email: string; password: string };
};

export const test = base.extend<EnvConfig>({
  testCredentials: async ({}, use) => {
    const email = process.env.TEST_USER_EMAIL;
    const password = process.env.TEST_USER_PASSWORD;
    if (!email || !password) {
      throw new Error('TEST_USER_EMAIL and TEST_USER_PASSWORD must be set');
    }
    await use({ email, password });
  },
});

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

JavaScript

// playwright.config.js
const { defineConfig } = require('@playwright/test');
const dotenv = require('dotenv');
const path = require('node:path');

const envFile = process.env.TEST_ENV || 'local';
dotenv.config({ path: path.resolve(__dirname, `.env.${envFile}`) });

module.exports = defineConfig({
  use: {
    baseURL: process.env.BASE_URL,
  },
});

Run against a specific environment:

TEST_ENV=staging npx playwright test

Fixtures for Test Data

Use when: Test data setup and teardown should be encapsulated, reusable, and guaranteed to clean up. This is the recommended pattern for all non-trivial test data.

Avoid when: The data is a simple inline value that does not require cleanup.

Fixtures are the backbone of reliable test data management in Playwright. They compose, they guarantee teardown, and they make tests declarative.

TypeScript

// tests/fixtures/index.ts
import { test as base, expect } from '@playwright/test';
import { createUserData, UserData } from '../factories/user.factory';

type TestFixtures = {
  seedUser: UserData & { id: string };
  seedProduct: { id: string; name: string; price: number };
};

export const test = base.extend<TestFixtures>({
  seedUser: async ({ request }, use) => {
    const data = createUserData();
    const res = await request.post('/api/users', { data });
    expect(res.ok()).toBeTruthy();
    const user = { ...data, id: (await res.json()).id };

    await use(user);

    await request.delete(`/api/users/${user.id}`);
  },

  seedProduct: async ({ request }, use) => {
    const data = {
      name: `Product-${Date.now()}`,
      price: 49.99,
      category: 'Testing',
    };
    const res = await request.post('/api/products', { data });
    expect(res.ok()).toBeTruthy();
    const product = { ...data, id: (await res.json()).id };

    await use(product);

    await request.delete(`/api/products/${product.id}`);
  },
});

export { expect };
// tests/shopping-cart.spec.ts
import { test, expect } from './fixtures';

test('adds product to cart', async ({ page, seedUser, seedProduct }) => {
  // seedUser and seedProduct are already created and will be cleaned up automatically
  await page.goto(`/products/${seedProduct.id}`);
  await page.getByRole('button', { name: 'Add to cart' }).click();

  await page.goto('/cart');
  await expect(page.getByText(seedProduct.name)).toBeVisible();
  await expect(page.getByText('$49.99')).toBeVisible();
});

JavaScript

// tests/fixtures/index.js
const { test: base, expect } = require('@playwright/test');
const { createUserData } = require('../factories/user.factory');

const test = base.extend({
  seedUser: async ({ request }, use) => {
    const data = createUserData();
    const res = await request.post('/api/users', { data });
    expect(res.ok()).toBeTruthy();
    const user = { ...data, id: (await res.json()).id };

    await use(user);

    await request.delete(`/api/users/${user.id}`);
  },

  seedProduct: async ({ request }, use) => {
    const data = {
      name: `Product-${Date.now()}`,
      price: 49.99,
      category: 'Testing',
    };
    const res = await request.post('/api/products', { data });
    expect(res.ok()).toBeTruthy();
    const product = { ...data, id: (await res.json()).id };

    await use(product);

    await request.delete(`/api/products/${product.id}`);
  },
});

module.exports = { test, expect };
// tests/shopping-cart.spec.js
const { test, expect } = require('./fixtures');

test('adds product to cart', async ({ page, seedUser, seedProduct }) => {
  await page.goto(`/products/${seedProduct.id}`);
  await page.getByRole('button', { name: 'Add to cart' }).click();

  await page.goto('/cart');
  await expect(page.getByText(seedProduct.name)).toBeVisible();
  await expect(page.getByText('$49.99')).toBeVisible();
});

Fixtures only run when a test requests them by name. If a test does not use seedProduct, the product is never created — no wasted setup, no unnecessary cleanup.

Decision Guide

What data does your test need?
│
├── Simple values (strings, numbers for form fields)?
│   └── Use INLINE DATA — keep it in the test
│
├── Same shape used across many tests?
│   ├── < 5 fields? → Use a FACTORY FUNCTION
│   └── >= 5 fields with optional/conditional fields? → Use a BUILDER
│
├── Need realistic-looking data (names, addresses)?
│   └── Use FAKER with a fixed seed
│
├── Entities that must exist before the test starts?
│   ├── App has an API for this entity?
│   │   └── Use API SEEDING in a fixture (preferred)
│   ├── No API, but have database access?
│   │   └── Use DATABASE SEEDING in a fixture
│   └── No API, no DB access?
│       └── Use UI setup in a beforeEach (slowest — avoid if possible)
│
├── Need an authenticated session?
│   └── Use STORAGE STATE via setup project
│
└── Need data isolated per worker?
    └── Use WORKER-SCOPED FIXTURES with tenant isolation

Speed ranking (fastest to slowest)

  1. Inline data / factories / faker — no I/O, instant
  2. API seeding — one HTTP call per entity
  3. Database seeding — direct DB call, skips app logic
  4. Storage state — one-time login, reused across tests
  5. UI-based setup — full browser interaction per test (avoid)

Isolation ranking (most to least isolated)

  1. Test-scoped fixtures with API teardown — each test gets fresh data, cleaned up after
  2. Worker-scoped tenant isolation — tests in a worker share a tenant, cleaned up when worker exits
  3. Timestamp-prefixed batch cleanup — catches orphaned data, runs in global teardown
  4. Shared database state — fragile, prone to ordering bugs (avoid)

Anti-Patterns

Shared mutable data across tests

// BAD — tests share the same user object and depend on execution order
let sharedUser: UserData;

test.beforeAll(async ({ request }) => {
  sharedUser = await createUser(request);
});

test('updates user name', async ({ page }) => {
  // Mutates sharedUser — other tests see the changed state
});

test('checks original name', async ({ page }) => {
  // Fails because the previous test changed the name
});

Fix: Use test-scoped fixtures. Each test gets its own instance.

Hardcoded IDs

// BAD — this ID only exists in your local database
test('edits product', async ({ page }) => {
  await page.goto('/products/507f1f77bcf86cd799439011/edit');
});

Fix: Create the product in a fixture and use the returned ID.

No cleanup

// BAD — creates data but never deletes it
test.beforeEach(async ({ request }) => {
  await request.post('/api/products', { data: { name: 'Leaked Product' } });
});
// Over time, the database fills with test debris causing slowdowns and false positives

Fix: Always pair creation with deletion in a fixture teardown.

Relying on pre-existing database state

// BAD — assumes "Premium Plan" already exists in the database
test('subscribes to premium', async ({ page }) => {
  await page.goto('/pricing');
  await page.getByRole('button', { name: 'Premium Plan' }).click();
  // Breaks on a fresh database or different environment
});

Fix: Seed the plan via API or database fixture before the test.

Using production data in tests

// BAD — tests against real customer data
test('views customer orders', async ({ page }) => {
  await page.goto('/admin/customers/real-customer-id/orders');
});

Fix: Create synthetic test data. Never point test suites at production databases or use real customer identifiers.

Over-engineering data setup

// BAD — abstraction for its own sake
const data = new TestDataOrchestrator()
  .withStrategy('api')
  .withRetry(3)
  .withCleanupPolicy('deferred')
  .withEnvironment('staging')
  .build();

Fix: A factory function and a fixture cover 95% of cases. Add complexity only when you have a proven need.

Troubleshooting

Problem Cause Fix
Tests fail with "duplicate key" errors Data from previous runs was not cleaned up Add fixture teardown; run batch cleanup in globalTeardown
Tests pass alone but fail in parallel Tests share mutable state or collide on unique fields Use Date.now() or crypto.randomUUID() in factory output; use test-scoped fixtures
Faker produces different data on retry Faker was not seeded, or seed changes between runs Seed with testInfo.workerIndex or a fixed value
Storage state expired / session invalid Auth tokens have a short TTL Re-run auth setup before each suite; set fullyParallel: false on auth-dependent project if needed
Cleanup fails and blocks other tests Teardown makes network calls that can timeout Wrap cleanup in try/catch; log failures but do not throw; rely on batch cleanup as a safety net
Database seeding is slow Too many individual INSERT statements Batch inserts; use transactions; consider API seeding instead
Tests break when run against staging Hardcoded values that only exist locally Use environment variables for all environment-specific data; validate in fixture with clear error messages

Making cleanup resilient

// Wrap fixture teardown in try/catch so a cleanup failure does not mask the real test failure
seedUser: async ({ request }, use) => {
  const user = await createViaApi(request);
  await use(user);

  try {
    await request.delete(`/api/users/${user.id}`);
  } catch (error) {
    console.warn(`Failed to clean up user ${user.id}:`, error);
  }
},