Deterministic Seed Management
This page covers exactly how to anchor a pseudo-random number generator (PRNG) to a fixed seed so that mock API payloads are byte-for-byte identical on every run — it does not cover broader randomisation strategies or production data masking.
Prerequisites
- Node.js 18 or later installed locally and in CI
-
@faker-js/fakerv8.4 or later (npm install --save-dev @faker-js/faker) - MSW v2 configured — see MSW handler registration for browser and Node setup
-
MOCK_SEEDenvironment variable slot available in your.env.testfile and CI pipeline - Familiarity with schema-driven data generation patterns, since seeds operate on top of schema-defined field constraints
- Jest 29 or Vitest 1.x for snapshot testing; either works with the patterns below
Phase 1 — Core Setup: Seeded Faker Factory
The entry point is a single createFaker() factory that reads MOCK_SEED at module load time and returns a frozen Faker instance. Every fixture factory in the project imports from this one file. That single import chain guarantees no handler accidentally calls the global faker directly and breaks the deterministic guarantee.
// src/mocks/seeded-faker.ts
import { Faker, en } from '@faker-js/faker';
/**
* Returns a Faker instance seeded from MOCK_SEED (integer).
* Centralise all mock data through this function — never import
* the global faker directly in handler or factory files.
*/
export function createFaker(): Faker {
const raw = process.env.MOCK_SEED ?? '42';
const seed = parseInt(raw, 10);
if (Number.isNaN(seed)) {
throw new Error(
`MOCK_SEED must be an integer, got: "${raw}". ` +
`Pass a number, not a git SHA or hex string.`
);
}
const faker = new Faker({ locale: [en] });
faker.seed(seed);
return faker;
}
// Export a module-level singleton — one seed, one sequence.
export const faker = createFaker();
Why reject non-integer seeds? Faker’s
seed()callsMath.imulunder the hood. PassingNaNsilently seeds with0, making every “seeded” instance behave identically — not deterministic, just wrong.
Now build a fixture factory that uses the seeded instance:
// src/mocks/factories/user-factory.ts
import { faker } from '../seeded-faker';
export interface MockUser {
id: string;
name: string;
email: string;
role: 'admin' | 'editor' | 'viewer';
createdAt: string;
}
const ROLES: MockUser['role'][] = ['admin', 'editor', 'viewer'];
export function makeUser(overrides: Partial<MockUser> = {}): MockUser {
return {
id: faker.string.uuid(),
name: faker.person.fullName(),
email: faker.internet.email(),
role: faker.helpers.arrayElement(ROLES),
createdAt: faker.date.past({ years: 2 }).toISOString(),
...overrides,
};
}
export function makeUserList(count: number): MockUser[] {
return Array.from({ length: count }, () => makeUser());
}
With MOCK_SEED=42, makeUser() always returns the same UUID, name, email, and role in the same order. Change the seed and the entire sequence shifts consistently.
Phase 2 — Configuration and Wiring
Environment variable injection
Add MOCK_SEED to every environment that runs the mock layer:
# .env.test (committed — this is a dev constant, not a secret)
MOCK_SEED=42
NODE_ENV=test
For projects using dotenv:
// jest.config.ts
import type { Config } from 'jest';
const config: Config = {
testEnvironment: 'node',
setupFiles: ['dotenv/config'], // loads .env.test before suite init
globals: {
'ts-jest': { tsconfig: 'tsconfig.test.json' },
},
};
export default config;
For Vitest:
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
env: {
MOCK_SEED: '42',
},
},
});
Wiring into MSW handlers
Import from seeded-faker (not the global faker package) in every MSW handler:
// src/mocks/handlers/users.ts
import { http, HttpResponse } from 'msw';
import { makeUser, makeUserList } from '../factories/user-factory';
export const userHandlers = [
http.get('/api/v1/users', () => {
return HttpResponse.json({
users: makeUserList(10),
total: 10,
});
}),
http.get('/api/v1/users/:id', ({ params }) => {
const user = makeUser({ id: params.id as string });
return HttpResponse.json(user);
}),
http.post('/api/v1/users', async ({ request }) => {
const body = await request.json() as Partial<{ name: string; email: string }>;
const user = makeUser({
name: body?.name ?? faker.person.fullName(),
email: body?.email ?? faker.internet.email(),
});
return HttpResponse.json(user, { status: 201 });
}),
];
Per-suite seed reset
When multiple test files share a process (the default in Jest’s --runInBand mode or Vitest’s singleThread), PRNG state from one file bleeds into the next. Reset explicitly in beforeEach:
// src/mocks/test-utils.ts
import { faker } from './seeded-faker';
export function resetFakerSeed(seed = Number(process.env.MOCK_SEED ?? 42)): void {
faker.seed(seed);
}
// src/__tests__/users.test.ts
import { resetFakerSeed } from '../mocks/test-utils';
import { makeUser } from '../mocks/factories/user-factory';
beforeEach(() => {
resetFakerSeed();
});
test('makeUser returns a stable shape', () => {
const user = makeUser();
expect(user.id).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
);
expect(user.email).toContain('@');
});
Phase 3 — CI Pipeline Integration
GitHub Actions
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
env:
# Integer seed — stable for regression; unique per run for PR diff coverage.
# Use 42 for main; run_number for PRs so flakiness reporters see variety.
MOCK_SEED: ${{ github.ref == 'refs/heads/main' && '42' || github.run_number }}
NODE_ENV: test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm test
- name: Upload seed on failure
if: failure()
run: echo "FAILED WITH MOCK_SEED=$MOCK_SEED" >> "$GITHUB_STEP_SUMMARY"
Recording the seed in the failure summary lets you reproduce the exact run locally:
MOCK_SEED=1312 npm test
Parallel worker isolation
Jest distributes test files across workers. Each worker shares the same module cache but not the same PRNG sequence if you offset the seed by JEST_WORKER_ID:
// src/mocks/seeded-faker.ts (updated factory)
export function createFaker(): Faker {
const base = parseInt(process.env.MOCK_SEED ?? '42', 10);
const workerId = parseInt(process.env.JEST_WORKER_ID ?? '0', 10);
if (Number.isNaN(base)) {
throw new Error(`MOCK_SEED must be an integer, got: "${process.env.MOCK_SEED}"`);
}
const faker = new Faker({ locale: [en] });
faker.seed(base + workerId);
return faker;
}
This keeps runs deterministic per worker while preventing two workers from producing the same user UUID and causing assertion collisions in shared database fixtures.
Docker Compose mock sidecar
If your stack runs WireMock or a mock server in Docker, pre-generate fixtures before the container starts:
# docker-compose.test.yml
services:
fixture-generator:
image: node:20-alpine
working_dir: /app
volumes:
- .:/app
environment:
MOCK_SEED: "42"
command: ["node", "scripts/generate-fixtures.mjs"]
wiremock:
image: wiremock/wiremock:3.5.4
depends_on:
fixture-generator:
condition: service_completed_successfully
volumes:
- ./mocks/wiremock/__files:/home/wiremock/__files
- ./mocks/wiremock/mappings:/home/wiremock/mappings
ports:
- "8080:8080"
// scripts/generate-fixtures.mjs
import { writeFileSync, mkdirSync } from 'node:fs';
import { createFaker } from '../src/mocks/seeded-faker.js';
import { makeUserList } from '../src/mocks/factories/user-factory.js';
const faker = createFaker();
mkdirSync('./mocks/wiremock/__files', { recursive: true });
writeFileSync(
'./mocks/wiremock/__files/users.json',
JSON.stringify({ users: makeUserList(20), total: 20 }, null, 2)
);
console.log('Fixtures generated with MOCK_SEED=' + process.env.MOCK_SEED);
This approach separates mock lifecycle management concerns: the fixture generator runs once per seed, and WireMock serves static files with zero PRNG involvement.
Verification Steps
Run these commands to confirm the implementation is working correctly before merging:
-
MOCK_SEED=42 npm test -- --ci— all tests pass on first run -
MOCK_SEED=42 npm test -- --ci(again, same terminal) — output is byte-identical; Jest reports no snapshot diffs -
MOCK_SEED=99 npm test -- --ci— tests still pass but snapshot values differ, confirming the seed actually controls output -
MOCK_SEED=abc npm test— should throwMOCK_SEED must be an integerrather than silently seeding withNaN -
JEST_WORKER_ID=2 MOCK_SEED=42 node -e "const {faker}=require('./src/mocks/seeded-faker'); console.log(faker.string.uuid())"— UUID differs from worker 1 output, confirming offset isolation
Expected snapshot for MOCK_SEED=42 with Faker v8.4 and en locale:
User.id: "f47ac10b-58cc-4372-a567-0e02b2c3d479" ← stable across runs
User.name: "Shanel Mueller"
User.email: "[email protected]"
If your versions differ, run once with --updateSnapshot to establish the baseline, then commit the snapshot file.
Troubleshooting
TypeError: faker.seed is not a function
Cause: You imported from faker (the old faker package, v5 or below) instead of @faker-js/faker.
Fix:
npm uninstall faker
npm install --save-dev @faker-js/faker
Update all import paths from import { faker } from 'faker' to import { faker } from '@faker-js/faker'. The seed() method exists only on the class instance, not the legacy CommonJS export.
Snapshot diffs appear on CI but not locally
Cause: Node.js minor-version differences between local (e.g. 20.10) and CI (e.g. 20.0) can change V8’s internal float representation, which propagates to faker.number.float() outputs.
Fix: Pin the exact Node version in .nvmrc and mirror it in the CI node-version field:
echo "20.14.0" > .nvmrc
# .github/workflows/ci.yml
- uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
Two tests produce the same UUID
Cause: Both tests call makeUser() without a beforeEach reset, so the PRNG sequence carries over from the previous test’s consumption. If the prior test consumed exactly the right number of values, two factories land on the same point in the sequence.
Fix: Add resetFakerSeed() in beforeEach (see Phase 2 above). Never rely on implicit PRNG state ordering across test boundaries.
MOCK_SEED is ignored in Docker builds
Cause: ARG and ENV in Dockerfile are build-time only; runtime env vars from docker run -e arrive later and do not override a hardcoded ENV MOCK_SEED=42 in the image.
Fix: Remove ENV MOCK_SEED from your Dockerfile and supply the value exclusively at runtime via docker-compose.yml or docker run -e MOCK_SEED=42. Use process.env.MOCK_SEED ?? '42' (the ?? fallback) so absence of the env var uses the default without throwing.
Faker locale import causes Cannot find module in Jest ESM mode
Cause: @faker-js/faker v8 ships only ESM for individual locale imports (@faker-js/faker/locale/en). Jest’s default CommonJS transform doesn’t handle them.
Fix: Add the package to transformIgnorePatterns exclusions:
// jest.config.json
{
"transformIgnorePatterns": [
"node_modules/(?!(@faker-js/faker)/)"
]
}
Or switch to Vitest, which handles ESM natively.
When to Advance
This setup is complete when:
- Every test file imports
fakerfrom../mocks/seeded-faker(or equivalent) — no direct@faker-js/fakerimports in handler or factory files -
MOCK_SEEDis explicitly set in.env.test, your CI pipeline YAML, and your Docker Compose test service - Snapshot tests exist for at least one fixture factory and are committed to the repository
- Running the test suite twice in succession with the same seed produces zero diffs
- CI failure output logs the seed value used, enabling exact local replay
Once these signals are green, you can confidently extend the fixture layer with schema-driven data generation patterns — using the seed as the stable anchor while the schema defines the valid value space.
FAQ
What seed value should I use in CI?
Use 42 (or any fixed integer) for regression suites on your main branch — these must never drift. For pull request builds, use github.run_number instead of github.sha: run_number is an integer and increments predictably; sha is a hex string that parseInt converts to NaN, which silently seeds with 0 and produces non-deterministic sequences.
Does a fixed seed limit edge-case coverage?
Yes — a single seed exercises one deterministic path through the PRNG. Complement fixed-seed regression runs with a nightly workflow that passes a random seed (MOCK_SEED=$RANDOM) and uploads it as an artefact. When that nightly build fails, you can replay it locally with the exact seed from the artefact.
How do I isolate seeds across parallel Jest workers?
Derive each worker’s seed from the base value plus JEST_WORKER_ID: const seed = base + parseInt(process.env.JEST_WORKER_ID ?? '0', 10). This keeps each worker’s sequence deterministic and distinct. See the parallel worker isolation section in Phase 3 above for the full factory code.
Can I use deterministic seeds with WireMock?
WireMock has no built-in PRNG seed. Pre-generate fixture JSON files with your seeded Node script and mount them into WireMock’s __files directory before the container starts. The Docker Compose example in Phase 3 shows a fixture-generator service that runs the generation step and signals completion before WireMock boots.
Related
- Schema-Driven Data Generation — define valid value spaces that your seeded factories populate
- Advanced MSW Handler Patterns — compose seeded factories into stateful request handlers
- Mock Lifecycle Management — coordinate seed resets alongside server startup and teardown
- Running WireMock in Docker Compose — integrate pre-generated fixtures with a containerised mock server
← Back to Data Generation & Realism Strategies