Ch. 22 · Software Testing

Testing Time with an Injected Clock

Pass a clock into time-dependent code, test expiry boundaries to the millisecond with node:test, and see why sleep-based tests flake.

~7 min readadvancedupdated Oct 19, 2026

Code that asks the system for the time has a hidden input. Date.now() returns a different value every call, so a function that uses it doesn’t give the same output for the same arguments, and a test can only check it loosely: “the trial is probably still active”, “the cache entry has probably expired by now”. The usual result is tests that sleep, tests that fail around midnight, and boundaries that nobody has actually tested.

Clock injection removes the hidden input. The code receives the time (or a way to read it) from its caller, production passes the real clock, and tests pass one they control. It’s a small change to the code with a big effect on what you can test.

A trial that expires after 14 days

Here’s a realistic function: a SaaS trial lasts 14 days from sign-up, and the UI shows how many days are left.

const DAY_MS = 24 * 60 * 60 * 1000;
export const TRIAL_DAYS = 14;

export function trialStatus(account, clock = () => Date.now()) {
  const elapsed = clock() - account.trialStartedAt.getTime();
  if (elapsed < 0) throw new RangeError('trial starts in the future');
  const daysLeft = TRIAL_DAYS - Math.floor(elapsed / DAY_MS);
  return daysLeft > 0 ? { state: 'active', daysLeft } : { state: 'expired', daysLeft: 0 };
}
JavaScript

The only change from the naive version is the clock parameter. Its default is the real clock, so production callers don’t change. Tests pass a function that returns whatever moment they want to examine.

Without that parameter, how would you test “the trial expires after exactly 14 days”? You could create an account whose trialStartedAt is new Date(Date.now() - 14 * DAY_MS). That works most of the time, but the test reads the clock once to build the fixture and the code reads it again a moment later, so you’re testing “14 days plus a few microseconds”. You can’t test “one millisecond before expiry” at all, because by the time the code runs, that millisecond has passed.

With the clock as an input, the boundary is just another value:

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { trialStatus } from './trial.js';

const start = new Date('2026-03-01T09:00:00Z');
const account = { trialStartedAt: start };
const at = (iso) => () => Date.parse(iso);

test('a new trial has 14 days left', () => {
  assert.deepEqual(trialStatus(account, at('2026-03-01T09:00:00Z')), { state: 'active', daysLeft: 14 });
});

test('one millisecond before day 14 ends the trial is still active', () => {
  assert.deepEqual(trialStatus(account, at('2026-03-15T08:59:59.999Z')), { state: 'active', daysLeft: 1 });
});

test('exactly 14 days later the trial has expired', () => {
  assert.deepEqual(trialStatus(account, at('2026-03-15T09:00:00Z')), { state: 'expired', daysLeft: 0 });
});

test('a start date in the future is rejected', () => {
  assert.throws(() => trialStatus(account, at('2026-02-28T09:00:00Z')), RangeError);
});
JavaScript

Running node --test --test-reporter=spec on Node 22.23 (summary lines trimmed):

✔ a new trial has 14 days left (1.717042ms)
✔ one millisecond before day 14 ends the trial is still active (0.08325ms)
✔ exactly 14 days later the trial has expired (0.0615ms)
✔ a start date in the future is rejected (1.174917ms)
Text

Each test reads like a specification: at this moment, the answer is this. The timestamps are written in UTC with an explicit Z, so the tests mean the same thing on a laptop in Mumbai and a CI runner in Virginia.

Writing these tests also forced a product question into the open. “14 days” here means 14 periods of 24 hours. If the business means “until the end of the 14th calendar day in the customer’s timezone”, the code is wrong, and a test that fixes the clock to 2026-03-15T23:30:00+05:30 is how you’d show it. Hidden time inputs hide decisions like this.

Shapes for the clock

There are a few common shapes, and the choice matters less than using one consistently.

  • A now function (() => number), as above. It’s the smallest possible interface and easy to fake with an arrow function.
  • A Clock object with now() and possibly today() or timeZone. Useful when code needs more than one time-related operation, or when you want one object to pass through a dependency-injection container.
  • A time value passed directly (trialStatus(account, nowMs)). The purest option: the function no longer reads time at all. It works well for calculations, and pushes the clock read up to the caller, typically a request handler that reads the time once.

That last point catches people. If a single operation reads the clock several times (once to check expiry, again to compute “days left”, again to stamp the audit log), the readings can straddle a boundary and disagree with each other. Read the time once per operation and pass the value down.

Advancing time without sleeping

Expiry logic often needs time to move. A cache with a time-to-live is the standard example. Here’s a cache that takes a now function, plus a tiny manual clock for tests:

export class TtlCache {
  #entries = new Map();
  constructor({ ttlMs, now = () => Date.now() }) {
    this.ttlMs = ttlMs;
    this.now = now;
  }
  set(key, value) {
    this.#entries.set(key, { value, expiresAt: this.now() + this.ttlMs });
  }
  get(key) {
    const entry = this.#entries.get(key);
    if (!entry) return undefined;
    if (this.now() >= entry.expiresAt) {
      this.#entries.delete(key);
      return undefined;
    }
    return entry.value;
  }
}

export function manualClock(startMs = 0) {
  let t = startMs;
  const now = () => t;
  now.advance = (ms) => { t += ms; };
  return now;
}
JavaScript

First, the test people write when they don’t control time:

test('entries expire (real clock, real sleep)', async () => {
  const cache = new TtlCache({ ttlMs: 50 });
  cache.set('user:7', { name: 'Ada' });
  await sleep(49);
  assert.deepEqual(cache.get('user:7'), { name: 'Ada' });
  await sleep(1);
  assert.equal(cache.get('user:7'), undefined);
});
JavaScript

Here sleep is setTimeout from node:timers/promises. In 20 runs on a Mac with nothing else busy, this test passed twice. The failures all looked like this:

✖ entries expire (real clock, real sleep) (51.585042ms)
  AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal:
  + actual - expected
  + undefined
Text

A timer promises to wait at least the delay, not exactly. sleep(49) regularly took 50 ms or more, so the entry had already expired at the first assertion. Make the margins bigger (sleep 25 ms, then 100 ms) and the test becomes reliable-ish, but it’s now slow, and it no longer tests the boundary. Multiply by a few hundred tests and the suite takes minutes.

With the manual clock, the same scenario is exact and instant:

test('entries expire exactly at the TTL', () => {
  const clock = manualClock(1_000);
  const cache = new TtlCache({ ttlMs: 30_000, now: clock });
  cache.set('user:7', { name: 'Ada' });

  clock.advance(29_999);
  assert.deepEqual(cache.get('user:7'), { name: 'Ada' });

  clock.advance(1);
  assert.equal(cache.get('user:7'), undefined);
});
JavaScript

The TTL is now a realistic 30 seconds, the test checks both sides of the boundary (29,999 ms is fresh, 30,000 ms is expired, which also pins down the >=), and it ran in under a millisecond.

Injection versus global fake timers

Node’s test runner (and Jest, Vitest and Sinon) can also fake time globally by replacing Date and the timer functions. The same cache, with no now passed in:

test('the same behaviour with mock.timers patching Date', () => {
  mock.timers.enable({ apis: ['Date'], now: 1_000 });
  try {
    const cache = new TtlCache({ ttlMs: 30_000 });
    cache.set('user:7', { name: 'Ada' });
    mock.timers.tick(30_000);
    assert.equal(cache.get('user:7'), undefined);
  } finally {
    mock.timers.reset();
  }
});
JavaScript

It passes, and Node 22 prints ExperimentalWarning: The MockTimers API is an experimental feature and might change at any time, which is worth knowing before you build a suite on it.

Global fakes are the right tool when you can’t change the code, such as a third-party library that calls setTimeout, or when the code under test schedules timers rather than reading the time. Injection has advantages when you can choose. The dependency is visible in the signature, so a reader knows the function is time-sensitive. Nothing global is patched, so tests that run concurrently in one process can’t see each other’s fake time, and a forgotten reset() can’t leak a frozen clock into the next test. And you aren’t relying on the fake implementing every corner of Date the code happens to use.

The two also combine. A clock interface can include setTimeout-like scheduling (clock.schedule(fn, ms)), and the test version runs callbacks when you advance it. That’s how you test retry back-off or debouncing without real waiting.

Interview exercise: the overdue-invoice job

A nightly job emails customers whose invoices are more than 30 days overdue. Its tests create invoices with dueDate: new Date(Date.now() - 31 * DAY_MS) and check an email was queued. The tests pass, but customers report getting the reminder a day early or a day late around the end of March. What’s wrong, and how would you restructure the code and tests?

Answer

The tests only cover a case comfortably past the threshold, computed from the real clock, so the boundary (exactly 30 days) is never tested. The “end of March” clue suggests the code compares calendar dates in local time (for example with getDate() arithmetic or by building midnight with new Date(y, m, d)) and is hit by a daylight-saving change, when a local day is 23 or 25 hours long, or that “30 days” is computed in the server’s timezone rather than the customer’s.

I’d change the job so it reads the time once at the start of the run (const now = clock.now()) and passes now and the customer’s timezone into a pure function isOverdue(invoice, now, timeZone). That function defines “30 days” explicitly, ideally as calendar days in the customer’s zone. Then the tests fix the clock: an invoice due on 1 March checked at 2026-03-31T23:59:59 in the customer’s zone is not yet overdue, at 2026-04-01T00:00:00 it is, and a case spanning the European DST switch on 29 March 2026 checks that the answer doesn’t shift by an hour. The job-level test injects a fixed clock and a fake mailer and checks that only the right invoice is emailed. No test reads the real clock or sleeps.

More in Software Testing

read ✓Software Testing · hard

Parallel Test Isolation

Reproduce a shared temp file, a fixed port and module-level state breaking parallel node:test runs, then give each test its own resources.

~7 min readread →
read ✓Software Testing · mid

Code Coverage and Its Limits

See a 100% covered function ship a boundary bug, read line and branch numbers from Node's coverage report, and set thresholds that help.

~6 min readread →
read ✓Software Testing · mid

Unit and Integration Test Balance

Watch fast unit tests pass while a real SQLite test catches a swapped column and an untranslated unique-constraint error, then split the work.

~7 min readread →
esc