Q7 of 38 · CI/CD & DevOps
How do you handle test data isolation when tests run in parallel?
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 names —
name = '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(() => {});
});