# 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** ```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** ```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** ```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 { counter++; const id = `${Date.now()}-${counter}`; return { firstName: `Test`, lastName: `User${id}`, email: `testuser-${id}@example.com`, password: 'SecureP@ss123!', ...overrides, }; } ``` ```typescript // 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** ```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 }; ``` ```javascript // 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** ```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(), }, }; } ``` ```typescript // 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** ```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 }; ``` ```javascript // 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** ```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 }; } } ``` ```typescript // 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** ```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 }; ``` ```javascript // 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** ```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({ 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'; ``` ```typescript // 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** ```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 }; ``` ```javascript // 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** ```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** ```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({ 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** ```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** ```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', }, }, ], }); ``` ```typescript // 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** ```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', }, }, ], }); ``` ```javascript // 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: ```typescript // 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. ```typescript // 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** ```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** ```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. ```typescript // 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** ```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 ``` ```typescript // 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({ 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** ```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: ```bash 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** ```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({ 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 }; ``` ```typescript // 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** ```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 }; ``` ```javascript // 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 ```typescript // 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 ```typescript // 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 ```typescript // 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 ```typescript // 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 ```typescript // 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 ```typescript // 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 ```typescript // 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); } }, ``` ## Related - [core/fixtures-and-hooks.md](fixtures-and-hooks.md) — fixture mechanics, scoping, composition - [core/authentication.md](authentication.md) — storage state setup, multi-role auth patterns - [core/api-testing.md](api-testing.md) — API request context, response validation - [ci/global-setup-teardown.md](../ci/global-setup-teardown.md) — global setup/teardown for batch operations - [pom/pom-vs-fixtures-vs-helpers.md](../pom/pom-vs-fixtures-vs-helpers.md) — when to use fixtures vs page objects vs helpers