How to fix Playwright DB data collisions in parallel test workers?

Playwright

Parallel Playwright workers interact with a shared database simultaneously, causing data collisions when tests assume exclusive ownership of records. A test that creates a user with email test@example.com will fail if another worker in the same run already created that record. A test that deletes all projects in a workspace will break tests running concurrently in the same workspace. The solution is not to serialize all tests — it is to make each test own its data exclusively.

Common mistake

test.beforeAll(async ({ request }) => {
  // Creates a shared test user — all workers collide on this
  await request.post('/api/users', {
    data: { email: 'test@example.com', password: 'secret' },
  });
});

test('creates project', async ({ page }) => {
  await page.goto('/projects/new');
  await page.getByLabel('Name').fill('Test Project');
  // Collides if another worker is creating projects in the same account
});

The fix

Generate unique identifiers per worker and per test to create fully isolated data:

import { test, expect } from '@playwright/test';

test('creates and views project', async ({ page }, testInfo) => {
  // workerIndex is unique per parallel worker (0, 1, 2...)
  // testInfo.testId is unique per test in the run
  const uniqueSuffix = `w${testInfo.workerIndex}-${Date.now()}`;
  const projectName = `Test Project ${uniqueSuffix}`;
  const userEmail = `tester+${uniqueSuffix}@example.com`;

  // Create isolated user via API
  const response = await page.request.post('/api/users', {
    data: { email: userEmail, password: 'TestPass123!' },
  });
  expect(response.ok()).toBeTruthy();

  // Create project under isolated user context
  await page.goto('/login');
  await page.getByLabel('Email').fill(userEmail);
  await page.getByLabel('Password').fill('TestPass123!');
  await page.getByRole('button', { name: 'Sign in' }).click();

  await page.goto('/projects/new');
  await page.getByLabel('Project name').fill(projectName);
  await page.getByRole('button', { name: 'Create' }).click();

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

For test suites that need shared fixtures, use worker-scoped fixtures that create isolated data per worker:

import { test as base, expect } from '@playwright/test';

type WorkerFixtures = { workerAccount: { email: string; token: string } };

const test = base.extend<{}, WorkerFixtures>({
  workerAccount: [
    async ({ playwright }, use, workerInfo) => {
      const email = `worker${workerInfo.workerIndex}+${Date.now()}@example.com`;

      const ctx = await playwright.request.newContext({ baseURL: process.env.API_URL });
      await ctx.post('/api/users', { data: { email, password: 'TestPass123!' } });
      const loginRes = await ctx.post('/api/auth/login', { data: { email, password: 'TestPass123!' } });
      const { token } = await loginRes.json();

      await use({ email, token });

      // Cleanup: delete the worker's account after all tests in this worker finish
      await ctx.delete(`/api/users/${email}`, { headers: { Authorization: `Bearer ${token}` } });
      await ctx.dispose();
    },
    { scope: 'worker' },
  ],
});

export { test };

Why it works

testInfo.workerIndex is a number unique to each parallel worker within a test run, and Date.now() ensures uniqueness across reruns. Together they create test-owned records with no collisions. Worker-scoped fixtures create shared data once per worker process (not once per test), which reduces API call overhead while still maintaining isolation between workers. Cleanup in fixture teardown prevents test pollution between runs without requiring a full database reset.

Tips

  • Never use static test data (fixed email addresses, usernames, IDs) in tests that will run in parallel — any static identifier is a potential collision point.
  • For tests that only read data rather than modifying it (e.g., validating a public product catalog), it is safe to share read-only fixtures across workers.
  • If your API supports it, add a testRunId tag to all created records and clean up by tag in test teardown — more reliable than tracking individual IDs.
  • For database-heavy test suites, consider running tests against a transaction that's rolled back after each test rather than creating and deleting real records.