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.

Why one seed at module load is not enough Two timelines over one shared generator sequence. In the first, a single seed at module load is followed by three consumers drawing in sequence; when the middle consumer is removed the third consumer's values shift. In the second, each consumer re-seeds immediately before drawing, so removing one leaves the others untouched. Seeded once at module load — order-dependent seed users draw 1–40 orders draw 41–90 invoices draw 91–120 remove "orders" and "invoices" changes entirely Seeded per generation unit — order-independent seed(1) then users seed(2) then orders seed(3) then invoices removing one changes nothing about the others The generator is shared state; the seed is only meaningful relative to the draws that follow it.

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.

Four sources of drift, four controls Four rows pairing a source of non-determinism with its control and the symptom it produces when uncontrolled. Random draws are controlled by seeding per unit. Generation order is controlled by deriving a seed per record. The system clock is controlled by freezing time. Locale defaults are controlled by pinning the locale explicitly. Source of drift Control Symptom when uncontrolled random draws faker.seed per unit different names every run generation order seed derived from the id whole-file diff for one insert the system clock vi.setSystemTime passes today, fails after midnight locale defaults explicit faker locale differs between laptop and CI image

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.shuffle and arrayElement consume draws too. Any Faker call advances the sequence, including ones that look like utilities rather than generators. Adding a single arrayElement at 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.json and treat a Faker upgrade as a deliberate fixture regeneration, not an incidental dependency bump.


Where seeding breaks in practice Three real-world breakages: a helper call added at the top of a factory shifting every value below it, a test importing a factory that seeded at module load, and a dependency upgrade changing the generator's corpus. Each is paired with the detection that catches it. A helper call added at the top every value below it shifts the double-generation diff catches it Seeding at module load import order decides the values seed inside the generation function A generator upgrade the same seed yields new values pin the version and treat upgrades as regenerations None of the three are visible in a code review; all three are visible in a generate-twice diff.

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.

← Back to Deterministic Seed Management