Pinning time, IDs, and randomness
Make Vitest unit tests deterministic by routing the clock, ID generation, and randomness through seams a test can freeze.
Every test you’ve written so far has had a quiet property: the same input gives the same answer, today, tomorrow, and on a CI runner in another timezone. That comes from purity. Break it and the test still passes most of the time, which is worse than a clean failure, because nobody trusts a test that fails for reasons they can’t explain.
A /lib function breaks purity in three ways: it reads the clock, generates an ID, or rolls a random number. Each is an input the function pulls from thin air instead of receiving as an argument, and an input you don’t control is one that can fail your test on a different day or a different run.
We’ll learn one move on the clock, where it’s most common and has a wrinkle that trips up everyone, then reuse it for IDs and randomness. Those frozen createdAt and id defaults from the factory lesson were pointing at exactly this machinery.
When a test depends on the clock
Section titled “When a test depends on the clock”A draft invoice is due 30 days after it’s created, so computeDueAt reads “now” and adds 30 days. The test checks the result by computing “30 days from now” a second time in the test body. (Temporal.Instant adds in fixed time units, so 30 days reads as 24 * 30 hours.)
import { Temporal } from 'temporal-polyfill';
const computeDueAt = (): Temporal.Instant => Temporal.Now.instant().add({ hours: 24 * 30 });
it('falls due 30 days out', () => { const expected = Temporal.Now.instant().add({ hours: 24 * 30 }); expect(computeDueAt().epochMilliseconds).toBe(expected.epochMilliseconds);});Green today, because both sides read the same clock in the same millisecond. The test recomputes the expected value exactly the way the unit does, so they agree right now.
import { Temporal } from 'temporal-polyfill';
const computeDueAt = (): Temporal.Instant => Temporal.Now.instant().add({ hours: 24 * 30 });
it('falls due 30 days out', () => { const expected = Temporal.Now.instant().add({ hours: 24 * 30 }); expect(computeDueAt().epochMilliseconds).toBe(expected.epochMilliseconds);});The unit’s input is the wall clock, and you can’t assert against a moving target. The two clock reads happen microseconds apart and can straddle a millisecond boundary, so the two epochMilliseconds differ and toBe fails. A literal expected instant doesn’t save you: it’s wrong the moment the calendar passes it. The bug isn’t in computeDueAt; it’s in letting the clock be an input.
A test like this fails at 14:32 and passes at 14:33 with no code changed in between. The team calls that flaky , but the word misleads: it implies bad luck you wait out or paper over with a retry. The cause here is structural. The test reads the wall clock , which keeps moving, so time is an uncontrolled input. That failure has a name and a repair, which makes it broken, not flaky, and broken is the better diagnosis.
The repair, in one sentence: route “now” through a single point the test can freeze, so the unit reads a clock the test controls. The rest of the clock sections build exactly that. The same uncontrolled input hides behind crypto.randomUUID() and Math.random(), and takes the same cure, which is why the last two sections will feel familiar.
The clock module: one seam for “now”
Section titled “The clock module: one seam for “now””The fix is a seam : one place in front of every clock read where production wires the real clock and tests wire a frozen one. Here’s the entire module.
import { Temporal } from 'temporal-polyfill';
export const clock = { now: (): Temporal.Instant => Temporal.Now.instant(),};A clock object with a single now() method that does the one thing it’s named for. (temporal-polyfill is the project’s pinned source for Temporal on Node 24; when native Temporal lands it’s a one-line import swap.) A seam is a few lines, not a framework. The power is in the discipline around it: every /lib function that needs the current time calls clock.now(), never Temporal.Now.instant() inline and never Date.now(). Here’s what that does to computeDueAt.
import { Temporal } from 'temporal-polyfill';
const computeDueAt = (): Temporal.Instant => Temporal.Now.instant().add({ hours: 24 * 30 });Untestable: nothing in the signature or imports gives a test a handle on time. The only way to control the output is to control the machine’s clock, which you can’t.
import { clock } from '@/lib/clock';
const computeDueAt = (): Temporal.Instant => clock.now().add({ hours: 24 * 30 });The clock is now a named dependency. Production wires the real Temporal.Now; a test wires a frozen instant. Only the source of the time changed.
One line moved, but it’s the whole point: time went from a hidden global the function reaches out and grabs to a named dependency it asks a known object for. Hidden globals can’t be swapped; named dependencies can.
clock.now() The next two sections are the same picture with a different box in the middle; only the wiring changes. And the payoff outlasts tests: the seam is the single place “now” enters your domain, so replaying historical events against a virtual “now” or stepping a debugger through time means changing one module instead of every clock read.
Freezing the clock in a test
Section titled “Freezing the clock in a test”You have the seam. How does a test swap it? Two shapes inject the frozen value in two different places. We’ll look at both, then settle on a default.
import { Temporal } from 'temporal-polyfill';
vi.mock('@/lib/clock', () => ({ clock: { now: () => Temporal.Instant.from('2026-01-15T12:00:00Z') },}));
it('falls due 30 days out', () => { const due = computeDueAt(); expect(due.toString()).toBe('2026-02-14T12:00:00Z');});Leaves the unit’s signature alone: it keeps calling clock.now(), and the test swaps the module underneath. vi.mock replaces the whole @/lib/clock module for this test file, so every clock.now() returns the frozen instant. One mechanic to flag: Vitest hoists vi.mock above the imports, so the module is replaced before any code that imports it runs.
const computeDueAt = ({ now = clock.now } = {}): Temporal.Instant => now().add({ hours: 24 * 30 });
it('falls due 30 days out', () => { const frozen = () => Temporal.Instant.from('2026-01-15T12:00:00Z'); const due = computeDueAt({ now: frozen }); expect(due.toString()).toBe('2026-02-14T12:00:00Z');});The signature documents the dependency: no module machinery, the seam sits in the parameter list. The function takes its clock as an argument that defaults to the real one, so production calls it with nothing and the test passes a frozen now.
Both freeze the clock; the only difference is where the friction lands.
Default to injection for /lib helpers. The dependency is in the signature, so a reader sees it without opening a test; it’s type-checked, so the wrong shape won’t compile; and it needs no mock machinery.
Reach for the module mock when threading now through every signature costs more than it’s worth: a deep call chain where computeDueAt calls a helper that calls another helper, and you’d have to plumb now through all three to freeze the leaf. There, one vi.mock beats four edited signatures. You’re choosing where to put the seam, not whether to have one.
A shared frozen instant
Section titled “A shared frozen instant”In the last two sections we kept hand-writing Temporal.Instant.from('2026-01-15T12:00:00Z'). Spread that across the suite and every file pins to a slightly different “now,” so every failure message reads differently. Time deserves one canonical value the whole suite agrees on.
That’s what src/test/clock.ts is for. The factory lesson planted this file in the test-support tree; here’s what fills it.
import { Temporal } from 'temporal-polyfill';import { vi } from 'vitest';import { clock } from '@/lib/clock';
export const FROZEN = Temporal.Instant.from('2026-01-15T12:00:00Z');
export const freezeClock = (instant: Temporal.Instant = FROZEN): void => { vi.spyOn(clock, 'now').mockReturnValue(instant);};FROZEN is the one instant every time-touching test pins to, and freezeClock() does the wiring so a test reads freezeClock() instead of repeating the swap. It swaps the seam with vi.spyOn, which wraps a single method on the real clock object rather than mocking the whole module; we’ll meet vi.spyOn properly in the randomness section.
Sharing one instant is what makes failures reproducible. A failure that says “expected 2026-01-15T12:00:00Z plus 30 days, got 2026-02-13T12:00:00Z” is a bug you can read, reproduce, and fix. A failure that says “expected today plus 30 days” tells you almost nothing, because today moves every time you open the log. This is the factory lesson’s “deterministic data debugs better than random data,” pointed at time.
Fake timers for scheduled code
Section titled “Fake timers for scheduled code”The clock seam handles every domain read of “now,” but not code that schedules. A debounce built on setTimeout, a poller on setInterval, a token that checks Date for expiry, none of these ask clock.now(); they hand work to the runtime’s timer queue and wait. You can’t freeze a setTimeout by injecting a clock. You fake the timer machinery itself.
Vitest does this with vi.useFakeTimers(). This is the one legitimate beforeEach/afterEach pair you’ll write in a /lib test, the exception held back from this chapter’s first lesson for code that touches time.
import { afterEach, beforeEach, vi } from 'vitest';
beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-01-15T12:00:00Z'));});
afterEach(() => { vi.useRealTimers();});vi.useFakeTimers() replaces the runtime’s time primitives with fakes Vitest controls: Date, setTimeout, setInterval, clearTimeout, clearInterval, setImmediate, performance.now, and requestAnimationFrame. From then on, time doesn’t pass unless you advance it. vi.setSystemTime(new Date('2026-01-15T12:00:00Z')) sets the fake wall clock to a fixed moment; vi.advanceTimersByTime(5000) jumps it forward five seconds and fires every timer due in that window. You become the clock.
That control is why teardown is non-negotiable. vi.useRealTimers() in afterEach hands the real machinery back; forget it and the next test inherits your frozen clock and fails for unrelated reasons. Put vi.useFakeTimers() in beforeEach, never beforeAll, which would freeze the clock for the whole file and leak it between tests. Fresh fakes per test, real timers after each.
Now the wrinkle, the single highest-value thing in this lesson. Read both tabs carefully.
vi.setSystemTime(new Date('2026-01-15T12:00:00Z'));
const now = Temporal.Now.instant();expect(now.toString()).toBe('2026-01-15T12:00:00Z'); // expectedYou set the system time, so surely Temporal.Now.instant() reports it. Fake timers freeze “the clock,” and Temporal reads the clock, so Temporal should read the frozen time.
vi.setSystemTime(new Date('2026-01-15T12:00:00Z'));
const now = Temporal.Now.instant();expect(now.toString()).toBe('2026-01-15T12:00:00Z'); // FAILS — real timeTemporal.Now ignores fake timers and returns the real wall clock. Vitest’s fakes patch Date and friends, but Temporal’s clock is a separate mechanism they never touch, so the assertion fails with whatever the real time was when the test ran.
vi.useFakeTimers() patches Date. It does not patch Temporal.Now, whose clock is a separate mechanism the fakes never reach, so Temporal.Now.instant() sails past your frozen system time and reports the real wall clock. This is the split: for domain “now,” anything calling Temporal.Now, use the clock.now() seam, which sidesteps the question by swapping a module you own; for Date- and setTimeout-based code, usually at a third-party boundary, reach for vi.setSystemTime and fake timers.
One practical footnote on intervals. A setInterval reschedules itself forever, so vi.runAllTimers(), which runs timers until the queue is empty, never returns on it. The two safe moves are to advance a fixed horizon, firing every interval due in that window, or to run a single round of whatever is currently pending.
vi.advanceTimersByTime(30_000);vi.runOnlyPendingTimers();When the scheduled code is await-ed, advancing the timer alone won’t flush the promise waiting on it; that forgotten-await-with-timers trap belongs to the async-testing lesson later in this chapter.
A setTimeout or setInterval callback that the timer queue runs is a macrotask ; the full micro-versus-macrotask story belongs to the async lesson.
The same seam for IDs
Section titled “The same seam for IDs”IDs need the same seam as the clock. A function mints an idempotency key or stamps a new row’s primary key, and a test wants to assert the exact value it produced. Generate that ID with a live crypto.randomUUID() inside the function and you get a value you can never assert against, different every run. So you build the seam.
import { uuidv7 } from 'uuidv7';
export const newId = (prefix: string): string => `${prefix}_${uuidv7()}`;Same shape as the clock: a one-line module wrapping the real generator (uuidv7(), the project’s ID standard, for the index locality you met in the database unit), with one discipline around it. Every ID-generation site calls newId('inv'), never crypto.randomUUID() or uuidv7() inline. This is the seam the factory’s fixed id: 'usr_test' default was pointing at all along.
To pin sequenced IDs in a test, mock the module and feed it a counter, so newId hands out predictable values instead of random ones.
vi.mock('@/lib/ids', () => ({ newId: vi.fn() }));
it('stamps sequential invoice ids', () => { let n = 0; vi.mocked(newId).mockImplementation((prefix) => `${prefix}_${++n}`);
expect(newId('inv')).toBe('inv_1'); expect(newId('inv')).toBe('inv_2');});vi.mock replaces the module with a stub whose newId is a vi.fn(), an empty mock function. Then vi.mocked(newId) reaches that mock with full type information so you can program it. mockImplementation gives it a body: a counter that returns inv_1, inv_2, and so on. The test asserts exact IDs because it minted exact IDs.
vi.mocked opens onto a small companion set you’ll reach for together:
.mockImplementation(fn)/.mockReturnValue(v)set what the mock does or returns..mockResolvedValue(v)/.mockRejectedValue(err)are async shorthands for a mock that returns a promise; you’ll meet them again in the async lessons..mockReset()resets one mock;vi.resetAllMocks()resets every mock in the file.
That last one carries a familiar discipline in a new form. A counter that reached inv_2 in one test starts the next at inv_3 unless you reset it, so test order decides which IDs a test sees. That’s the run-order coupling from the factory lesson, this time in mock state rather than a shared object, and the cure is the same: wipe the slate between tests.
afterEach(() => vi.resetAllMocks());Now try the injection shape yourself, the cleaner of the two and the one that needs no mock at all. The function below builds an idempotency key from a user, an action, and an injected ID-maker. It must use the maker it’s handed, never generate an ID of its own. The tests pass a deterministic counter and assert the exact composed key across two calls, which only passes if the maker is doing the work.
Implement buildIdempotencyKey so it composes the key as userId:action:<id>, where the id comes from the injected makeId. Use the passed-in makeId — never generate an id inside the function.
The expected key is `${userId}:${action}:${makeId()}`. Reach for crypto.randomUUID() inside the function instead of calling makeId, and the second test’s :2 never matches. The seam is a parameter.
The same seam for randomness
Section titled “The same seam for randomness”Randomness is where business logic genuinely needs entropy: backoff jitter so retries don’t stampede, shard selection, A/B bucketing. It’s also where Math.random() quietly destroys reproducibility. The seam lives at src/lib/random.ts: production wires the real entropy source (crypto.getRandomValues, or Math.random for non-cryptographic jitter), and tests wire what the clock and IDs already taught you to want, a fixed starting point that yields the same stream every run.
That fixed point is a seed , and pure-rand turns one into a generator in a couple of lines.
import { unsafeUniformIntDistribution, xoroshiro128plus } from 'pure-rand';
const rng = xoroshiro128plus(42);const roll = unsafeUniformIntDistribution(0, 99, rng);xoroshiro128plus(42) builds a generator from the seed 42; draw from it and you get the identical stream on every run, until you change the seed. That completes the set. A frozen instant, a sequenced counter, a fixed seed are one idea in three forms: pin the uncontrolled source to a known starting point and the result is reproducible.
For a single call site, spying on Math.random is the lighter tool:
vi.spyOn(Math, 'random').mockReturnValue(0.42);// ... assert the one branch that depends on it ...afterEach(() => vi.restoreAllMocks());vi.spyOn(Math, 'random') wraps the real method and pins one return value, restored by vi.restoreAllMocks() in teardown. That’s fine when your logic draws a single random value. But the moment it draws a sequence, five jitter values or a stream of bucket assignments, a hand-fed list of mock returns gets brittle and you want a seed again. So the rule is clean: spy for one value, seam-plus-seed for a stream.
Enforcing the seam with a lint rule
Section titled “Enforcing the seam with a lint rule”A seam only works if nobody walks around it. The day someone types Date.now() inline, the determinism you built springs a leak. The durable fix is to stop relying on everyone remembering and make the codebase enforce it.
A lint rule does that. It bans the raw time sources everywhere except test files and the seam modules themselves, so reaching for the wall clock outside the seam isn’t a code-review note; it’s a build error.
// banned outside *.test.ts and src/lib/{clock,ids,random}.ts"no-restricted-syntax": [ "error", { "selector": "CallExpression[callee.object.name='Date'][callee.property.name='now']", "message": "Use clock.now() — Date.now() is banned outside the clock seam." }, { "selector": "NewExpression[callee.name='Date'][arguments.length=0]", "message": "Use clock.now() instead of new Date()." }]This is the exact pattern from the first lesson of this chapter, where no-restricted-paths turned a /lib-to-app import into a build error. Same philosophy: structural enforcement over code-review vigilance. The seam gives you the swap point; the lint rule guarantees nobody bypasses it.
That leaves one rule for the whole lesson, three sources folded into a single move:
One exercise to confirm the model landed. Each item below is a real non-deterministic input from this codebase. Drag it to the way you’d pin it in a test: a frozen clock or fake timers for moments and scheduling, a sequenced counter for IDs, a fixed seed for entropy.
Sort each non-deterministic source into the way you'd pin it in a test. Drag each item into the bucket it belongs to, then press Check.
dueAt computed from nowsetTimeout-based debouncevitest --project unit --watch is still your inner loop, but now the tests it runs hold still no matter the day, the runner’s timezone, or how many times you run them.
External resources
Section titled “External resources”The Vitest references for the vi surface this lesson used, covering fake timers, module mocks, and spies, plus the seeded-RNG library behind the randomness seam.