Pinning time, IDs, and randomness
Make Vitest unit tests deterministic by routing the clock, ID generation, and randomness through seams your tests can freeze.
Every test you’ve written so far has had a steady property you may not have noticed: feed it the same input and it gives you the same answer, today, tomorrow, and at 3 AM on a CI runner in another timezone. That steadiness comes from purity. Break it and the test still passes most of the time, which is worse than failing outright, because nobody trusts a test that fails for reasons they can’t explain.
A /lib function can break that steadiness in three ways: it reads the clock, it generates an ID, or it rolls a random number. Each of these is an input the function pulls out of thin air instead of receiving as an argument, and an input you don’t control is an input that breaks your test on a different day, in a different timezone, or on a different run. This lesson is about taking control of all three.
The plan is to learn one move and apply it three times. We’ll teach it in full on the clock, since it’s the most common and it has a wrinkle that trips up everyone, then move quickly through IDs and randomness, because by then you’ll already know the shape. We’ll also cash in a promise from the factory lesson: those frozen createdAt and id defaults were pointing at exactly this machinery.
A test that passes today and fails next month
Section titled “A test that passes today and fails next month”Here’s a /lib helper you’d recognize. A draft invoice is due 30 days from the moment it’s created, so computeDueAt reads “now” and adds 30 days. The test checks that the result is 30 days out 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; that’s a detail, not the point here.) Read both tabs.
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. Pin a literal expected instant instead and it’s wrong the moment the calendar moves past it. Run the suite at 23:59:59 and a read on either side of midnight lands on a different day. None of these are bugs in computeDueAt; they’re bugs in letting the clock be an input.
A test like this fails on Tuesday at 14:32 and passes on Tuesday at 14:33, with not a single character of code changed in between. The team has a word for that: flaky . The word is misleading. Flaky implies bad luck, something random you wait out or paper over with a retry. This isn’t bad luck. The test reads the wall clock , the machine’s real current time that Temporal.Now and Date.now() return, and the wall clock keeps moving. The failure has a cause, and the cause is structural: time is an uncontrolled input. A test that fails for a reason you can name and fix is broken, not flaky, and broken is the better diagnosis, because broken has a repair.
The repair, in one sentence: route “now” through a single point your test can freeze, so the unit reads a clock the test controls instead of the one on the wall. The rest of the clock sections build exactly that. The same problem hides behind crypto.randomUUID() in an idempotency key and Math.random() in a retry’s jitter: same uncontrolled input, same cure, which is why the last two sections of this lesson will feel familiar.
The clock module: one seam for “now”
Section titled “The clock module: one seam for “now””The fix has a name. It’s called a seam , a deliberate point in your code where a dependency can be swapped, so production wires one implementation and your tests wire another. The word comes from the seam on a garment: the place designed to be opened. You’re going to put one seam in front of every clock read in the whole codebase.
It’s smaller than you might expect. Here’s the entire thing.
import { Temporal } from 'temporal-polyfill';
export const clock = { now: (): Temporal.Instant => Temporal.Now.instant(),};That’s it: a clock object with a single now() method, and that method does the one thing it’s named for. (The temporal-polyfill import is the project’s pinned source for Temporal on Node 24; when native Temporal lands across the runtime it’s a one-line import swap, and nothing here changes.) A seam is a few lines, not a framework. The power isn’t in the code; it’s in the discipline around it.
The discipline is one rule: 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 the imports gives a test a handle on time. The only way to control this function’s output is to control the machine’s clock, which you can’t do.
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. The function body didn’t really change, only where the time comes from.
One line moved, but that one line is 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.
Here’s the move drawn out. The unit in the middle calls clock.now() and never knows or cares what it gets back. In production, the seam hands it the real wall clock. In a test, the seam hands it a frozen instant, a fixed point in time that never moves. Same call site, two different things behind it.
clock.now() Hold onto that picture, because the next two sections are the same picture with a different box in the middle. The unit never changes; only the wiring does.
One more thing, so you don’t file this under “a trick for tests.” The seam is the single place “now” enters your domain, and that’s valuable on its own. The day you need to replay a batch of historical events with a virtual “now,” or step a debugger through time, you change one module instead of hunting down every clock read in the codebase. That’s the experienced engineer’s framing: you pay one small indirection up front to make time a first-class, swappable input forever. Tests are just the first thing it buys you.
Freezing the clock in a test
Section titled “Freezing the clock in a test”You’ve got the seam. So how does a test actually swap it? There are two shapes, and they answer the same question, “where does the frozen value get injected?”, 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');});Least invasive on the unit’s signature: the function keeps calling clock.now(), and the test hands it a frozen module. vi.mock replaces the whole @/lib/clock module for this test file, so every clock.now() in the code under test returns the frozen instant. One surprising mechanic to flag: Vitest hoists vi.mock to the very top of the file, so it runs before the imports do. (Why that hoisting matters in depth is the async-testing lesson’s job; for now just know the call floats up.)
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 right there 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. The function declares what it depends on, in plain sight.
Both work, and both freeze the clock. The only difference is where the friction lands, and that’s the whole decision.
Default to injection for /lib helpers. It’s explicit: the dependency is in the signature, so a reader sees it without opening a test. It’s type-checked: pass the wrong shape and the compiler stops you. And it needs no mock machinery. The seam-as-a-parameter is the cleanest version of the whole idea, since the unit literally accepts its clock.
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 just to freeze the leaf. There, one vi.mock at the top of the test file beats four edited signatures. Both are legitimate. You’re choosing where to put the seam, not whether to have one.
The frozen-instant convention
Section titled “The frozen-instant convention”In the last two sections we kept writing Temporal.Instant.from('2026-01-15T12:00:00Z') by hand. Do that in fifty test files and you’ve got fifty slightly different “nows,” some at noon, some at midnight, one with a typo’d month, and 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, a lighter touch than mocking the whole module; we’ll meet vi.spyOn properly in the randomness section.) Now every clock-dependent failure across the suite reproduces identically, because they all started from the same moment.
That word reproduces is the entire reason. A failure that says “expected 2026-01-15T12:00:00Z plus 30 days, got 2026-02-13T12:00:00Z” is a bug you can chase: you read it, you reproduce it, you fix it. A failure that says “expected today plus 30 days” tells you almost nothing, because today is a different date every time you open the log, and the run that produced the failure is already gone. This is the factory lesson’s “deterministic data debugs better than random data,” pointed at time instead of at field values. Same principle, same payoff: one frozen instant, shared by everyone.
Fake timers for code that schedules
Section titled “Fake timers for code that schedules”The clock seam handles every domain read of “now.” But there’s a second kind of time-dependence it doesn’t touch: 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() for the time; they hand work to the runtime’s timer queue and wait. You can’t freeze a setTimeout by injecting a clock. You need to fake the timer machinery itself.
Vitest does this with vi.useFakeTimers(), and this is the moment to cash in a promise from the first lesson of this chapter. The rule there was no beforeEach in /lib tests, since a pure function needs no setup, with one named exception held back for “code that touches time.” This is that exception, and it’s the only legitimate beforeEach/afterEach pair you’ll write in a /lib test.
import { afterEach, beforeEach, vi } from 'vitest';
beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-01-15T12:00:00Z'));});
afterEach(() => { vi.useRealTimers();});When you call vi.useFakeTimers(), Vitest replaces the runtime’s time primitives with fakes it controls: Date, setTimeout, setInterval, clearTimeout, clearInterval, setImmediate, performance.now, and requestAnimationFrame. From that point, time doesn’t pass unless you tell it to. 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 that was due in that window. You become the clock.
That control is exactly why the teardown is non-negotiable. vi.useRealTimers() in afterEach hands the real machinery back. Forget it, and the next test inherits your frozen clock, then fails for reasons that have nothing to do with what it’s testing, which is a confusing failure to debug. Put vi.useFakeTimers() in beforeEach, never beforeAll: a beforeAll freezes the clock for the whole file and leaks 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. The intuition says 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 entirely and returns the real wall clock. Vitest’s fake timers patch Date, setTimeout, and the rest, but Temporal’s clock is a separate mechanism that those fakes never touch, so this assertion fails with a “now” that’s whatever the real time was when the test ran.
vi.useFakeTimers() patches Date. It does not patch Temporal.Now. Temporal’s clock is a separate mechanism, decoupled from Date, and the fake-timer machinery never reaches it, so Temporal.Now.instant() sails right past your frozen system time and reports the real wall clock. This is exactly why the clock-module seam is the durable answer for every domain “now”: the seam sidesteps the question entirely. You don’t have to work out whether Temporal is faked; you swap a module you own.
So the split is clear. For domain “now,” anything calling Temporal.Now, use the clock.now() seam from the last three sections. For Date-based and setTimeout-based code, usually at a third-party boundary, reach for vi.setSystemTime and fake timers. (Native fake-timer support for Temporal is being discussed as Temporal lands in the runtime; worth knowing it’s coming, but not worth depending on today.)
One practical footnote on intervals. A setInterval reschedules itself forever, so vi.runAllTimers(), which means “run timers until the queue is empty,” never returns on it, because the queue never empties. The two safe moves are to advance a fixed horizon, which fires every interval due in that window, or to run a single round of whatever is currently pending.
vi.advanceTimersByTime(30_000);vi.runOnlyPendingTimers();There’s a sharper edge here when the scheduled code is await-ed: advancing the timer alone won’t flush the promise that’s waiting on it. That’s the forgotten-await-with-timers trap, and it belongs to the async-testing lesson later in this chapter. We’re staying on the synchronous timer surface today; just know the trap exists and has an owner.
A setTimeout or setInterval callback that the timer queue runs is a macrotask , the unit the timer queue schedules and fires. That’s the only piece of the task-queue model you need right now; the full micro-versus-macrotask story belongs to the async lesson too.
The same seam for IDs
Section titled “The same seam for IDs”The clock taught you the move. IDs are the same move on a different source, so this goes quickly.
Where does an ID come from that a test would want to control? A function mints an idempotency key, or stamps a new row’s primary key, and a test wants to assert on the exact value it produced. Generate that ID with a live crypto.randomUUID() inside the function and you’re back to square one: 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, deterministically. The test asserts exact IDs because it minted exact IDs.
That vi.mocked(...) opens onto a small companion set worth learning together, because you’ll reach for all of them:
.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 lean on these in the async and integration lessons; just meet them here.).mockReset()resets one mock;vi.resetAllMocks()resets every mock in the file.
That last one carries a discipline you already know in a new form. A counter that reached inv_2 in one test starts the next test at inv_3 unless you reset it, and then test order decides which IDs a test sees. That’s the run-order coupling the factory lesson warned about, reappearing in mock state instead of in a shared object. 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 should build an idempotency key from a user, an action, and an injected ID-maker. The catch is in the instructions: 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 actually 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()}`. If you reached for crypto.randomUUID() inside the function instead of calling makeId, the second test’s :2 would never match, which is the whole point of the drill. The seam is a parameter.
The same seam for randomness
Section titled “The same seam for randomness”Third source, same move, the quickest yet. 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.
For randomness that fixed point is a seed , and pure-rand gives you a seeded generator in a couple of lines.
import { unsafeUniformIntDistribution, xoroshiro128plus } from 'pure-rand';
const rng = xoroshiro128plus(42);const roll = unsafeUniformIntDistribution(0, 99, rng);A seed makes randomness deterministic: same seed, same sequence, every single run. xoroshiro128plus(42) builds a generator from the seed 42; draw from it and you get the identical stream of values on every run, until you change the seed. That is the randomness twin of FROZEN for time and the counter for IDs. Look at the three together: a frozen instant, a sequenced counter, a fixed seed. They are one idea in three forms: pin the uncontrolled source to a known starting point, and you get a reproducible result. Learn it once, use it everywhere.
You might ask why not just spy on Math.random. You can, and for a single call site it’s 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 lets you pin one return value, restored by vi.restoreAllMocks() in teardown. It’s narrower than a module mock, and fine when your logic draws a single random value. But the moment the logic draws a sequence, five jitter values or a stream of bucket assignments, a hand-fed list of mock returns gets brittle and you’re back to wanting a seed. So the rule is clean: spy for one value, seam-plus-seed for a stream.
Keeping the wall clock out by default
Section titled “Keeping the wall clock out by default”You’ve now seen the seam three times, and there’s a weakness in all of it: a seam only works if nobody walks around it. One tired developer types Date.now() inline at 5 PM on a Friday, and the determinism you built quietly 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 instead of a habit you had to police by hand. Same philosophy: structural enforcement over code-review vigilance. A rule a machine checks on every commit beats a rule humans are supposed to remember, every time. The seam gives you the swap point; the lint rule guarantees nobody bypasses it.
That leaves you with 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: frozen clock or fake timers for moments and scheduling, a sequenced counter for IDs, a fixed seed for entropy. If you can sort these without hesitating, the three-instances-of-one-pattern model is yours.
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 debounceThat’s the daily craft of keeping /lib tests deterministic. The command hasn’t changed, vitest --project unit --watch is still your inner loop, but now the tests it runs hold still no matter what day it is, what timezone the runner sits in, or how many times you run them. Time, IDs, and randomness stopped being things that happen to your tests and became inputs you hand them.
External resources
Section titled “External resources”The Vitest references for the vi surface this lesson leaned on, covering fake timers, module mocks, and spies, plus the seeded-RNG library behind the randomness seam.