Introduction
Test data is one of the most underestimated parts of automation framework design. A Playwright test may have excellent locators, clean Page Objects, reusable fixtures, and a professional CI/CD pipeline — and still fail because of its data.
- Missing or expired data
- Data already used by an earlier run
- Data modified by another test
- Data incorrect for the current environment
- Data shared between parallel workers
- Data hardcoded inside the test
- Data left behind after a failed test
- Data containing sensitive information
In small projects, test data is often a few usernames and passwords in a JSON file. That approach does not scale. Enterprise applications need data for roles, products, orders, subscriptions, payments, feature flags, API payloads, database validation, CSV exports, and date-sensitive workflows.
In this tutorial you will learn how to manage static, dynamic, generated, API-created, database-seeded, environment-specific, and sensitive test data — and how to make it parallel-safe and self-cleaning.
What Is Test Data Management?
- Define the data a test requires.
- Create or retrieve that data.
- Provide it to the test.
- Isolate it from other tests.
- Track which test owns it.
- Clean it up when execution finishes.
- Protect sensitive values.
- Keep data appropriate for each environment.
The goal is not simply to store data. The goal is to ensure every test receives predictable, valid, independent data.
Why Test Data Causes Automation Failures
test('customer can register', async ({ page }) => {
await page.goto('/register');
await page.getByLabel('Email')
.fill('automation@example.com');
await page.getByLabel('Password')
.fill('Password123');
await page.getByRole('button', {
name: 'Create account'
}).click();
});The test may pass the first time. On the second run it fails because the email address is already registered. The problem is not Playwright — the problem is test-data ownership.
const user = createUserData();
await registrationPage.register(
user.email,
user.password
);Every execution receives a new user.
The Main Categories of Test Data
Static reference data
Country names, user roles, product categories, currency codes, and expected validation messages — values that change infrequently.
Environment data
Application URLs, API URLs, database names, feature flags, and test account identifiers that differ per environment.
Generated data
Unique values created during execution: email addresses, order references, customer names, product identifiers, and correlation IDs.
Seeded, API-created and database-created data
Records inserted before tests begin, created through backend endpoints before a UI test, or inserted directly into a database when an approved API is unavailable.
Sensitive data
Passwords, API tokens, private keys, payment information, and personally identifiable information — values that must never be committed or logged.
Recommended Project Structure
playwright-framework/
│
├── data/
│ ├── static/
│ │ ├── countries.json
│ │ ├── roles.json
│ │ └── validationMessages.json
│ ├── expected/
│ │ ├── products.json
│ │ └── subscriptions.json
│ └── files/
│ ├── sample-upload.pdf
│ └── expected-export.csv
│
├── factories/
│ ├── userFactory.ts
│ ├── productFactory.ts
│ └── orderFactory.ts
│
├── builders/
│ ├── UserBuilder.ts
│ └── OrderBuilder.ts
│
├── fixtures/
│ ├── user.fixture.ts
│ ├── product.fixture.ts
│ └── testData.fixture.ts
│
├── api/
│ └── services/
│ ├── UserApiService.ts
│ └── ProductApiService.ts
│
├── database/
│ ├── repositories/
│ └── seeders/
│
├── config/
│ └── environments.ts
│
└── tests/This separates static files, generation logic, setup fixtures, APIs, and database utilities.
Avoid Hardcoding Data Inside Tests
// Bad
await loginPage.login(
'admin@example.com',
'Password123'
);
// Better
await loginPage.login(
testUser.email,
testUser.password
);The test should consume data. It should not decide where the data comes from — that may be a fixture, an environment variable, a factory, an API, a database query, a JSON file, or a secrets platform.
Static Test Data with JSON
{
"requiredEmail": "Email is required",
"invalidEmail": "Enter a valid email address",
"requiredPassword": "Password is required",
"accountLocked": "Your account has been locked"
}import messages from
'../data/static/validationMessages.json';
await expect(loginPage.errorMessage)
.toHaveText(messages.invalidEmail);Create Strong TypeScript Data Models
export interface TestUser {
firstName: string;
lastName: string;
email: string;
password: string;
role: 'customer' | 'admin' | 'support';
active: boolean;
}const user: TestUser = {
firstName: 'John',
lastName: 'Smith',
email: 'john@example.com',
password: 'Password123',
role: 'customer',
active: true
};Typed models are especially useful for API request payloads, expected responses, database records, Page Object methods, and complex business objects.
Generate Unique Test Data
import { randomUUID } from 'node:crypto';
import { TestUser } from '../models/TestUser';
export function createUserData(
overrides: Partial<TestUser> = {}
): TestUser {
const id = randomUUID();
return {
firstName: 'Automation',
lastName: `User-${id.slice(0, 8)}`,
email: `automation-${id}@example.test`,
password: 'TemporaryPassword123!',
role: 'customer',
active: true,
...overrides
};
}const user = createUserData();
const adminUser = createUserData({ role: 'admin' });
const inactiveUser = createUserData({ active: false });The overrides parameter keeps the factory flexible while every execution still receives unique values.
Why Factories Are Better Than Large Data Files
// Without a factory — every test must know all required fields
const user = {
firstName: 'John',
lastName: 'Smith',
email: 'john@example.com',
password: 'Password123',
role: 'customer',
active: true,
country: 'US',
language: 'en',
marketingConsent: false
};
// With a factory
const user = createUserData({ country: 'US' });The factory controls defaults while the test changes only what matters to the scenario. This reduces duplication and missing-field errors.
Use the Builder Pattern for Complex Data
export interface OrderData {
customerId: string;
productId: string;
quantity: number;
shippingMethod: 'standard' | 'express';
discountCode?: string;
giftOrder: boolean;
}export class OrderBuilder {
private order: OrderData = {
customerId: '',
productId: '',
quantity: 1,
shippingMethod: 'standard',
giftOrder: false
};
forCustomer(customerId: string): this {
this.order.customerId = customerId;
return this;
}
withProduct(productId: string, quantity = 1): this {
this.order.productId = productId;
this.order.quantity = quantity;
return this;
}
withExpressShipping(): this {
this.order.shippingMethod = 'express';
return this;
}
withDiscount(code: string): this {
this.order.discountCode = code;
return this;
}
asGift(): this {
this.order.giftOrder = true;
return this;
}
build(): OrderData {
if (!this.order.customerId) {
throw new Error('Order customerId is required');
}
if (!this.order.productId) {
throw new Error('Order productId is required');
}
return { ...this.order };
}
}const order = new OrderBuilder()
.forCustomer(customer.id)
.withProduct(product.id, 2)
.withExpressShipping()
.withDiscount('SAVE20')
.build();Use Playwright Fixtures to Supply Data
Fixtures establish the environment a test needs and are isolated between tests, which makes them the natural place to provide data and control setup and teardown.
import { test as base } from '@playwright/test';
import { createUserData } from '../factories/userFactory';
import { TestUser } from '../models/TestUser';
type TestDataFixtures = {
userData: TestUser;
};
export const test = base.extend<TestDataFixtures>({
userData: async ({}, use) => {
const user = createUserData();
await use(user);
}
});
export { expect } from '@playwright/test';test('customer can register', async ({
registrationPage,
userData
}) => {
await registrationPage.open();
await registrationPage.register(userData);
});Create Data Through an API
Creating preconditions through the UI is slow and fragile. A test needing an existing customer with an active subscription should not register, verify email, log in, and purchase before the scenario even starts.
import { APIRequestContext, expect } from '@playwright/test';
export class UserApiService {
constructor(
private readonly request: APIRequestContext
) {}
async createUser(user: TestUser): Promise<string> {
const response = await this.request.post('/api/users', {
data: user
});
expect(response.ok()).toBeTruthy();
const body = await response.json();
return body.id;
}
async deleteUser(userId: string): Promise<void> {
const response = await this.request.delete(
`/api/users/${userId}`
);
expect(response.ok()).toBeTruthy();
}
}API-Backed Data Fixture with Cleanup
type UserFixtures = {
registeredUser: TestUser & { id: string };
};
export const test = base.extend<UserFixtures>({
registeredUser: async ({ request }, use) => {
const userData = createUserData();
const userApi = new UserApiService(request);
const id = await userApi.createUser(userData);
await use({ ...userData, id });
await userApi.deleteUser(id);
}
});Create data
↓
Run the test
↓
Clean up dataAlways Design Cleanup
Data creation without cleanup eventually damages the test environment.
- Thousands of automation users
- Duplicate products and abandoned orders
- Search results polluted by old test records
- Tests finding the wrong record
- Database storage growth and environment instability
Cleanup can happen after each test, after each worker, after the suite, through scheduled maintenance, or through expiration policies. Test-scoped cleanup is usually safest because the test owns its data.
Cleanup Must Run Even When the Test Fails
registeredProduct: async ({ request }, use) => {
const productApi = new ProductApiService(request);
const product = await productApi.createProduct(
createProductData()
);
try {
await use(product);
} finally {
await productApi.deleteProduct(product.id);
}
}Make Data Safe for Parallel Tests
Playwright runs tests in independent worker processes that do not share memory. This creates a critical rule: parallel tests should not modify the same data.
// Unsafe — five workers fighting over one account
const customerEmail = 'shared-user@example.com';
// Better
const user = createUserData({
email:
`worker-${testInfo.workerIndex}-` +
`${randomUUID()}@example.test`
});Use Worker-Specific Accounts When Necessary
Some systems do not allow unlimited user creation. Playwright's authentication guidance recommends one account per worker for tests that modify shared server-side state.
const workerAccounts = [
{
email: 'worker-0@example.test',
password: process.env.WORKER_0_PASSWORD!
},
{
email: 'worker-1@example.test',
password: process.env.WORKER_1_PASSWORD!
},
{
email: 'worker-2@example.test',
password: process.env.WORKER_2_PASSWORD!
}
];
const account = workerAccounts[testInfo.workerIndex];Ensure the number of configured accounts supports the maximum number of CI workers.
Use TestInfo to Create Traceable Data
import { TestInfo } from '@playwright/test';
export function buildTestDataPrefix(
testInfo: TestInfo
): string {
const title = testInfo.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.slice(0, 30);
return [
'pw',
title,
`w${testInfo.workerIndex}`,
Date.now()
].join('-');
}const prefix = buildTestDataPrefix(testInfo);
const product = createProductData({
name: `${prefix}-product`
});The record name later reveals the tool, the test, the worker, and the creation time. Never include sensitive information in generated names.
Environment-Specific Data
type EnvironmentName = 'dev' | 'qa' | 'stage';
interface EnvironmentData {
defaultProductId: string;
supportUserEmail: string;
subscriptionPlan: string;
}
const testDataByEnvironment:
Record<EnvironmentName, EnvironmentData> = {
dev: {
defaultProductId: 'DEV-PRODUCT-100',
supportUserEmail: 'support-dev@example.test',
subscriptionPlan: 'DEV-PREMIUM'
},
qa: {
defaultProductId: 'QA-PRODUCT-100',
supportUserEmail: 'support-qa@example.test',
subscriptionPlan: 'QA-PREMIUM'
},
stage: {
defaultProductId: 'STAGE-PRODUCT-100',
supportUserEmail: 'support-stage@example.test',
subscriptionPlan: 'STAGE-PREMIUM'
}
};
const environment =
(process.env.TEST_ENV ?? 'qa') as EnvironmentName;
export const environmentTestData =
testDataByEnvironment[environment];Use Projects for Different Data Configurations
import { defineConfig } from '@playwright/test';
export type TestOptions = {
testEnvironment: 'qa' | 'stage';
};
export default defineConfig<TestOptions>({
projects: [
{
name: 'qa-chromium',
use: { testEnvironment: 'qa' }
},
{
name: 'stage-chromium',
use: { testEnvironment: 'stage' }
}
]
});environmentData: async ({ testEnvironment }, use) => {
await use(testDataByEnvironment[testEnvironment]);
}This avoids environment-specific if statements scattered throughout the tests.
Parameterize Tests with Data Sets
const invalidEmails = [
{ value: '', expected: 'Email is required' },
{ value: 'invalid', expected: 'Enter a valid email address' },
{ value: 'user@', expected: 'Enter a valid email address' }
];
for (const data of invalidEmails) {
test(`reject email: "${data.value}"`, async ({
registrationPage
}) => {
await registrationPage.open();
await registrationPage.enterEmail(data.value);
await registrationPage.submit();
await expect(registrationPage.emailError)
.toHaveText(data.expected);
});
}Use parameterization for meaningful variations, not hundreds of nearly identical combinations.
Separate Input Data from Expected Results
interface DiscountScenario {
customerType: 'standard' | 'premium';
orderTotal: number;
coupon?: string;
expectedDiscount: number;
}
const scenarios: DiscountScenario[] = [
{
customerType: 'standard',
orderTotal: 100,
expectedDiscount: 0
},
{
customerType: 'premium',
orderTotal: 100,
expectedDiscount: 10
},
{
customerType: 'premium',
orderTotal: 100,
coupon: 'SAVE20',
expectedDiscount: 20
}
];This makes the business rules visible in the test data itself.
Do Not Store Secrets in Test Data Files
const adminCredentials = {
username: process.env.ADMIN_USERNAME,
password: process.env.ADMIN_PASSWORD
};
function requiredEnvironmentVariable(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(
`Missing required environment variable: ${name}`
);
}
return value;
}
export const secrets = {
adminUsername: requiredEnvironmentVariable('ADMIN_USERNAME'),
adminPassword: requiredEnvironmentVariable('ADMIN_PASSWORD')
};Manage Authentication State Carefully
Playwright can save authenticated browser state and reuse it. Those files contain sensitive cookies and headers that can impersonate an account, so they must never be committed.
playwright/.auth/
storage/auth/A setup project can generate the state before dependent projects execute. Project dependencies are the recommended mechanism because the setup participates in reporting and produces artifacts such as traces.
Database Seeding
A database seeder is appropriate when the organization approves direct access, the schema is controlled, API setup is unavailable, or the test requires a rare backend state.
export class SubscriptionSeeder {
constructor(private readonly db: DatabaseClient) {}
async createExpiredSubscription(
customerId: string
): Promise<string> {
const subscriptionId = randomUUID();
await this.db.query(
`
INSERT INTO subscriptions (
subscription_id,
customer_id,
status,
expiration_date
)
VALUES ($1, $2, $3, $4)
`,
[subscriptionId, customerId, 'EXPIRED', '2025-01-01']
);
return subscriptionId;
}
async deleteSubscription(
subscriptionId: string
): Promise<void> {
await this.db.query(
`
DELETE FROM subscriptions
WHERE subscription_id = $1
`,
[subscriptionId]
);
}
}Keep SQL in repositories or seeders, never inside test files. Direct database setup is powerful but couples tests to implementation details — prefer APIs when they provide the capability.
Test Data for Date-Sensitive Scenarios
// Bad — eventually invalid
const expirationDate = '2026-12-31';
// Better
export function addDays(date: Date, days: number): Date {
const result = new Date(date);
result.setUTCDate(result.getUTCDate() + days);
return result;
}
const expiredDate = addDays(new Date(), -1);
const activeDate = addDays(new Date(), 30);Use fixed dates only when testing specific historical or boundary behaviour. Keep timezone handling consistent, preferably with ISO timestamps and an agreed application timezone.
Use Data Contracts
interface SearchableStudent {
studentId: string;
firstName: string;
lastName: string;
graduationYear: number;
searchable: true;
subscriptionTier: 'basic' | 'premium';
}A fixture returning this object guarantees the record is present, searchable, correctly indexed, and on the expected subscription. That is far stronger than a comment saying "this student should exist in QA".
Track Data Ownership
interface AutomationMetadata {
createdBy: 'playwright';
runId: string;
testName: string;
workerIndex: number;
createdAt: string;
}This metadata helps find abandoned records, run scheduled cleanup, investigate failures, identify the creating pipeline, and separate automation data from manual testing data.
Create a Test Data Registry
type CleanupAction = () => Promise<void>;
export class TestDataRegistry {
private readonly cleanupActions: CleanupAction[] = [];
register(cleanup: CleanupAction): void {
this.cleanupActions.unshift(cleanup);
}
async cleanupAll(): Promise<void> {
const errors: Error[] = [];
for (const cleanup of this.cleanupActions) {
try {
await cleanup();
} catch (error) {
errors.push(
error instanceof Error
? error
: new Error(String(error))
);
}
}
if (errors.length > 0) {
throw new AggregateError(
errors,
'One or more test-data cleanup actions failed'
);
}
}
}const registry = new TestDataRegistry();
const user = await userApi.createUser(createUserData());
registry.register(() => userApi.deleteUser(user.id));
const order = await orderApi.createOrder(
createOrderData({ customerId: user.id })
);
registry.register(() => orderApi.deleteOrder(order.id));Cleanup actions are registered in reverse dependency order, so the order is deleted before the user.
Full Enterprise Fixture Example
import { test as base } from '@playwright/test';
import { createUserData } from '../factories/userFactory';
import { createProductData } from '../factories/productFactory';
import { UserApiService } from '../api/services/UserApiService';
import { ProductApiService } from '../api/services/ProductApiService';
type TestDataFixtures = {
registeredUser: {
id: string;
email: string;
password: string;
};
availableProduct: {
id: string;
name: string;
price: number;
};
};
export const test = base.extend<TestDataFixtures>({
registeredUser: async ({ request }, use) => {
const service = new UserApiService(request);
const input = createUserData();
const created = await service.createUser(input);
try {
await use({
id: created.id,
email: input.email,
password: input.password
});
} finally {
await service.deleteUser(created.id);
}
},
availableProduct: async ({ request }, use) => {
const service = new ProductApiService(request);
const input = createProductData({ inStock: true });
const created = await service.createProduct(input);
try {
await use({
id: created.id,
name: input.name,
price: input.price
});
} finally {
await service.deleteProduct(created.id);
}
}
});
export { expect } from '@playwright/test';test('customer can purchase an available product', async ({
registeredUser,
availableProduct,
loginPage,
productsPage,
checkoutPage
}) => {
await loginPage.open();
await loginPage.login(
registeredUser.email,
registeredUser.password
);
await productsPage.open();
await productsPage
.productCardByName(availableProduct.name)
.addToCart();
await checkoutPage.completeOrder();
await expect(checkoutPage.confirmationMessage)
.toContainText('Order confirmed');
});The test is independent because it receives its own user and its own product.
Expected Execution Flow
Generate unique user data
↓
Create user through API
↓
Generate unique product data
↓
Create product through API
↓
Execute Playwright UI test
↓
Delete product
↓
Delete userCommon Test Data Management Mistakes
- Sharing one user across all tests — this creates conflicts during parallel execution.
- Hardcoding production-like personal data instead of synthetic values.
- Creating records without any cleanup or ownership strategy.
- Depending on a previous test to create preconditions.
- Using unconstrained random data that violates business rules.
- Logging passwords, tokens, payment data, or personal information.
- Using UI setup for preconditions that are not what the test validates.
- Reusing stale environment data without validating it still exists.
Test 1 creates a customer.
Test 2 assumes the customer exists.
Test 3 deletes the customer.
// If Test 1 fails, the remaining tests fail.// Bad — random values can be invalid
price: Math.random() * 100000
// Better — a valid value, or a range approved by the business
price: 99.99Troubleshooting
Test passes alone but fails in the full suite
This usually indicates shared data or test-order dependency. Check for shared accounts, shared carts, fixed record names, tests changing the same settings, or cleanup deleting another test's data.
API-created record does not appear in the UI
The application may use caching, search indexing, asynchronous synchronisation, event processing, or read replicas. Wait for the actual observable condition.
await expect
.poll(async () => {
return productApi
.getProduct(product.id)
.then(result => result.searchable);
})
.toBe(true);Cleanup fails because the record is already deleted
Make cleanup idempotent. Treat an expected "not found" response as successful cleanup.
Generated email is rejected
The application may restrict domains or formats. Configure your factory to produce values accepted by the test environment.
Data conflicts in CI but not locally
CI probably uses more workers. Include a UUID, worker index, or run identifier in generated data.
Fixture setup times out
Playwright includes fixture setup time in the test timeout for test-scoped fixtures. Optimise long data preparation, move it to worker scope when safe, or give the fixture an appropriate timeout.
Best Practices
- Give each test ownership of its data.
- Generate unique values for mutable records.
- Prefer APIs for fast setup and cleanup.
- Use fixtures to control the data lifecycle.
- Store stable reference data separately.
- Keep secrets outside source control.
- Use TypeScript interfaces as data contracts.
- Make cleanup reliable and idempotent.
- Include worker or run identifiers in generated data.
- Avoid test-order dependencies.
- Use one account per worker when unique creation is impossible.
- Separate environment configuration from test logic.
- Track automation-created records with metadata.
- Use factories for defaults and builders for complex scenarios.
- Keep SQL and API setup outside test files.
Frequently Asked Questions
Should all test data be generated dynamically?
No. Stable reference values can remain static. Mutable business records such as users, products, carts, and orders are better candidates for dynamic creation.
Is JSON enough for test data management?
JSON is useful for stable reference data, but it does not solve creation, uniqueness, parallel execution, cleanup, or secret management.
Should Playwright tests create data through the UI?
Only when the creation workflow is itself under test. Otherwise API-based setup is faster and more reliable.
Can tests share a login account?
Read-only tests may share authentication state. Tests that modify server-side state should use isolated accounts or one account per parallel worker.
Where should passwords be stored?
In environment variables or an approved secrets-management system. Never commit passwords to Git.
Should test data be deleted after every test?
Mutable data should usually be cleaned after the owning test. Some organizations use tagged records with scheduled cleanup when immediate deletion is not practical.
Can database scripts create test data?
Yes, when direct database setup is approved and appropriate. However, API setup produces data through business-supported paths and reduces schema coupling.
Conclusion
Reliable automation depends on reliable test data. A professional Playwright framework manages the complete data lifecycle rather than hardcoding a few values inside test files.
Define
↓
Generate or retrieve
↓
Create
↓
Use
↓
Validate
↓
Clean upThe strongest strategy combines typed data models, factories, builders, fixtures, API-based setup, environment configuration, secure secrets, parallel-safe records, automatic cleanup, and clear data ownership. When every test controls its own preconditions and cleanup, the suite becomes easy to run locally, in parallel, and across CI/CD pipelines.
Keywords: Playwright test data management, test data management in Playwright, Playwright dynamic test data, Playwright data factory, Playwright fixtures test data, Playwright API test setup, Playwright parallel test data, Playwright TypeScript test data, enterprise test data management, automation test data best practices.
Get Playwright tutorials in your inbox
Weekly tips, real-world examples, and framework patterns – no spam, unsubscribe anytime.
Playwright Framework Series
View all →- 1Getting Started with Playwright: Installation, Setup, and Your First Test
- 2Playwright Locators: The Complete Guide with Real-World Examples
- 3Playwright Actions: Complete Guide to Click, Fill, Hover, Keyboard, Mouse & File Upload
- 4Playwright Assertions: The Complete Guide with Real-World Examples
- 5Playwright Auto Waiting: The Complete Guide with Real-World Examples
- 6Playwright Fixtures: The Complete Guide with Real-World Examples
- 7Playwright Browser Context & Multiple Tabs: The Complete Guide with Real-World Examples
- 8Playwright Authentication & Session Management: Complete Guide with Enterprise Examples
- 9Playwright Network Interception & API Mocking: Complete Guide with Real-World Examples
- 10Playwright Page Object Model (POM): The Complete Guide with Enterprise Examples
- 11Page Object Model with Playwright: A Practical Guide
- 12How to Build a Robust Professional Playwright Framework from Scratch (Step-by-Step)
- 13Building an Enterprise Playwright Framework from Scratch
API Testing Series
View all →- 1Playwright API Testing: The Complete Guide with Real-World Examples
- 2Part 1 : Playwright API Testing Tutorial: Build Enterprise-Level API Automation Framework
- 3Part 2: Playwright API Testing Tutorial: Setting Up Project & Sending Your First API Request
- 4Part 3: Mastering CRUD Operations in Playwright API Testing
- 5Part 4: Authentication in Playwright API Testing (Bearer Token, JWT, API Key & Reusable Fixtures)
- 6Part 5: Building an Enterprise-Level Playwright API Automation Framework
- 7Part 6: API Models, Schema Validation & Test Data Management in Playwright
- 8Part 7: Custom Fixtures, Hooks, Logging & Parallel Execution in Playwright API Testing
- 9Part 8: Building a Production-Ready Playwright API Framework (Environment Management, CI/CD, Reporting & Best Practices)
- 10Part 9: Advanced Playwright API Testing Techniques for Enterprise Automation
- 11Part 10: Building a Complete Enterprise Playwright API Automation Framework (Final Part)
- 12API Testing with Playwright: Request Context, Auth, and Assertions