← Recipes

阅读中文版本

Make Randomized Tests Reproducible with Seeds

Route all randomness through one seeded generator, print the seed on failure, and replay the exact failing sequence with one flag.

~30 mintestingdeterminismci

Task

Centralize randomness behind a seeded PRNG, surface the seed in test output, and verify failures replay identically from the seed.

When to use this

A property test failed once in CI with a random input nobody saved. The tester could not reproduce it, so the failure was closed as unreproducible noise.

Prerequisites

  • A test suite using Math.random, crypto random, or a fuzzer
  • A PRNG library or built-in seed support (fast-check, seedrandom, or vitest seed)
  • A convention for logging seeds in failure output

Steps

1. Audit where randomness enters the suite.

rg -n "Math\.random|crypto\.randomUUID|crypto\.getRandomValues" src/ tests/ | head -10

Expected output: Every call site prints; direct randomness calls outside a seeded generator are the reproducibility leaks to fix.

2. Route randomness through one seeded generator.

// tests/helpers/rand.ts
import { createRequire } from 'node:module';
const seed = Number(process.env.TEST_SEED ?? Date.now());
console.error(`[rand] seed=${seed}  (replay with TEST_SEED=${seed})`);

// xorshift128: tiny, fast, seedable
let s = seed >>> 0 || 1;
export const rand = () => {
  s ^= s << 13; s >>>= 0;
  s ^= s >> 17;
  s ^= s << 5;  s >>>= 0;
  return s / 4294967296;
};
export const pick = <T>(xs: readonly T[]) => xs[Math.floor(rand() * xs.length)];

Expected output: All randomized helpers derive from one seed printed at startup; any failing run can be replayed with the logged TEST_SEED value.

3. Seed everything else that is implicitly random: time, ids, order.

// before: hidden randomness
const id = crypto.randomUUID();
const at = new Date();
tests.sort();

// after: derived from the same seed
const id = `id-${rand().toString(36).slice(2, 10)}`;
const at = new Date(seed);
tests.sort((a, b) => hash(seed, a.name) - hash(seed, b.name));

Expected output: UUIDs, timestamps, and execution order become functions of the seed, so replay reproduces them exactly instead of merely approximately.

4. Prove replay works by failing on purpose at a seed.

TEST_SEED=777 npx vitest run tests/checkout.test.ts 2>&1 | grep -E "seed=|FAIL|✓|×" | head -8

Expected output: The output shows the seed line; injecting a temporary assertion that fails only under seed 777 demonstrates identical replay on rerun.

5. Make CI feed its seed back into reruns.

# ci.yml
- name: Test
  run: npx vitest run 2>&1 | tee test.log
- name: Rerun failures with the same seed
  if: failure()
  run: |
    SEED=$(grep -oP "seed=\K[0-9]+" test.log | head -1)
    echo "replaying with seed=$SEED"
    TEST_SEED=$SEED npx vitest run --rerun-tried || true

Expected output: On failure, CI logs the seed and immediately reruns with it, so the log itself contains a locally reproducible trace.

Troubleshooting

Problem

Test still not reproducible after seeding

Solution

Something random remains: async timing, network, or a library internal; search for remaining direct random calls, then stub Date and Math.random globally in test setup.

Problem

Library has its own seed mechanism

Solution

Forward your seed into it (fast-check seed option, jest randomSeed) rather than maintaining two systems; print both if the library seed differs from yours.

Problem

Parallel workers each got a different seed

Solution

Derive per-worker seeds deterministically from the main seed and the worker index, and log worker plus seed, so any single failure replays in that worker context.

Limitations

  • Seeds control inputs, not wall-clock timing; combine with fake timers for full determinism.
  • Replay fidelity ends where the code reads true entropy (crypto-random tokens, OS randomness); those must be stubbed in test setup.

An unreproducible failure is a closed bug report

Random inputs make tests strong and failures useless, unless the randomness is seeded and the seed survives the failure. One generator, one environment variable, one console line convert a once-in-a-CI flake into a locally replayable script.

# The failure log now carries its own replay key
npx vitest run tests/checkout.test.ts
# [rand] seed=1729664701234  (replay with TEST_SEED=1729664701234)
# × applies coupon to random cart  (expected 850, got 925)

# Replay exactly
TEST_SEED=1729664701234 npx vitest run tests/checkout.test.ts
# × applies coupon to random cart  (expected 850, got 925)  <-- identical

Seed the invisible randomness too

Random UUIDs, Date.now, and even test execution order change the outcome as much as random inputs do. Deriving all of them from the same seed is what makes replay exact rather than usually-similar. The audit grep is the tool that finds the leaks you forgot you had.

The seed belongs in the artifact

A seed printed only to a disappeared terminal saves nobody. Emit it to stderr on every run, capture it in CI logs, and have the rerun job consume it automatically. The best reproducibility system is the one that works during an incident, not only in a demo.