Root cause #
Test data libraries are deterministic by design. faker.seed(42) guarantees the same sequence of names, emails, and IDs on every run so that snapshots stay stable. That determinism is a feature inside one process and a liability across many. When Playwright runs with --shard=1/4 and --shard=2/4 on separate GitHub Actions matrix jobs, both jobs import the same setupSeed() helper, both call faker.seed(42), and both generate [email protected]. If those jobs target a shared database, the second INSERT hits a unique constraint and the test that “sometimes fails” is actually losing a write race.
The collision is not limited to faker. Any source of supposedly-unique values that is keyed off a fixed seed will repeat across shards: crypto-free UUID stubs, sequential counter fixtures, hard-coded tenant slugs, or a shared S3 prefix. The reason it presents as flakiness rather than a hard failure is timing — when shards happen to write at slightly different moments, one wins and the suite passes; under load they interleave and one loses. Fixing it means giving every shard a seed that is reproducible (so a failure can be replayed) yet unique (so namespaces never overlap), and the shard index is the natural entropy source.
Step-by-step fix #
1. Expose the shard index to the test process #
GitHub Actions matrix jobs already know their shard number. Pass it through as an environment variable so the test code can read it.
# .github/workflows/e2e.yml — fan out into 4 shards, each with its own index
jobs:
e2e:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v4
- run: npm ci
# SHARD_INDEX is the per-job entropy source for the seed; fail-fast:false
# keeps every shard reporting so a collision is visible, not hidden.
- run: npx playwright test --shard=${{ matrix.shard }}/4
env:
SHARD_INDEX: ${{ matrix.shard }}
SHARD_TOTAL: "4"
2. Derive a per-shard seed instead of a constant #
Read the index once at setup and fold it into the base seed. Use the same helper for faker and for any other deterministic generator.
// tests/support/seed.ts — single source of per-shard determinism
import { faker } from '@faker-js/faker';
const BASE_SEED = 1000;
export function shardSeed(): number {
// Falls back to worker id locally where SHARD_INDEX is unset; the offset
// keeps shard ranges far apart so generated counters never overlap.
const shard = Number(process.env.SHARD_INDEX ?? 1);
return BASE_SEED + shard * 1_000_000;
}
export function setupSeed(): void {
faker.seed(shardSeed()); // reproducible per shard, distinct across shards
}
3. Namespace fixtures and DB keys by shard, not just the RNG #
Seeding the RNG fixes generated values, but hard-coded identifiers still collide. Prefix every shared resource with the shard index so concurrent writes land in separate ranges — the same principle that keeps Cypress component test race conditions from corrupting shared module state.
// tests/support/namespace.ts — disjoint identifiers per shard
const shard = Number(process.env.SHARD_INDEX ?? 1);
export function scopedEmail(local: string): string {
// s1_alice@... vs s2_alice@... — unique constraint can never collide.
return `s${shard}_${local}@example.com`;
}
export function scopedTenant(): string {
return `tenant-shard-${shard}`; // each shard owns one tenant row
}
4. Print the resolved seed so failures are replayable #
A derived seed is only useful if you can reproduce it. Log it at startup and on failure.
// tests/support/global-setup.ts
import { shardSeed } from './seed';
export default function globalSetup() {
// Echo the seed so a CI failure on shard 3 can be replayed locally with
// SHARD_INDEX=3 — determinism is worthless if you can't reconstruct it.
console.log(`[seed] shard=${process.env.SHARD_INDEX ?? 'local'} seed=${shardSeed()}`);
}
The same approach maps onto Jest: --shard=1/4 exposes the split, and reading SHARD_INDEX (or JEST_WORKER_ID within a shard) into the seed gives you the same disjoint ranges. For worker-level database isolation underneath the shard, pair this with isolating database state in parallel Jest workers.
Pitfalls #
- Forgetting the local fallback. If
SHARD_INDEXis unset locally and you callNumber(undefined), the seed becomesNaNand faker silently reverts to its default — re-introducing collisions. Mitigation: default to1(orJEST_WORKER_ID) explicitly. - Small offsets that overlap. Adding only
+shardto the base seed lets two shards generate overlapping counter ranges once a test makes thousands of records. Mitigation: multiply the shard by a large stride (e.g.1_000_000). - Seeding faker but not the database sequence. A per-shard RNG still collides if every shard inserts into the same auto-increment table from a fixed starting row. Mitigation: namespace the key, not just the random value.
- Re-seeding mid-test. Calling
faker.seed()again inside a test resets the stream and can re-emit values an earlier test already used. Mitigation: seed exactly once in global setup. fail-fast: truehiding the collision. A cancelled matrix masks which shard lost the race. Mitigation: setfail-fast: falseso every shard reports.
Reliability targets #
| Metric | Target | How to hit it |
|---|---|---|
| Cross-shard data collision rate | 0% |
Per-shard seed + namespaced keys |
| Seed reproducibility | 100% of failures replayable |
Log resolved seed in global setup |
| Inter-shard pass-rate variance | < 0.5% between shards |
Disjoint ranges, no shared fixtures |
| Suite flake rate after fix | < 0.5% |
Combine with per-worker DB isolation |
Frequently Asked Questions #
Q: Why not just give every shard a random seed instead of deriving one?
A: A purely random seed prevents collisions but destroys reproducibility — when shard 3 fails you cannot replay the exact data. Deriving from the shard index gives you both: distinct ranges and a value you can reconstruct with SHARD_INDEX=3.
Q: Does this matter if each shard uses its own ephemeral database? A: Less so for DB keys, but seed collisions can still produce identical snapshot files, identical external-API request bodies, or identical S3 prefixes. Per-shard seeding is cheap insurance even with isolated databases.
Q: How does this interact with retries? A: Retries within a shard reuse that shard’s seed, which is correct — you want the retry to reproduce the same data. Cross-shard distinctness is unaffected because the offset is fixed per job.
Deterministic Namespacing Beats Randomness #
The instinct when two workers collide is to add randomness — a random suffix, a UUID, a timestamp — and it works while making failures unreproducible. A collision that occurred with random keys cannot be replayed, because the keys are different on the next run, so the investigation starts from scratch every time.
Deriving names from the worker index gives the same separation with none of that cost. The index is stable within a run and predictable across runs, so a failure can be reproduced by running the same shard with the same worker count, and the names in the logs mean the same thing on the second attempt as on the first.
Where a value must be unique across runs as well as across workers — an external account, a record in a shared staging system — combining the worker index with a run identifier keeps both properties: unique enough to avoid collisions between concurrent pipelines, and reconstructible from the run’s own metadata.
// Deterministic within a run, unique across runs, reproducible either way.
// Trade-off: slightly more plumbing than a random suffix, and it is the
// difference between a reproducible failure and a mystery.
const worker = process.env.TEST_WORKER_INDEX ?? '0';
const run = process.env.CI_RUN_ID ?? 'local';
const key = `w${worker}-${run}`;
The Inventory Worth Taking Once #
Collisions come from a small, enumerable set of resources, and auditing for them once is faster than discovering them one incident at a time.
Six categories cover almost everything: ports bound by a local server or a mock, temporary paths for downloads, screenshots and generated files, database identifiers such as fixed primary keys or unique constraints on a constant value, external accounts used for signing in, cache keys in a shared store, and file names for artifacts written to a common directory.
For each, the question is the same: can two workers hold this at the same time? Ports should be allocated from the operating system rather than assigned; temporary paths and artifact names should carry the worker index; database records should use supplied keys derived from it; accounts should be provisioned per worker at start-up.
The audit is mechanical and produces a finite list. Running it before increasing the worker count — rather than after the failures start — turns a scaling exercise into a configuration change, and it is considerably cheaper than diagnosing the same collisions individually as intermittent failures spread across a dozen specs.