Deterministic Seeding for Test Data in Vitest

A test that builds its inputs from Math.random(), faker without a seed, or new Date() is a test that fails on a schedule you cannot predict. The fixture that passes a thousand times generates the one edge-case string — an empty name, a leap-day date, a negative balance — that breaks an assertion, and because the input was random, you cannot reproduce it. This guide is for developers and QA engineers using Vitest 1+/2+ who want fixtures that are byte-identical on every run, on every machine, in every CI shard. We cover seeding faker and raw PRNGs, freezing the clock and timers, and structuring fixtures so determinism survives parallel execution. The result is the cheapest and most durable form of flake mitigation: removing nondeterminism at its source so retries and quarantine lanes have far less to do.

Root Cause Analysis

Data-driven flakiness has three classic sources, and each maps to an uncontrolled global. The first is unseeded randomness: faker and Math.random() draw from a generator that is re-seeded from system entropy on each process start, so the same test produces different data every run, and only occasionally hits a value that breaks an assertion. The second is ambient time: new Date(), Date.now(), and timers read the wall clock, so a test that formats “today” or computes an age passes until it runs at a timezone boundary, a month rollover, or a leap day. The third is cross-test state leakage, where one test’s generated data mutates a shared structure that another test reads, making the failure depend on execution order.

All three share a structural cause: the test reaches outside its own scope for inputs it should control. The remedy is to make every nondeterministic source explicit and pinned — seed the PRNG, set the system time, and isolate fixture state per test. The mapping is one-to-one: each uncontrolled global has exactly one pinning primitive that neutralises it, and a test is deterministic only when all three are applied together. Skipping any one leaves a residual entropy source that will surface as an occasional, unreproducible red.

Three data-flake sources mapped to their uncontrolled global and remedy Unseeded randomness, ambient time, and cross-test state leakage each trace to one uncontrolled global and are neutralised by one pinning primitive: a faker seed, a fixed system time, and a pure factory. Flake source Uncontrolled global Pinning remedy Unseeded randomness faker / Math.random() faker.seed(n) Ambient time Date.now(), timers setSystemTime() Cross-test leakage shared fixture object pure factory
Each data-flake source has exactly one uncontrolled global and one pinning remedy.

This is the same determinism discipline that underpins reliable mocking; freezing the clock here uses the time and date control strategies that the broader suite relies on, and stable fixtures complement the request-level determinism of external service simulation. Getting seeding right is the highest-leverage step in a test pyramid strategy, because it removes flakes at the unit tier where they are cheapest to fix.

Reproducible Setup

Install Vitest and faker, then create a global setup that pins every source of nondeterminism.

npm install -D vitest @faker-js/faker
// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    globals: true,
    setupFiles: ['./vitest.setup.ts'],
  },
});
// vitest.setup.ts — one place that makes the whole suite deterministic
import { beforeEach, afterEach, vi } from 'vitest';
import { faker } from '@faker-js/faker';

const FIXED_SEED = 20260621;

beforeEach(() => {
  // Pin synthetic data
  faker.seed(FIXED_SEED);
  // Freeze the clock and timers
  vi.useFakeTimers();
  vi.setSystemTime(new Date('2026-06-21T12:00:00Z'));
});

afterEach(() => {
  vi.useRealTimers();
  vi.restoreAllMocks();
});

Re-seeding in beforeEach rather than once globally guarantees each test starts from the same generator state regardless of how many tests ran before it — the property that makes determinism survive parallelism and reordering.

Implementation

Step 1 — Seed faker per test, not per run. A single top-level faker.seed() only fixes the first test’s data; subsequent tests consume the advancing generator and diverge if execution order changes. Seeding in beforeEach resets the sequence every time.

import { test, expect, beforeEach } from 'vitest';
import { faker } from '@faker-js/faker';

beforeEach(() => faker.seed(20260621));

test('builds a stable user fixture', () => {
  const user = { name: faker.person.fullName(), email: faker.internet.email() };
  // These exact values are reproducible on any machine
  expect(user.name).toBeTypeOf('string');
  expect(user.email).toContain('@');
});

The distinction is easiest to see as a timeline of generator state. When the seed is set once, every test consumes a different slice of one advancing sequence, so reordering the tests reshuffles which value each one gets. When the seed is reset before every test, each test starts from the identical generator state, and execution order stops mattering entirely.

Seeding once versus seeding before each test Seeding once lets each test draw a different value from one advancing generator, so reordering changes results; re-seeding before each test resets the generator so every test draws the same value regardless of order. Seed once at file top — order-dependent seed(n) test 1 draws v0 test 2 draws v1 reorder shifts values Seed in beforeEach — order-independent seed(n) test 1 draws v0 seed(n) test 2 draws v0 reorder-safe Re-seeding before every test makes fixture data invariant to execution order, sharding, and shuffling.
Re-seeding per test resets the generator so results no longer depend on how many tests ran first.

Step 2 — Seed raw PRNGs and inject them, never call the global. Code that needs randomness should accept a generator so tests can pass a seeded one. Replace bare Math.random() with an injectable PRNG.

// src/rng.ts — a small seedable generator (mulberry32)
export function createRng(seed: number): () => number {
  let a = seed >>> 0;
  return () => {
    a |= 0;
    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;
  };
}
import { test, expect } from 'vitest';
import { createRng } from '../src/rng';

test('shuffle is reproducible with a seeded rng', () => {
  const rng = createRng(99);
  const pick = Math.floor(rng() * 10);
  const rng2 = createRng(99);
  expect(Math.floor(rng2() * 10)).toBe(pick);
});

Step 3 — Freeze time and advance it explicitly. With vi.useFakeTimers() and vi.setSystemTime(), Date.now() is constant and timers only move when you tell them to. This makes “today”-relative logic and debounce/throttle behavior fully deterministic.

import { test, expect, vi, beforeEach, afterEach } from 'vitest';

beforeEach(() => {
  vi.useFakeTimers();
  vi.setSystemTime(new Date('2026-06-21T12:00:00Z'));
});
afterEach(() => vi.useRealTimers());

test('computes age against a frozen today', () => {
  const ageDays = Math.floor((Date.now() - Date.parse('2026-06-01')) / 86_400_000);
  expect(ageDays).toBe(20); // stable forever, not "depends when CI runs"
});

test('debounced callback fires after advancing fake time', () => {
  const fn = vi.fn();
  const debounced = () => setTimeout(fn, 300);
  debounced();
  vi.advanceTimersByTime(300);
  expect(fn).toHaveBeenCalledOnce();
});

Step 4 — Build fixtures as pure factories. A fixture factory that takes overrides and returns a fresh object — never a shared mutable singleton — prevents one test from corrupting another’s data and keeps each fixture reproducible in isolation.

// test/factories.ts
import { faker } from '@faker-js/faker';

export function makeOrder(overrides: Partial<Order> = {}): Order {
  faker.seed(20260621); // local reset keeps the factory self-contained
  return {
    id: faker.string.uuid(),
    total: 42_00,
    createdAt: new Date('2026-06-21T12:00:00Z'),
    ...overrides,
  };
}

A pure factory has a second, quieter benefit: it makes the intent of each test legible. When every test constructs its subject through makeOrder({ total: 0 }), the override is a precise statement of the one property under test, and everything else is a known, seeded default. Compare that to a test that mutates a shared fixture three lines up — the reader cannot tell which fields matter and which are incidental. Determinism and readability turn out to be the same discipline viewed from two angles: a fixture you can reproduce byte-for-byte is also a fixture whose relevant inputs are explicit. Keep factories free of any I/O — no database reads, no fetch, no filesystem — so constructing test data can never itself become a source of nondeterminism.

Step 5 — Verify isolation survives parallel and shuffled runs. Determinism that only holds in file order is an illusion. Confirm fixtures are identical whether a file runs alone or inside the full parallel suite, and shuffle the order to flush out hidden coupling. This same isolation discipline is what keeps the quarantine workflow in quarantining flaky tests in CI honest.

Verification

Prove reproducibility directly rather than trusting it. The strongest check generates a value, re-seeds, generates it again, and asserts equality within a single test — if that fails, your seeding is not taking effect.

import { test, expect } from 'vitest';
import { faker } from '@faker-js/faker';

test('seeded faker is byte-identical', () => {
  faker.seed(7);
  const first = faker.person.fullName();
  faker.seed(7);
  const second = faker.person.fullName();
  expect(first).toBe(second);
});

Then confirm cross-run stability at the suite level by running the file in isolation and again inside the full suite with shuffling enabled:

npx vitest run test/factories.test.ts                       # isolated
npx vitest run --sequence.shuffle                            # full suite, randomized order

Identical fixture output across both invocations is the proof that no state is leaking and no ambient global is bleeding in. If the shuffled run diverges, you have order-dependent coupling that seeding alone will not fix — track it down with the isolation checks above before declaring the test deterministic.

Verifying fixture determinism across run modes A seeded pure factory feeds both an isolated run and a parallel shuffled run; when both produce the same hash, the fixture is byte-identical and no state is leaking. Fixture factory seeded + pure Isolated run hash = X Parallel + shuffled hash = X Byte-identical no state leak run alone run under load
Determinism holds only when the isolated and shuffled-parallel runs produce the identical fixture hash.

Troubleshooting

Symptom: faker produces different data despite a top-level seed. Diagnosis: the seed was set once at module load, so each test consumes a different slice of the advancing generator. Fix: move faker.seed() into a beforeEach (or into the fixture factory) so the sequence resets before every test rather than once per file.

Symptom: date assertions pass locally but fail in CI. Diagnosis: the test reads the real clock and CI runs in a different timezone or at a different moment. Fix: call vi.useFakeTimers() and vi.setSystemTime() with an explicit UTC instant in setup, and assert against that fixed time. For timezone-specific formatting, also pin process.env.TZ in your runner config.

Symptom: fake timers break code that awaits real promises. Diagnosis: vi.useFakeTimers() by default also fakes queueMicrotask and process.nextTick in some configs, stalling awaited async work. Fix: pass vi.useFakeTimers({ toFake: ['setTimeout', 'setInterval', 'Date'] }) to fake only the timers you need, or use await vi.runAllTimersAsync() to flush pending timers and microtasks together.

FAQ

Why seed in beforeEach instead of once at the top of the file?

A single top-level seed only resets the generator before the first test; every later test continues consuming the same advancing sequence, so its data depends on how many tests ran first. The moment execution order changes — a new test is added, or the suite shuffles or shards — those values shift and assertions break. Re-seeding in beforeEach makes each test start from an identical generator state, which is what makes determinism robust to reordering.

Does deterministic seeding work the same way in Jest?

The concepts are identical and faker’s seed() call is the same; only the timer API differs. Jest uses jest.useFakeTimers() and jest.setSystemTime() where Vitest uses the vi equivalents, and the setup-file wiring is analogous. Because the examples here use Vitest as the primary runner, port the timer calls to the jest namespace and the rest carries over unchanged.

How do I make randomized application code testable without rewriting everything?

Inject the random source instead of calling the global directly: have functions accept a generator argument that defaults to Math.random, then pass a seeded PRNG in tests. This is a small, incremental change per function and keeps production behavior identical while making the test path deterministic. The seedable createRng helper shown above is enough to replace most uses of Math.random().

Is seeding a replacement for retries and quarantine?

Seeding is the preferred first line of defense because it eliminates a whole category of flakes at the source, which means fewer tests ever need retries or a quarantine lane. It does not replace them entirely, since timing races in real browsers and genuine environmental noise live outside your data layer. Think of seeding as shrinking the problem so that the retry budget and quarantine workflow handle a much smaller, harder residue.

Should I use one fixed seed for the whole suite or a different seed per test?

A single shared constant is the right default because it makes failures trivially reproducible: when a test breaks, anyone can re-run it with the same seed and see the same data. Varying the seed per test — or worse, per run — reintroduces exactly the unpredictability you are trying to remove, and turns a failing assertion into a hunt for which inputs triggered it. The exception is deliberate fuzzing, where you want to sweep a range of inputs; even then, log the seed on failure so any interesting case can be pinned and promoted into a fixed regression test. For ordinary fixtures, one constant seed applied in beforeEach gives you both reproducibility and coverage of a representative, stable data shape.

Does freezing the clock break libraries that schedule their own timers?

It can, if those libraries rely on real setTimeout or setInterval under fake timers — animation loops, polling clients, and some data-fetching libraries are common offenders. The fix is to fake only the timers your code under test actually reads by passing an explicit toFake list, or to advance the fake clock deliberately with vi.advanceTimersByTimeAsync() so scheduled callbacks and the microtasks they queue both flush. When a third-party library genuinely needs the real clock, scope the fake timers to the narrow block that needs them rather than installing them globally in setup.