Time and randomness

Make the two unpredictable things in every program exact, so the tests stop guessing.

07of 16
≈ 12Min read
5Parts

The test that fails at midnight

Real time in a test means: a test that waits, a test that is different at the end of the month, a test that fails when the machine is slow, and a test nobody can reason about. The cure is always the same — the code asks something for the time, rather than taking it.

and passes for the rest of the year
function isExpired(quote) {
  return Date.now() - quote.issuedAt > 30 * 24 * 60 * 60 * 1000;
}

it("expires after thirty days", () => {
  const quote = { issuedAt: Date.now() - 31 * 24 * 60 * 60 * 1000 };

  expect(isExpired(quote)).toBe(true);      // fine — but the test depends on the real clock
});

Inject the clock

Two lines of production code, and the test becomes an exact statement about a boundary. This is the same move as the dependency injection in the last lesson, applied to the most-used global in the language.

a default argument, and the whole problem goes away
export function isExpired(quote, now = Date.now()) {
  return now - quote.issuedAt > THIRTY_DAYS;
}

it("expires after thirty days", () => {
  expect(isExpired({ issuedAt: 0 }, THIRTY_DAYS + 1)).toBe(true);
  expect(isExpired({ issuedAt: 0 }, THIRTY_DAYS - 1)).toBe(false);
});

Fake timers, for code that schedules

Every real runner has this — vi.useFakeTimers(), jest.useFakeTimers() — and it replaces setTimeout, setInterval and Date.now globally for the duration of the test. The effect is the same: a debounce test that takes a microsecond and proves the boundary, instead of one that sleeps 300 ms and proves roughly nothing.

move the clock by hand instead of waiting
const clock = fakeTimers(0);

const save = spy();
const debounced = debounce(save, 300, clock);

debounced("a");
debounced("b");

clock.tick(299);
expect(save.callCount).toBe(0);      // not yet

clock.tick(1);
expect(save.callCount).toBe(1);      // exactly once
expect(save.calledWith("b")).toBe(true);
Restore them afterwards

Fake timers are global state. A suite that installs them and forgets to restore leaves every later test in a world where setTimeout never fires — which shows up as a completely unrelated test hanging. afterEach(() => vi.useRealTimers()).

Randomness

The same applies to ids and tokens: crypto.randomUUID() in the middle of a function is untestable, and a function that takes an id generator is testable and no harder to call.

seeded, or injected — never left to chance
// the code takes its source of randomness
export function pickFitter(fitters, random = Math.random) {
  return fitters[Math.floor(random() * fitters.length)];
}

// the test decides what "random" means
expect(pickFitter(["a", "b", "c"], () => 0)).toBe("a");
expect(pickFitter(["a", "b", "c"], () => 0.99)).toBe("c");

// or a seeded generator, when you need a whole sequence
const random = seededRandom(42);

Time zones and formatting

A test that asserts a formatted date fails on a machine in another zone unless the zone is fixed. Set TZ=UTC in the test environment, store everything in UTC as the web course said, and assert on the ISO string rather than the localised one — or pass an explicit locale and zone to Intl.DateTimeFormat and assert that.

Practice