Skip to content
faker.tools
All 79

Guide · 11 min read

Seeded Randomness: How to Make Test Fixtures Reproducible

Math.random() cannot be seeded, and that is why your test suite is flaky. What to use instead, and how to derive a whole dataset from one string.

Here is a failure every team eventually has. A test goes red in CI. Someone reruns it and it goes green. It fails again two days later, on a different machine, in a different test. Nobody can reproduce it locally. After a week of intermittent noise, the team adds a retry and stops thinking about it.

The test was almost certainly right. Somewhere in the fixture there is a random value — a name with an apostrophe, a quantity of zero, a date on a leap day, a string just long enough to hit a column limit — and one run in two hundred generates it. The randomness found a real bug and then destroyed the evidence.

The fix is not to remove randomness. Random fixtures find bugs that hand-written ones never will, because a human writing test data unconsciously writes data their code handles. The fix is to make randomness reproducible, so that the run which failed can be replayed exactly.

Math.random() cannot be seeded

The first thing people try is seeding the built-in generator. You cannot. The ECMAScript specification defines Math.random() as returning a value "chosen randomly or pseudo randomly with approximately uniform distribution," and explicitly leaves the algorithm and its seeding to the implementation. There is no API for supplying a seed and never has been.

V8 currently uses xorshift128+, seeded from the platform entropy source at context creation. You cannot reach that state, and even if you monkey-patched Math.random you would only be redirecting your own calls — every library in your dependency tree would still be drawing from the unseeded one.

So a seeded generator has to be your own function, threaded explicitly through the code that needs it. That constraint turns out to be a feature: it makes the randomness in your system visible.

A pseudo-random generator in nine lines

You do not need a library. mulberry32 is a 32-bit generator that fits in a few lines, passes the gjrand test suite, and has a period of 2³²:

function mulberry32(seed) {
  let a = seed >>> 0;
  return function () {
    a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

const rand = mulberry32(1337);
rand(); // 0.4471209812909365 — the same number, forever, everywhere

The Math.imul calls are doing real work: JavaScript numbers are doubles, so ordinary multiplication of two large integers loses low bits to floating-point rounding. Math.imul performs C-style 32-bit integer multiplication with wraparound, which is what the algorithm requires.

A period of 2³² — about four billion values — is ample for fixtures and far too small for anything else. If you need more, sfc32 and xoshiro128** are similarly compact with vastly longer periods. What you must not do is use any of them where unpredictability matters, which we will come back to.

Turning a string into a seed

mulberry32(1337) works but is unmemorable. What you actually want is to seed from a description — "invoice-fixtures-v2" — so the seed documents itself in the diff. Hash the string to an integer first. FNV-1a is four lines:

function fnv1a(str) {
  let h = 0x811c9dc5;
  for (let i = 0; i < str.length; i++) {
    h ^= str.charCodeAt(i);
    h = Math.imul(h, 0x01000193);
  }
  return h >>> 0;
}

const rand = mulberry32(fnv1a("invoice-fixtures-v2"));

Now the seed is a name. checkout-empty-cart, user-list-page-3, bug-4417 — each one deterministically mints its own dataset, and the name tells the next reader what it is for.

The discipline that actually makes it work

Having a seeded generator is the easy half. Reproducibility breaks in four specific places, and all four are avoidable.

1. Never read the wall clock

This is the most common leak by a wide margin. A generator that calls Date.now() produces a different value on every run, and no seed will save you. Timestamps, ages derived from birthdates, "created 3 days ago", expiry dates — all of them silently become non-reproducible.

The fix is to treat the current time as an input, not an ambient fact:

// Wrong — the fixture changes every millisecond
const createdAt = new Date(Date.now() - rand() * 86400000);

// Right — `now` is passed in, so the caller controls it
function makeRecord(rand, now) {
  return { createdAt: new Date(now - rand() * 86400000) };
}

Pin now to a fixed instant in the test, and the fixture is stable. This also fixes a second bug you may not have noticed: any test involving a relative date has a latent failure on daylight-saving boundaries, leap days, and midnight rollovers, and pinning the clock removes it.

2. Never draw from an unseeded source

crypto.randomUUID(), crypto.getRandomValues(), Math.random() and process.pid are all outside your seed. A single call to any of them anywhere in the generation path makes the whole fixture irreproducible, and it will not be obvious which record is unstable.

If you need UUIDs in a fixture, mint them from the seeded stream and set the version and variant bits by hand. A UUID is only 122 bits of payload plus 6 fixed bits; nothing about the format requires cryptographic entropy unless you need uniqueness against an adversary.

3. Do not let iteration order vary

Object.keys() has a specified order in modern JavaScript, but Set and Map iteration follows insertion order, and if the insertion order depends on anything non-deterministic you have reintroduced the problem. Filesystem reads via readdir return entries in an order that varies by platform and filesystem. Sort explicitly before iterating anything that will feed the generator.

4. Draw in a fixed order

This is the subtle one. Because each call advances the generator's state, the sequence of draws determines every value that follows.

// Generating fields conditionally shifts everything downstream
const user = {
  name: pickName(rand),
  nickname: wantsNicknames ? pickName(rand) : null, // ← moves the stream
  city: pickCity(rand),                              // ← now different
};

Flip wantsNicknames and every subsequent value in the entire dataset changes. Nothing is wrong, exactly, but a one-line change to an unrelated option produces a thousand-line diff in your snapshots.

The robust pattern is to give each field its own derived stream rather than sharing one:

function streamFor(seed, field, index) {
  return mulberry32(fnv1a(`${seed}:${field}:${index}`));
}

Now city for record 42 depends only on the seed, the field name, and the index. Adding a field, removing one, or generating records out of order changes nothing else. It costs one hash per field and buys you diffs you can actually read.

Commit the fixture or commit the seed?

Once generation is deterministic, you have a genuine choice, and the right answer depends on one question: does the generator's version live in the same repository as the test?

Commit the seed when the generator is yours and versioned alongside the test. The repository stays small, the intent is legible — seed: "orders-with-refunds" says more than 4,000 lines of JSON — and regenerating with a wider dataset is a one-character change.

Commit the generated output when the generator is a third-party dependency, or a hosted tool, or anything whose version you do not pin. Determinism guarantees that the same generator produces the same batch; it guarantees nothing across versions. A library that adds one name to its wordlist changes every draw after that point. If your CI can silently pick up a new minor version, your seed is not a stable reference and the fixture file is.

The failure mode of getting this wrong is memorable: a dependency update lands, four hundred snapshot tests fail simultaneously, and none of them indicate a real regression.

When you must not use any of this

A seeded PRNG is deterministic by construction. Given a few outputs, an attacker can recover the internal state of mulberry32 and predict every subsequent value — and for a 32-bit generator, brute-forcing all four billion seeds takes seconds.

So: never use a seeded generator for passwords that will be used, session identifiers, API keys, CSRF tokens, password-reset links, salts, nonces, or anything shuffled for money. Use crypto.getRandomValues(), crypto.randomUUID(), Python's secrets module, or your platform's CSPRNG.

The distinction is not about quality. mulberry32's output distribution is excellent. It is about the fact that reproducibility and unpredictability are opposite requirements, and a single function cannot serve both. Test fixtures want the first. Secrets want the second. Keep them in separate code paths and the mistake becomes hard to make.

What you get for it

The payoff is a specific and pleasant change in how failures behave. A CI run fails; the log includes seed: bug-4417-attempt-3; you paste that seed locally and get byte-identical data. The bug reproduces on the first try. You fix it and add the seed as a permanent regression case.

Randomness stops being the thing that hides bugs and becomes the thing that finds them.

Tools this guide covers

Full directory →