Test data management is where many automation suites silently collapse under production pressure. We've seen high-confidence test runs pass locally, then fail randomly in CI because tests were sharing state, reusing stale data, or cleaning up only sometimes. Test data management: Playwright lessons from production means building isolation, uniqueness, and cleanup into every test from day one.
Why test data matters in Playwright
Playwright tests run at speed. They can execute hundreds of scenarios in minutes. That velocity is only valuable if each test operates on reliable, independent data. When tests share fixtures—a user account, an order, a customer record—one test's side effects become another test's random failure. In production systems handling real transactions, orders, and users, test data isolation isn't optional. It's the difference between a suite you trust and one you work around.
We've debugged failures in e-commerce checkout flows, CRM contact imports, and ride-hailing dispatchers where the root cause was test pollution: one test created a user that another test expected to not exist, or deleted data a parallel test still needed. The fix isn't better assertions. It's intentional test data architecture.
Create per test — Each test creates what it needs
The foundational rule: every test owns its data setup. Don't rely on a shared test database seeded once before the suite runs. Don't assume a "standard user" exists. Each test should create the exact entities it needs in its setup phase.
Here's why this matters in practice. Imagine you're testing an e-commerce platform with a checkout flow. One test verifies that discount codes work. Another test verifies that inventory is decremented. If both tests rely on a pre-seeded product with ID 12345, and one test deletes it during cleanup, the other test fails sporadically depending on execution order.
Instead, each test creates its own product:
import { test, expect } from '@playwright/test';
test('apply discount code at checkout', async ({ page }) => {
// Setup: create product and discount for THIS test only
const product = await createProduct({ name: 'Test Laptop', price: 999 });
const discount = await createDiscount({ code: 'SAVE10', percentage: 10 });
await page.goto('/products/' + product.id);
await page.click('text=Add to cart');
await page.goto('/checkout');
await page.fill('input[name="discount"]', 'SAVE10');
await page.click('button:has-text("Apply")');
const finalPrice = await page.locator('[data-testid="total-price"]').textContent();
expect(finalPrice).toContain('899.10'); // 999 - 10%
});
test('inventory decrements after purchase', async ({ page }) => {
// This test creates its own product, unaffected by the other test
const product = await createProduct({ name: 'Test Monitor', price: 299, stock: 5 });
await page.goto('/products/' + product.id);
const initialStock = await page.locator('[data-testid="stock"]').textContent();
expect(initialStock).toContain('5');
await page.click('text=Add to cart');
await page.goto('/checkout');
await completeCheckout();
await page.reload();
const updatedStock = await page.locator('[data-testid="stock"]').textContent();
expect(updatedStock).toContain('4');
});Notice neither test assumes state. Both create what they test. If the first test fails and skips cleanup, the second test still works because it creates fresh data.
In a real SaaS application, apply the same pattern to users, organizations, API keys, and configurations. Use your application's API or database to create test fixtures, or use Playwright's built-in fixture system.
Unique values — Add random suffixes to emails and names
Unique values matter because your application often enforces uniqueness: email addresses must be unique, usernames must be unique, order numbers must be unique. If two tests try to create a user with the same email, the second test fails.
The solution is simple: append random values. A common pattern is a timestamp or UUID suffix:
// Helper function for generating unique test data
function uniqueEmail(base = 'testuser') {
return `${base}+${Date.now()}@test.example.com`;
}
function uniqueName(base = 'Test') {
return `${base} ${Math.random().toString(36).substring(7)}`;
}
test('register new user with email verification', async ({ page }) => {
const email = uniqueEmail('signup');
const name = uniqueName('User');
await page.goto('/register');
await page.fill('input[name="email"]', email);
await page.fill('input[name="name"]', name);
await page.fill('input[name="password"]', 'SecurePass123!');
await page.click('button:has-text("Register")');
await expect(page.locator('text=Verification email sent')).toBeVisible();
});
test('duplicate email rejected', async ({ page }) => {
const email = uniqueEmail('duplicate');
// First registration succeeds
await registerUser(email, 'User One', 'Password123!');
// Second registration with same email should fail
await page.goto('/register');
await page.fill('input[name="email"]', email);
await page.fill('input[name="name"]', 'User Two');
await page.fill('input[name="password"]', 'Password123!');
await page.click('button:has-text("Register")');
await expect(page.locator('text=Email already in use')).toBeVisible();
});This pattern scales. For API-driven workflows, generate unique request bodies. For database tests, use unique SKUs or order IDs. The principle is consistent: every value that must be unique gets a random component.
We've found that timestamps work well for most scenarios because they're readable in logs. UUIDs work too, but they're harder to trace during debugging. Choose what your team finds easiest to correlate in logs.
Clean up — Delete created data in fixture teardown
Data creation without cleanup leads to database bloat and, worse, leftover data that pollutes later test runs. Playwright's fixture system makes cleanup straightforward with teardown blocks.
Structure your fixtures to track what they create and clean it up:
import { test as base, expect } from '@playwright/test';
type TestData = {
productIds: string[];
userIds: string[];
orderId?: string;
};
const test = base.extend<{ testData: TestData }>({
testData: async ({}, use) => {
const data: TestData = {
productIds: [],
userIds: [],
};
// Setup phase: make data available to test
yield data;
// Teardown phase: clean everything up
for (const productId of data.productIds) {
await deleteProduct(productId);
}
for (const userId of data.userIds) {
await deleteUser(userId);
}
if (data.orderId) {
await deleteOrder(data.orderId);
}
},
});
test('place order with multiple products', async ({ page, testData }) => {
// Create products and track them for cleanup
const product1 = await createProduct({ name: 'Keyboard' });
testData.productIds.push(product1.id);
const product2 = await createProduct({ name: 'Mouse' });
testData.productIds.push(product2.id);
// Create user and track for cleanup
const user = await createUser({ email: uniqueEmail() });
testData.userIds.push(user.id);
// Perform test flow
await page.goto('/login');
await loginAs(user.email, 'password');
await page.goto(`/product/${product1.id}`);
await page.click('text=Add to cart');
await page.goto(`/product/${product2.id}`);
await page.click('text=Add to cart');
await page.goto('/checkout');
const order = await completeCheckout();
testData.orderId = order.id;
await expect(page.locator('text=Order confirmed')).toBeVisible();
// Cleanup happens automatically after this test
});Cleanup should also run even if a test fails. Playwright's fixture teardown runs regardless of test outcome, so your database stays clean. This is critical for CI environments where cleanup failures accumulate silently.
For API-heavy tests, consider creating a helper that batches deletes. Some teams maintain a "cleanup service" endpoint that accepts test IDs and purges associated data in one request, speeding up teardown in large test suites.
Avoid shared state — Tests that depend on each other fail randomly
Test interdependence is the hidden killer. You might write tests in sequence: create a customer, then run a report, then archive the customer. Each test "passes" locally. But in CI, where tests run in parallel or in random order, they fail sporadically.
Here's a real example we've debugged multiple times:
// ❌ BAD: Tests depend on each other
test('create customer account', async ({ page }) => {
await page.goto('/admin/customers/new');
await page.fill('input[name="name"]', 'Test Customer');
await page.fill('input[name="email"]', 'testcustomer@test.com');
await page.click('button:has-text("Create")');
// This customer exists globally now... maybe
await expect(page.locator('text=Customer created')).toBeVisible();
});
test('generate customer report', async ({ page }) => {
// Assumes the customer from the previous test exists
await page.goto('/reports/customers');
await page.click('text=Generate Report');
// If create_customer_account didn't run, or ran in parallel, this fails
await expect(page.locator('text=1 customer')).toBeVisible();
});
test('archive customer', async ({ page }) => {
// Assumes the customer still exists from earlier
await page.goto('/admin/customers');
await page.click('text=Test Customer');
await page.click('button:has-text("Archive")');
await expect(page.locator('text=Customer archived')).toBeVisible();
});These tests work in sequence but fail in any other configuration. The fix is to make each test completely independent:
// ✅ GOOD: Tests are independent
test('create customer account', async ({ page, testData }) => {
const customer = await createCustomerViaAPI({
name: 'Test Customer',
email: uniqueEmail('customer'),
});
testData.customerIds = [customer.id];
await page.goto('/admin/customers/' + customer.id);
await expect(page.locator('text=Test Customer')).toBeVisible();
});
test('generate customer report', async ({ page, testData }) => {
// Create its own customer; doesn't depend on previous test
const customer = await createCustomerViaAPI({
name: 'Report Test Customer',
email: uniqueEmail('report'),
});
testData.customerIds = [customer.id];
await page.goto('/reports/customers');
await page.fill('input[name="customer_id"]', customer.id);
await page.click('button:has-text("Generate")');
await expect(page.locator('text=Report for Report Test Customer')).toBeVisible();
});
test('archive customer', async ({ page, testData }) => {
// Create its own customer; doesn't depend on previous test
const customer = await createCustomerViaAPI({
name: 'Archive Test Customer',
email: uniqueEmail('archive'),
});
testData.customerIds = [customer.id];
await page.goto('/admin/customers/' + customer.id);
await page.click('button:has-text("Archive")');
await expect(page.locator('text=Customer archived')).toBeVisible();
});Each test now creates its own data and cleans up after itself. They can run in any order, in parallel, or in isolation. This is the mark of a production-grade test suite.
Common mistakes
Mistake 1: Seeding a "golden" test database once. Teams often create a single test database snapshot and revert to it before each test run. This works until tests run in parallel or modify shared data unpredictably. Per-test creation is more verbose but vastly more reliable.
Mistake 2: Reusing emails or unique values in loops. If you create ten test users in a loop with the same email, nine will fail silently. Always append the loop index or a timestamp: user_${i}@test.com or user_${Date.now()}_${i}@test.com.
Mistake 3: Skipping cleanup in "quick" tests. It's tempting to skip teardown for fast tests, assuming they won't pollute the database. They will. Cleanup is not optional. Automate it in fixtures and trust the system.
Mistake 4: Cleaning up only on success. Tests fail. If cleanup only runs on success, failed tests leave garbage behind. Use Playwright's fixture teardown, which runs regardless of test outcome.
Mistake 5: Testing the test data creation logic. Don't waste time asserting that your createUser helper works. It should be boring. If it fails, the test setup fails and you see it immediately. Focus assertions on application behavior, not fixture setup.
Key takeaways
- Every test creates the data it needs. No shared fixtures, no assumptions about pre-existing state.
- Make values unique by appending timestamps or random suffixes to emails, names, and IDs.
- Use Playwright fixtures with teardown to clean up data automatically, even if tests fail.
- Never make tests depend on each other. Each test must run independently, in any order, in parallel.
- When a test fails due to data issues, the problem is usually shared state or missing cleanup, not flaky selectors.
- Test data management isn't overhead. It's the foundation of a suite you can trust in production.






Comments
Loading comments…