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.