Seeding Faker for Reproducible Test Data
A test fails on CI, passes locally, and the diff shows a customer named Marguerite where you expected Kwame. The fixtures are random, so the failure is unreproducible and the investigation stops. This page makes generated data identical on every machine and every run, which is the precondition for every other kind of debugging.
Context: one generator, one sequence
Faker exposes a single shared pseudo-random sequence. faker.seed(1234) sets where that sequence starts; every subsequent call advances it. This has a consequence that surprises people: the values you get depend on how many draws happened before yours.
Seeding once at module load therefore does not guarantee anything. If two fixture modules both draw from the generator and their import order changes — because a bundler reordered them, or a test file was renamed, or a spec was added — the second module’s values shift wholesale even though its own code did not change.
The fix is to think of the seed as belonging to a generation unit rather than to the process. Every unit seeds immediately before it draws, so its values depend only on its own seed.
Solution
1. Seed at the start of each generation unit
// src/fixtures/seeds.ts
/** One place to look up what a fixture set was generated with. */
export const SEEDS = {
users: 20260731_001,
orders: 20260731_002,
invoices: 20260731_003,
} as const;
// src/fixtures/orders.ts
import { faker } from '@faker-js/faker';
import { SEEDS } from './seeds';
export interface Order { id: string; customerId: string; totalMinor: number; placedAt: string; }
export function buildOrders(count = 50): Order[] {
// Seed HERE, not at module load — the values then depend only on this seed.
faker.seed(SEEDS.orders);
return Array.from({ length: count }, (_, i) => ({
id: `ord_${String(i + 1).padStart(4, '0')}`,
customerId: `cus_${faker.string.alphanumeric({ length: 8, casing: 'lower' })}`,
totalMinor: faker.number.int({ min: 500, max: 99_000 }),
placedAt: faker.date
.between({ from: '2026-01-01T00:00:00Z', to: '2026-07-31T00:00:00Z' })
.toISOString(),
}));
}
Naming the seeds in one module has a second benefit beyond tidiness: when a fixture set changes unexpectedly, the first question is “did the seed change?”, and there is exactly one file to check.
2. Derive per-record seeds for stability under change
Seeding per unit fixes cross-module drift but not within-unit drift. Insert a record at position three and every record after it shifts, producing a diff across the whole file for a one-record change. Deriving each record’s seed from its own identifier removes that:
// src/fixtures/derive.ts
/** Small, fast, deterministic string hash — FNV-1a. */
export function hashSeed(input: string): number {
let h = 0x811c9dc5;
for (let i = 0; i < input.length; i += 1) {
h ^= input.charCodeAt(i);
h = Math.imul(h, 0x01000193) >>> 0;
}
return h;
}
import { faker } from '@faker-js/faker';
import { hashSeed } from './derive';
export function buildOrder(id: string): Order {
// This record's values depend on its id alone — not on what was built before it.
faker.seed(hashSeed(id));
return {
id,
customerId: `cus_${faker.string.alphanumeric({ length: 8, casing: 'lower' })}`,
totalMinor: faker.number.int({ min: 500, max: 99_000 }),
placedAt: faker.date
.between({ from: '2026-01-01T00:00:00Z', to: '2026-07-31T00:00:00Z' })
.toISOString(),
};
}
export const buildOrders = (ids: string[]): Order[] => ids.map(buildOrder);
Now inserting ord_0003 changes exactly one line of the generated file. That is the difference between a reviewable diff and a rubber-stamped one, and it is the same property the keyed pseudonyms in mock data privacy and anonymisation rely on.
3. Seed the clock too
Dates are the other source of non-reproducibility, and faker.seed does not touch them. Anything computed from new Date() or Date.now() varies by run:
// vitest.setup.ts
import { afterEach, beforeEach, vi } from 'vitest';
const FROZEN = new Date('2026-07-31T09:00:00.000Z');
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(FROZEN);
});
afterEach(() => vi.useRealTimers());
A fixture with placedAt: faker.date.recent() is reproducible only if “now” is fixed. Freezing the clock is half the seeding work and is routinely forgotten, which is why “reproducible” fixtures still drift across a midnight boundary or a month end.
4. Pin the locale
// src/fixtures/faker.ts — import this everywhere instead of the bare package
import { Faker, en_GB, en } from '@faker-js/faker';
// Explicit locale chain: the default can differ between environments and versions.
export const faker = new Faker({ locale: [en_GB, en] });
Importing a configured instance rather than the package default also gives you one place to add future global configuration, and makes an accidental bare import easy to catch with a lint rule.
Verification
# Generate twice in the same job; any difference is an unseeded call
npx tsx scripts/build-fixtures.ts && cp -r src/fixtures/generated /tmp/gen-a
npx tsx scripts/build-fixtures.ts && diff -r /tmp/gen-a src/fixtures/generated && echo 'reproducible'
# The committed fixtures match what the current code generates
git diff --exit-code src/fixtures/generated || {
echo 'fixtures are stale — regenerate and commit'; exit 1;
}
Both belong in CI. The first catches a newly added unseeded call the day it lands; the second catches a fixture change that was made by hand and will be silently overwritten on the next build.
Gotchas and edge cases
-
faker.helpers.shuffleandarrayElementconsume draws too. Any Faker call advances the sequence, including ones that look like utilities rather than generators. Adding a singlearrayElementat the top of a factory shifts every value below it — which reads as an unrelated regression in a diff. -
A parallel test runner does not share the sequence, but it does share the module. Vitest workers each get their own module instance, so per-worker seeding is independent, which is fine. What breaks is a fixture file written by two workers at once. Generate fixtures in a single pre-test step, not inside the specs.
-
Faker’s data changes between versions. A minor upgrade can add names to a corpus, which changes what a given seed produces. Pin the version exactly in
package.jsonand treat a Faker upgrade as a deliberate fixture regeneration, not an incidental dependency bump.
Recording the seed where a failure can quote it
Determinism only pays if the seed is recoverable from a failure report. A pipeline that seeds correctly and never prints the seed has bought reproducibility and thrown away access to it.
Three places are worth writing it.
The test reporter output. A single line at the start of a run — the seed, the frozen clock, the locale, the generator version — costs nothing and is captured in every CI log. When someone pastes a failure into a chat, the line comes with it.
The generated fixtures themselves. A small metadata file beside the output, recording the same values, means anyone looking at a fixture set can tell what produced it without finding the run that did.
The failure message, for randomised runs. A job deliberately using a rotating seed must print it on failure, or the failure is unactionable. This is the difference between a randomised job that finds real bugs and one that generates noise people learn to ignore.
Alongside recording it, make it overridable. An environment variable that pins the seed lets a reproduction run recreate the exact world of a failure, which is the whole point of having recorded it. Without the override, the recorded seed is documentation rather than a tool.
One caution: the seed alone is not the world. A reproduction that pins the seed but runs with a different clock, locale or library version will produce different data and a different result, which sends the investigation somewhere misleading. Record all four together and treat them as a single unit — the run’s provenance — rather than as four independent settings.
Related
- Keeping Snapshot Tests Stable with Fixed Seeds — applying these seeds where the diff is the assertion
- Building a Reusable Fixture Factory — the factory shape these seeds slot into
- Generating Realistic Relational Mock Data — keeping derived identifiers consistent across entities
← Back to Deterministic Seed Management