Inject a Clock Instead of Calling Date.now
Pass a clock through the domain code, freeze time in tests, and assert expiry exactly at the TTL boundary instead of sleeping.
Task
Make time-dependent logic deterministic by injecting a clock and testing the exact boundary of a token TTL.
When to use this
A session token is valid for sixty minutes. Tests sleep or use real time, so the boundary is untested and a rounding bug keeps tokens valid one second too long.
Prerequisites
- Node.js 22 with TypeScript
- A token or session module that depends on the current time
- Vitest, Jest, or node:test for the assertions
Steps
1. Define a minimal clock interface and a system implementation used by production.
export interface Clock {
now(): number;
}
export const systemClock: Clock = { now: () => Date.now() };
Expected output: Domain code accepts a Clock, and only the composition root passes systemClock.
2. Take the clock as a parameter and make the expiry comparison inclusive at the boundary.
export function isExpired(issuedAt: number, ttlMs: number, clock: Clock = systemClock): boolean {
return clock.now() >= issuedAt + ttlMs;
}
Expected output: A token is expired at exactly ttlMs after issue, and the default keeps existing call sites working.
3. Drive the clock manually in the test instead of sleeping.
let now = 1_700_000_000_000;
const clock: Clock = { now: () => now };
const issuedAt = now;
assert.equal(isExpired(issuedAt, 3_600_000, clock), false);
now = issuedAt + 3_600_000 - 1;
assert.equal(isExpired(issuedAt, 3_600_000, clock), false);
now = issuedAt + 3_600_000;
assert.equal(isExpired(issuedAt, 3_600_000, clock), true);
Expected output: The test runs in microseconds and covers one millisecond before, at, and after the boundary.
4. Add a table for the boundary contract so the inclusive rule is documented.
for (const [offset, expected] of [[-1, false], [0, true], [1, true]] as const) {
assert.equal(
isExpired(issuedAt, 3_600_000, { now: () => issuedAt + 3_600_000 + offset }),
expected,
);
}
Expected output: Offsets of -1 ms, 0, and +1 ms produce false, true, and true, pinning the inclusive semantics.
5. Use fake timers only at the edges, and restore them after the test.
import { install } from '@sinonjs/fake-timers';
const clock = install({ now: 0, toFake: ['setTimeout', 'Date'] });
try {
await runRetry();
clock.tick(5_000);
} finally {
clock.uninstall();
}
Expected output: Retry delays are exercised instantly, and timers are restored so later tests are unaffected.
Troubleshooting
Problem
Fake timers hang promises or database calls
Solution
Do not fake setTimeout globally. Inject a clock for business logic, and use tickAsync with fake timers only around timer-based code.
Problem
A new code path still calls Date.now directly
Solution
Add a lint rule that bans Date.now and new Date() in domain modules, and allow them only in the clock adapter.
Problem
Expiry behaves differently around DST or time zones
Solution
Store and compare epoch milliseconds in UTC. Convert to a local time zone only for display, using an explicit zone.
Limitations
- Clock injection does not solve calendar or time-zone arithmetic; use a library that takes an explicit zone.
- Third-party libraries that call Date internally cannot be injected and need edge-level tests.
- Fake timers can hide real concurrency and IO timing problems, so keep integration tests on real time.
Time makes tests flaky and bugs invisible
A test that calls the real clock can only assert “not expired yet” or sleep until expiry. Sleeping makes the suite slow, and the interesting boundary, one millisecond around the TTL, is never tested. Worse, the production code has no seam: Date.now() appears in five places and each one can disagree about what “now” means within a single request. Injecting a clock fixes both problems.
One clock per unit of work
Pass a Clock into the function or the service that needs time. Production passes the system clock once, at the composition root. The function then reads clock.now() consistently, which also guarantees that all decisions inside one operation use the same instant. A token issued at T and validated at T+1 ms should not be evaluated with two different wall-clock reads.
export interface Clock { now(): number }
export const systemClock: Clock = { now: () => Date.now() };
export const isExpired = (issuedAt: number, ttlMs: number, clock: Clock = systemClock) =>
clock.now() >= issuedAt + ttlMs;
Test the boundary, not the middle
The bug lives at the comparison. Choose whether expiry is inclusive (>=) or exclusive (>), write it down, and test -1 ms, 0, and +1 ms relative to the boundary. Those three assertions catch off-by-one errors, rounding mistakes, and accidental changes from >= to >. They run instantly and never depend on the machine clock.
Fake timers have a narrower role
Fake timers replace setTimeout, Date, and sometimes performance.now globally. They are useful for retry loops and debounce logic, but they can deadlock promise-based code and hide real IO behaviour. Prefer clock injection for business rules, and reserve fake timers for a small number of timer-specific tests where you advance time explicitly with tickAsync.
Enforce the seam
A single direct Date.now() in a new helper quietly reintroduces nondeterminism. Add a lint rule that forbids it in domain code, and allow it only in the clock adapter and in logging. The rule is cheap, and it keeps the injected clock from becoming an optional convention that new code ignores.