You have written a solid Playwright test suite. Every test passes locally. Then you push to CI, enable parallel execution, and suddenly half your tests fail randomly. The culprit is almost never your test logic — it is your test data.
Poorly managed test data is the number one cause of flaky tests. Two tests share the same user account and step on each other. A test expects a specific product in the database that was deleted by a previous run. Hardcoded IDs work in staging but break in production. These are all symptoms of the same underlying problem: your tests do not own their data.
This guide walks through every strategy for managing test data in Playwright — from fixtures and data factories to API seeding, database cleanup, and best practices that keep your suite reliable at any scale.
Why Test Data Management Matters
Before diving into solutions, let us understand the three problems that bad test data creates.
Flakiness from shared state. When two tests read or write the same database record, they create a race condition. Test A expects three items in a shopping cart, but Test B just added a fourth. This works in serial execution but fails the moment you enable parallelism — which Playwright does by default.
Lack of isolation. If Test A depends on data created by Test B, your tests have an invisible ordering dependency. Rearranging, skipping, or running a single test in isolation will break the chain. Every test should be able to run independently, in any order, at any time.
Environment drift. Hardcoded user credentials, product IDs, or API URLs that work in your local database will fail in staging, QA, or CI environments. Your test data strategy must account for environment differences from day one.
The golden rule: Each test should create the data it needs, use it, and clean it up. No test should ever depend on data created by another test or left over from a previous run.
Playwright Fixtures: The Foundation
Playwright fixtures are the primary mechanism for managing test data lifecycle. A fixture sets up a resource before the test runs, provides it to the test, and tears it down afterward — all in a single, encapsulated unit.
Built-in Fixtures
Playwright ships with several built-in fixtures that every test uses implicitly:
- page — a fresh, isolated browser page for each test
- context — the browser context that owns the page (cookies, storage)
- browser — the shared browser instance (worker-scoped)
- request — an API request context for HTTP calls without a browser
Worker-Scoped vs Test-Scoped
Understanding fixture scope is critical for test data management. Test-scoped fixtures are created fresh for each test — perfect for data that must be isolated. Worker-scoped fixtures are created once per worker process and shared across all tests on that worker — ideal for expensive resources like database connections or authentication tokens.
import { test as base } from '@playwright/test'; // Test-scoped: new instance per test (default) export const test = base.extend<{ testUser: { id: string; email: string }; }>({ testUser: async ({ request }, use) => { // Setup: create a unique user before test const res = await request.post('/api/users', { data: { email: `user-${Date.now()}@test.com` } }); const user = await res.json(); // Provide to test await use(user); // Teardown: delete after test await request.delete(`/api/users/${user.id}`); }, }); // Worker-scoped: shared across all tests in the worker export const test = base.extend<{}, { dbConnection: DbClient; }>({ dbConnection: [async ({}, use) => { const db = await connectToDatabase(); await use(db); await db.disconnect(); }, { scope: 'worker' }], });
Creating Custom Fixtures for Test Data
Custom fixtures are where test data management truly shines. By extending Playwright's test object with typed fixtures, you get type-safe, reusable data setup that integrates seamlessly into every test.
import { test as base, expect } from '@playwright/test'; // Define types for all your test data fixtures type User = { id: string; email: string; name: string; role: 'admin' | 'editor' | 'viewer'; }; type Product = { id: string; name: string; price: number; sku: string; }; type TestFixtures = { adminUser: User; viewerUser: User; testProduct: Product; testOrder: { id: string; userId: string; productId: string }; }; export const test = base.extend<TestFixtures>({ adminUser: async ({ request }, use) => { const res = await request.post('/api/users', { data: { email: `admin-${Date.now()}@test.com`, name: 'Test Admin', role: 'admin', }, }); const user = await res.json(); await use(user); await request.delete(`/api/users/${user.id}`); }, viewerUser: async ({ request }, use) => { const res = await request.post('/api/users', { data: { email: `viewer-${Date.now()}@test.com`, name: 'Test Viewer', role: 'viewer', }, }); const user = await res.json(); await use(user); await request.delete(`/api/users/${user.id}`); }, testProduct: async ({ request }, use) => { const res = await request.post('/api/products', { data: { name: `Product ${Date.now()}`, price: 29.99, sku: `SKU-${Math.random().toString(36).slice(2, 8)}`, }, }); const product = await res.json(); await use(product); await request.delete(`/api/products/${product.id}`); }, testOrder: async ({ request, adminUser, testProduct }, use) => { // Fixtures can depend on other fixtures const res = await request.post('/api/orders', { data: { userId: adminUser.id, productId: testProduct.id, quantity: 1, }, }); const order = await res.json(); await use(order); await request.delete(`/api/orders/${order.id}`); }, }); export { expect } from '@playwright/test';
Now any test file that imports from this fixture file gets typed, auto-cleaned test data:
import { test, expect } from '../fixtures/test-data.fixture'; test('admin can view order details', async ({ page, adminUser, testOrder }) => { // adminUser and testOrder are created automatically await page.goto(`/orders/${testOrder.id}`); await expect(page.getByText(adminUser.name)).toBeVisible(); await expect(page.getByText(testOrder.id)).toBeVisible(); // Cleanup happens automatically after test });
Tip: Fixture dependencies form a DAG (directed acyclic graph). Playwright resolves the order automatically. In the example above, requesting testOrder triggers adminUser and testProduct first because testOrder depends on them.
Data Factories for Dynamic Test Data
Hardcoded test data creates collisions in parallel execution. Data factories solve this by generating unique, realistic data for every test run. The factory pattern combined with faker.js is the industry standard approach.
import { faker } from '@faker-js/faker'; type UserInput = { email?: string; name?: string; role?: 'admin' | 'editor' | 'viewer'; password?: string; }; export function buildUser(overrides: UserInput = {}): Required<UserInput> { return { email: overrides.email ?? faker.internet.email({ provider: 'testmail.com' }), name: overrides.name ?? faker.person.fullName(), role: overrides.role ?? 'viewer', password: overrides.password ?? faker.internet.password({ length: 16 }), }; } export function buildProduct(overrides: Partial<ProductInput> = {}) { return { name: overrides.name ?? faker.commerce.productName(), price: overrides.price ?? parseFloat(faker.commerce.price({ min: 5, max: 500 })), sku: overrides.sku ?? `SKU-${faker.string.alphanumeric(8).toUpperCase()}`, description: overrides.description ?? faker.commerce.productDescription(), category: overrides.category ?? faker.commerce.department(), }; } export function buildOrder(userId: string, productId: string, overrides: Partial<OrderInput> = {}) { return { userId, productId, quantity: overrides.quantity ?? faker.number.int({ min: 1, max: 10 }), shippingAddress: overrides.shippingAddress ?? { street: faker.location.streetAddress(), city: faker.location.city(), state: faker.location.state({ abbreviated: true }), zip: faker.location.zipCode(), }, }; }
Factories generate unique data while allowing targeted overrides. You get random, realistic values by default, but can fix specific fields when your test depends on them:
import { buildUser, buildProduct } from '../factories/user.factory'; // Random user every time const user1 = buildUser(); // { email: 'john.doe@testmail.com', name: 'John Doe', role: 'viewer', ... } // Override only what matters for this test const admin = buildUser({ role: 'admin' }); // { email: 'random@testmail.com', name: 'Random Name', role: 'admin', ... } // Fixed email for login test, everything else random const loginUser = buildUser({ email: 'qa-tester@company.com' });
Environment Configuration
Your tests should run against local, staging, QA, and production environments without code changes. Playwright's configuration system makes this straightforward with .env files and the use.baseURL option.
import { defineConfig, devices } from '@playwright/test'; import dotenv from 'dotenv'; import path from 'path'; // Load environment-specific .env file const env = process.env.TEST_ENV || 'local'; dotenv.config({ path: path.resolve(__dirname, `.env.${env}`) }); export default defineConfig({ testDir: './tests', fullyParallel: true, retries: env === 'ci' ? 2 : 0, use: { // baseURL from environment file baseURL: process.env.BASE_URL, extraHTTPHeaders: { 'x-api-key': process.env.API_KEY ?? '', }, trace: 'on-first-retry', }, projects: [ { name: 'chromium', use: { ...devices['Desktop Chrome'] }, }, ], });
BASE_URL=http://localhost:3000 API_KEY=dev-api-key-12345 DB_CONNECTION=postgresql://localhost:5432/app_test ADMIN_EMAIL=admin@localhost.test ADMIN_PASSWORD=localPassword123
BASE_URL=https://staging.example.com API_KEY=staging-api-key-67890 DB_CONNECTION=postgresql://staging-db:5432/app_staging ADMIN_EMAIL=qa-admin@example.com ADMIN_PASSWORD=stagingSecurePass!
Run tests against different environments by setting the TEST_ENV variable:
# Local development npx playwright test # Staging environment TEST_ENV=staging npx playwright test # CI pipeline TEST_ENV=ci npx playwright test --reporter=html
API Seeding: Set Up Data Before Tests
The most reliable way to create test data is through your application's API. Playwright's built-in request fixture makes this seamless. API seeding validates your endpoints as a side effect and respects all business logic and validation rules.
import { test as base, expect } from '@playwright/test'; import { buildUser, buildProduct } from '../factories'; type SeedFixtures = { seededUser: { id: string; email: string; password: string }; seededProduct: { id: string; name: string; price: number }; }; export const test = base.extend<SeedFixtures>({ seededUser: async ({ request }, use) => { const userData = buildUser({ role: 'editor' }); // Create via API const createRes = await request.post('/api/users', { data: userData }); expect(createRes.ok()).toBeTruthy(); const user = await createRes.json(); // Pass to test (with password for login) await use({ ...user, password: userData.password }); // Cleanup: delete user and all associated data await request.delete(`/api/users/${user.id}`); }, seededProduct: async ({ request }, use) => { const productData = buildProduct(); const createRes = await request.post('/api/products', { data: productData }); expect(createRes.ok()).toBeTruthy(); const product = await createRes.json(); await use(product); await request.delete(`/api/products/${product.id}`); }, }); // Usage in test test('editor can update product price', async ({ page, seededUser, seededProduct }) => { // Login as the seeded user await page.goto('/login'); await page.getByLabel('Email').fill(seededUser.email); await page.getByLabel('Password').fill(seededUser.password); await page.getByRole('button', { name: 'Sign in' }).click(); // Navigate to the seeded product await page.goto(`/products/${seededProduct.id}/edit`); await page.getByLabel('Price').fill('49.99'); await page.getByRole('button', { name: 'Save' }).click(); await expect(page.getByText('Product updated')).toBeVisible(); });
Data-Driven Testing
When the same test logic needs to run with different inputs, data-driven testing eliminates duplication. Playwright supports parameterized tests natively through arrays, JSON files, or CSV data sources.
import { test, expect } from '@playwright/test'; const loginScenarios = [ { email: 'admin@test.com', password: 'AdminPass1!', expectedRole: 'Admin', canDelete: true }, { email: 'editor@test.com', password: 'EditPass1!', expectedRole: 'Editor', canDelete: false }, { email: 'viewer@test.com', password: 'ViewPass1!', expectedRole: 'Viewer', canDelete: false }, ]; for (const scenario of loginScenarios) { test(`${scenario.expectedRole} sees correct permissions`, async ({ page }) => { await page.goto('/login'); await page.getByLabel('Email').fill(scenario.email); await page.getByLabel('Password').fill(scenario.password); await page.getByRole('button', { name: 'Sign in' }).click(); await expect(page.getByText(scenario.expectedRole)).toBeVisible(); const deleteBtn = page.getByRole('button', { name: 'Delete' }); if (scenario.canDelete) { await expect(deleteBtn).toBeVisible(); } else { await expect(deleteBtn).toBeHidden(); } }); }
Loading Test Data from JSON Files
import { test, expect } from '@playwright/test'; import searchData from '../data/search-queries.json'; // search-queries.json: // [ // { "query": "playwright", "minResults": 5 }, // { "query": "automation testing", "minResults": 3 }, // { "query": "xyznonexistent", "minResults": 0 } // ] for (const { query, minResults } of searchData) { test(`search "${query}" returns at least ${minResults} results`, async ({ page }) => { await page.goto('/search'); await page.getByPlaceholder('Search...').fill(query); await page.getByRole('button', { name: 'Search' }).click(); const results = page.getByTestId('search-result'); await expect(results).toHaveCount(expect.any(Number)); const count = await results.count(); expect(count).toBeGreaterThanOrEqual(minResults); }); }
Database Seeding and Cleanup
For large-scale data setup that would be too slow through APIs, direct database seeding in globalSetup is the pragmatic choice. This runs once before the entire test suite and creates shared reference data that all tests can read (but not modify).
import { Pool } from 'pg'; import { FullConfig } from '@playwright/test'; async function globalSetup(config: FullConfig) { const pool = new Pool({ connectionString: process.env.DB_CONNECTION, }); // Clean up stale test data from previous runs await pool.query(` DELETE FROM orders WHERE created_at < NOW() - INTERVAL '1 hour'; DELETE FROM products WHERE sku LIKE 'SKU-TEST-%'; DELETE FROM users WHERE email LIKE '%@testmail.com'; `); // Seed reference data needed by all tests await pool.query(` INSERT INTO categories (id, name) VALUES ('cat-1', 'Electronics'), ('cat-2', 'Books'), ('cat-3', 'Clothing') ON CONFLICT (id) DO NOTHING; `); await pool.end(); } export default globalSetup;
import { defineConfig } from '@playwright/test'; export default defineConfig({ globalSetup: './global-setup.ts', globalTeardown: './global-teardown.ts', // ... rest of config });
Cleanup Strategies
There are three common cleanup strategies, each with tradeoffs:
- Fixture teardown — cleanup runs after each test via the fixture's post-
usecode. Most reliable; guarantees isolation. - Global teardown — a single cleanup script runs after the entire suite. Faster but risks data leaks between tests.
- Time-based cleanup — delete test data older than N minutes. Useful as a safety net for orphaned data from crashed test runs.
Best practice: Use fixture teardown as your primary cleanup mechanism. Add time-based cleanup in globalSetup as a safety net for data left behind by crashed or timed-out test runs.
Managing Authentication State
Authentication is the most common test data challenge. Logging in through the UI for every test is slow and brittle. Playwright's storageState feature lets you authenticate once and reuse the session across all tests.
import { test as setup } from '@playwright/test'; import path from 'path'; const authFile = path.join(__dirname, '../.auth/user.json'); setup('authenticate', async ({ page }) => { // Perform login once 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(); // Wait for auth to complete await page.waitForURL('/dashboard'); // Save auth state (cookies + localStorage) await page.context().storageState({ path: authFile }); });
import { defineConfig } from '@playwright/test'; export default defineConfig({ projects: [ // Setup project: runs auth first { name: 'setup', testMatch: '**/*.setup.ts', }, // Test projects: depend on setup, reuse auth state { name: 'chromium', dependencies: ['setup'], use: { storageState: '.auth/user.json', }, }, { name: 'firefox', dependencies: ['setup'], use: { storageState: '.auth/user.json', }, }, ], });
Multiple Auth Roles
For applications with role-based access, create separate storage state files for each role:
import { test as base } from '@playwright/test'; export const test = base.extend<{ adminPage: Page; editorPage: Page; }>({ adminPage: async ({ browser }, use) => { const ctx = await browser.newContext({ storageState: '.auth/admin.json', }); const page = await ctx.newPage(); await use(page); await ctx.close(); }, editorPage: async ({ browser }, use) => { const ctx = await browser.newContext({ storageState: '.auth/editor.json', }); const page = await ctx.newPage(); await use(page); await ctx.close(); }, }); // Test with two different roles simultaneously test('admin approves editor submission', async ({ adminPage, editorPage }) => { // Editor submits a draft await editorPage.goto('/drafts/new'); await editorPage.getByLabel('Title').fill('New Article'); await editorPage.getByRole('button', { name: 'Submit for review' }).click(); // Admin approves it await adminPage.goto('/admin/pending'); await adminPage.getByText('New Article').click(); await adminPage.getByRole('button', { name: 'Approve' }).click(); });
Test Data Anti-Patterns to Avoid
After reviewing hundreds of Playwright test suites, these are the patterns that consistently cause the most pain:
1. Hardcoded Test Data
// BAD: Hardcoded values cause collisions in parallel test('create user', async ({ page }) => { await page.getByLabel('Email').fill('john@test.com'); // collision! await page.getByLabel('Name').fill('John Doe'); }); // GOOD: Factory generates unique values test('create user', async ({ page }) => { const user = buildUser(); await page.getByLabel('Email').fill(user.email); await page.getByLabel('Name').fill(user.name); });
2. Shared Mutable State
// BAD: Tests share and mutate the same data let sharedCartId: string; test.beforeAll(async ({ request }) => { const res = await request.post('/api/carts'); sharedCartId = (await res.json()).id; }); test('add item to cart', async ({ request }) => { await request.post(`/api/carts/${sharedCartId}/items`, { data: { productId: 'prod-1' } }); // Mutates shared state! }); test('cart is empty', async ({ page }) => { await page.goto(`/cart/${sharedCartId}`); // FAILS if 'add item' test ran first! });
3. Sequential Test Dependencies
// BAD: Test B depends on Test A running first let createdUserId: string; test('create user', async ({ request }) => { const res = await request.post('/api/users', { data: buildUser() }); createdUserId = (await res.json()).id; }); test('edit user', async ({ page }) => { // Breaks if 'create user' did not run or failed await page.goto(`/users/${createdUserId}/edit`); }); // GOOD: Use fixtures — each test owns its data test('edit user', async ({ page, seededUser }) => { await page.goto(`/users/${seededUser.id}/edit`); });
AI-Generated Test Data with Claude
Claude AI can accelerate your test data management workflow significantly. Instead of manually writing factory functions, fixture definitions, and cleanup logic, you can describe your data model and let Claude generate the entire test data infrastructure.
Here is what Claude can generate for you in seconds:
- Data factory functions with faker.js integration and proper TypeScript types
- Custom fixtures with API seeding and automatic teardown
- Parameterized test data covering edge cases you might miss
- Environment configuration files for all your deployment targets
- Database seed scripts with cleanup queries
- Authentication setup with storageState for multiple roles
With the Page Object Model pattern and properly structured test data, Claude can generate entire test suites that are production-ready from the start. The Playwright + Claude AI course teaches you exactly how to prompt Claude for these patterns and integrate them into your CI/CD pipeline.
Frequently Asked Questions
What is the best way to manage test data in Playwright?
Combine Playwright fixtures for dependency injection, data factory functions (with faker.js) for generating unique test data, and API seeding for creating server-side state. Each test should create its own isolated data, use it, and clean it up in the fixture teardown. This ensures tests are independent, repeatable, and safe for parallel execution.
How do I use fixtures for test data in Playwright?
Use test.extend() to create custom fixtures that set up test data before each test and tear it down afterward. Define a fixture function that creates data via API calls, passes it to the test via the use() callback, and cleans it up after the test completes. Fixtures are lazy (only run when requested) and encapsulate setup and teardown in one place.
Should I use a database or API to seed test data in Playwright?
API seeding is generally preferred because it validates your API endpoints, respects business logic and validation rules, and does not require direct database access. Use Playwright's built-in request fixture for API calls. Direct database seeding is faster for large datasets and useful in globalSetup for shared reference data, but it bypasses application logic.
How do I handle authentication state across Playwright tests?
Use Playwright's storageState feature. In a setup project, log in once and save cookies and localStorage to a JSON file via context.storageState(). Reference that file in your playwright.config.ts using use.storageState. All subsequent tests start already authenticated, eliminating repeated login flows. Create separate state files for different user roles.
What are the most common test data anti-patterns in Playwright?
The worst anti-patterns are: hardcoding values that collide in parallel runs, sharing mutable state between tests, relying on sequential test execution order, not cleaning up test data after runs, and depending on pre-existing database records instead of creating fresh data per test. Use data factories for unique values and fixtures for isolated lifecycle management.
Asim Noaman
Senior QA Automation Engineer & AI Testing Specialist
With years of hands-on experience building test automation frameworks for production applications, Asim specializes in combining traditional QA methodologies with cutting-edge AI tools. He has helped teams adopt Playwright and AI-driven testing workflows to ship faster with fewer bugs.