Root cause #
Fixture generators feel deterministic but rarely are. faker without a seed pulls from a process-global PRNG whose starting state varies; Date.now(), new Date(), and crypto.randomUUID() all return run-specific values. A factory that builds a user with createdAt: new Date() and id: randomUUID() produces a different object every call. Locally that is harmless because you only see one run. Across environments it is corrosive: a snapshot recorded on macOS in your timezone fails when CI runs in UTC, and a test asserting on a generated id passes only on the machine that generated it.
The async dimension makes timing especially fragile. A factory that stamps expiresAt: Date.now() + 3600_000 encodes wall-clock time into the fixture, so a test comparing it to the app’s own clock drifts by the few milliseconds between the two reads — a tiny gap that becomes a real failure under CI load. Determinism means removing every source of ambient input: seed the randomness, freeze the clock, and stub id generation.
Step-by-step fix #
1. Fix the faker seed before generating anything #
Seed once per test (or per worker) so the sequence is reproducible.
const { faker } = require('@faker-js/faker');
beforeEach(() => {
// Same seed -> same sequence of names/emails on every machine and CI runner.
faker.seed(42);
});
2. Write pure factory functions #
A factory should take overrides and return a plain object with no hidden time or randomness.
// factories/user.js
const { faker } = require('@faker-js/faker');
function makeUser(overrides = {}) {
return {
id: faker.string.uuid(), // deterministic once faker is seeded
name: faker.person.fullName(),
createdAt: '2026-01-01T00:00:00.000Z', // literal, not new Date() — no clock drift
...overrides
};
}
// Trade-off: literal timestamps need updating if a test asserts on "recency", but they never flake.
module.exports = { makeUser };
3. Freeze the clock and UUID source #
Use fake timers and a stubbed id generator so any code path that still reaches for the real ones is neutralized.
beforeEach(() => {
// Pin the clock so Date.now()/new Date() are constant across environments.
jest.useFakeTimers().setSystemTime(new Date('2026-01-01T00:00:00Z'));
});
afterEach(() => jest.useRealTimers());
// If the app uses crypto.randomUUID(), stub it to a fixed value in setup.
jest.spyOn(crypto, 'randomUUID').mockReturnValue('00000000-0000-4000-8000-000000000000');
4. Verify reproducibility in CI #
Generate fixtures twice and assert they are identical, so nondeterminism fails fast.
test('factory output is stable', () => {
faker.seed(42);
const a = makeUser();
faker.seed(42);
const b = makeUser();
// If this ever differs, an unseeded source of randomness has crept in.
expect(a).toEqual(b);
});
Once your data is deterministic, keep the schema it conforms to honest with Diffing OpenAPI Fixtures Against Live Schemas.
Pitfalls #
- Seeding faker once globally but mutating it per test. Mitigation: re-seed in
beforeEachso each test starts from the same state. - Literal timestamps that a “last updated” assertion expects to be recent. Mitigation: freeze the clock and compute relative values from the frozen now.
- Stubbing
crypto.randomUUIDbut missing a library that imports its own. Mitigation: inject id generation through a factory you control. - Sharing one mutable fixture object across tests. Mitigation: factories return fresh objects every call.
- Locale-dependent faker output differing by environment. Mitigation: set an explicit locale alongside the seed.
Reliability targets #
| Target | Goal |
|---|---|
| Fixture reproducibility | 100% identical across environments |
| Clock-drift failures | 0 per 1k runs |
| Snapshot churn from random data | 0 unexpected diffs |
| Environment-specific flakiness | < 0.5% of runs |
Frequently Asked Questions #
Q: Is faker.seed() enough on its own?
A: No. It makes faker deterministic, but Date.now(), new Date(), and crypto.randomUUID() are separate ambient sources. Freeze the clock and stub id generation too.
Q: Where should I seed — globally or per test?
A: Per test, in beforeEach. A single global seed drifts as soon as one test consumes a different number of random values than another.
Q: How do I handle data that genuinely needs to look “recent”? A: Freeze the system time and derive relative values from that fixed now, so “recent” is computed against a constant rather than a live clock.
Three Things That Must Not Be Left to the System #
Seeded data is only deterministic if the values the tests depend on are supplied rather than allocated, and three categories are routinely left to whatever the database chooses.
Identifiers. A test asserting on a generated id is coupled to insertion order or to a sequence whose starting point differs between environments. Supply a natural key — a reference, a slug, an email — and assert on that; let the system allocate its internal id, which no test should ever mention.
Ordering. A query without an explicit sort returns rows in whatever order the storage engine finds convenient, and that order can legitimately differ between a freshly seeded local database and a staging one with different statistics. A test asserting on “the first row” is then correct in one environment and wrong in another. Sort explicitly, or assert on set membership where position is not the behaviour under test.
Time. Records seeded “three days ago” relative to the seeding moment behave differently from records with fixed instants, and anything that renders relative time, groups by day or applies an expiry window is affected. Fixing the instants and freezing the clock in the tests that depend on them removes a whole family of failures that cluster near midnight and at month ends.
// Supply the values assertions depend on; let the system own the rest.
// Trade-off: explicit values are more verbose than letting the database decide,
// and they are what makes an assertion mean the same thing in every environment.
await seedInvoice({
reference: `INV-${workerIndex}-0001`, // supplied, assertable
issuedAt: '2026-08-02T12:00:00.000Z', // absolute, not relative
status: 'open',
});
Seeding Per Worker Without Serialising the Suite #
Determinism and parallelism pull in opposite directions unless the seed is scoped correctly. A single shared dataset makes tests fast to write and impossible to run concurrently, because two workers mutating the same records interfere in ways no browser-side isolation can prevent.
The workable arrangement separates two kinds of data. Reference data — currencies, plan types, permission sets, anything read but never written — is seeded once per environment and shared safely, because nothing mutates it. Working data is created per test, with keys derived from the worker index so two copies can never collide, and either torn down or simply abandoned when the environment is discarded.
That split usually removes teardown from the critical path entirely, which matters more than it sounds: truncation between tests costs tens to hundreds of milliseconds each, and at a few thousand tests that becomes minutes of every run spent deleting rows nobody will read again.
The discipline it requires is a single rule: no test may assert on global state. “There are three invoices” becomes “my invoice appears in the list”, and with that one change the suite can run at any worker count in any environment without the seed strategy needing to change again.
Verifying the Seed Before Trusting the Test #
A seeded fixture that silently failed to apply produces a test failure that looks like an application defect, and the misdirection can cost an afternoon. A short verification step converts that into an immediate, self-describing error.
The check is to read back what was seeded and assert it exists before the test proceeds — one request, and it distinguishes “the application is broken” from “the fixture never landed”. Where the seed is created through an API, the response already contains that confirmation and only needs asserting.
The second verification worth having is environment identity: assert at suite start that the base URL points where the test intends. A misconfigured environment variable sending a write-heavy suite at a shared system is a rare failure with an outsized cost, and a single request to a health or version endpoint rules it out.
Where seeding is expensive, splitting it into an immutable reference layer created once and a per-test layer created cheaply keeps both determinism and speed — and it makes clear, in the code, which data any given test is allowed to change.
Naming seeded records after the test that created them makes an orphaned row in a shared environment traceable rather than mysterious.