Q7 of 38 · CI/CD & DevOps

How do you handle test data isolation when tests run in parallel?

CI/CD & DevOpsMidci-cdparalleltest-dataisolationflake

Short answer

Short answer: Generate unique data per test (UUIDs in usernames, emails, IDs), use per-worker databases or schemas, transactional rollback for unit/integration, and tagged tenants in shared environments. Avoid shared mutable state — that's the canonical parallel-test failure mode.

Detail

Parallel tests need data that can't collide. The strategies stack by layer.

Strategy 1 — generate unique values. user-${uuid()}@example.com, random UUIDs for IDs, time-stamped tenant names. Eliminates almost all primary-key collisions. Cheap, works in any environment.

Strategy 2 — per-worker isolation.

  • Per-worker database: each parallel worker gets its own DB or schema. Heavy but absolute isolation. Use case: integration tests with complex schemas.
  • Per-worker schema in shared DB: lighter — SET search_path TO worker_${id} in Postgres. Workers see only their own tables.
  • Per-worker port / process: each worker spins up its own service instance. Use case: stateful service tests.

Strategy 3 — transactional rollback. Wrap each test in a transaction; rollback on completion. The DB is logically clean after each test. Limitation: doesn't work if the system-under-test commits internally (e.g. background jobs).

Strategy 4 — tagged-tenant isolation in shared envs. All tests share staging, but each test creates a tenant tagged with its run ID. Cleanup is a periodic sweep deleting old tagged tenants. Use case: E2E against a long-lived staging environment.

Anti-patterns:

  • Shared seed data — tests assume "user 1 exists". One test mutates user 1; another fails. Classic parallel-test flake.
  • Order-dependent tests — test B depends on test A having run. Parallel execution randomises order; B fails 50% of the time.
  • Hard-coded namesname = 'test' collides between workers. Always randomise.
  • Cleanup-on-teardown without idempotency — if cleanup fails, the next run inherits the mess.

The good test design rule: a single test should set up its own data, exercise the system, and assert. It shouldn't care what other tests did or are doing in parallel.

// EXAMPLE

test-data-pattern.ts

import { randomUUID } from 'node:crypto';

test('create order', async () => {
  // Unique data — no collision with parallel workers
  const userId = `user-${randomUUID()}`;
  const email = `${userId}@test.example`;

  const user = await api.createUser({ email, name: 'Test' });
  const order = await api.createOrder(user.id, { sku: 'A1' });

  expect(order.userId).toBe(user.id);
  // Cleanup is per-test, idempotent
  await api.deleteUser(user.id).catch(() => {});
});

// WHAT INTERVIEWERS LOOK FOR

Stack of strategies (unique values + per-worker isolation + transactional rollback + tagged tenants) and recognition of order-dependence as a parallel-test killer.

// COMMON PITFALL

Adding sleeps or retries to mask data collisions. The flake is now intermittent rather than frequent; the bug is still there.