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. 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('@');
});

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,
  };
}

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.

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.