Ch. 22 · Software Testing

Fake Timers and Clock-Based Behavior

Test debounces, cooldowns and retry backoff with node:test mock timers, and avoid the Date.now and promise traps that make fake clocks lie.

~7 min readintermediateupdated Oct 11, 2026

Code that waits is awkward to test. A search box that debounces keystrokes by 300 ms, a button with a one-second cooldown, a client that retries after 100 ms and then 200 ms: testing any of these with real time means real waiting, and real waiting means slow suites and tests that fail when a CI machine is busy.

Fake timers swap the runtime’s timer functions for versions driven by a clock the test controls. Nothing fires until the test says “advance 300 ms”, and then everything due in that window fires immediately, in order. A test that would take seconds runs in a couple of milliseconds, and it does the same thing on every machine.

The examples here use the mock timers built into Node’s test runner (Node 22 prints an ExperimentalWarning the first time you use them; the API itself is stable enough for daily use). Jest’s jest.useFakeTimers() and Sinon’s @sinonjs/fake-timers work on the same idea, and every trap described below applies to them too.

What a fake clock actually replaces

t.mock.timers.enable({ apis: [...] }) replaces the functions you list. The options are setTimeout, setInterval, setImmediate (each with its clear counterpart), scheduler.wait and Date. Once enabled, scheduled callbacks sit in a queue until you move the clock:

  • t.mock.timers.tick(ms) advances the clock by ms and runs every callback that became due, in due-time order.
  • t.mock.timers.runAll() jumps the clock to the furthest timer currently queued and fires everything due on the way. With a lone 30 ms setInterval it fires once, not forever; Jest’s runAllTimers() instead keeps going and aborts after 100,000 timers, assuming an infinite loop.

Calling enable() with no list fakes all of them, Date included. When you pass a list, anything you didn’t name stays real. That detail causes most of the confusing failures, so it gets its own section below.

Testing a debounce without waiting

A debounce delays a call until input has stopped for a given time. Every new call cancels the pending one and starts the wait again.

export function debounce(fn, waitMs) {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), waitMs);
  };
}
JavaScript

The behavior you want to pin down: three quick keystrokes produce one search, for the last value, exactly 300 ms after the last keystroke.

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { debounce } from './timing.js';

test('debounced search fires once, 300 ms after the last keystroke', (t) => {
  t.mock.timers.enable({ apis: ['setTimeout'] });
  const runSearch = t.mock.fn();
  const onInput = debounce(runSearch, 300);

  onInput('r');
  t.mock.timers.tick(100);
  onInput('re');
  t.mock.timers.tick(100);
  onInput('rea');
  t.mock.timers.tick(299);
  assert.equal(runSearch.mock.callCount(), 0);

  t.mock.timers.tick(1);
  assert.equal(runSearch.mock.callCount(), 1);
  assert.deepEqual(runSearch.mock.calls[0].arguments, ['rea']);
});
JavaScript
✔ debounced search fires once, 300 ms after the last keystroke (1.726416ms)
Text

Two choices in that test matter. The ticks of 100 ms between keystrokes are shorter than the 300 ms wait, so each keystroke must cancel the previous timer; an implementation that forgot clearTimeout would call runSearch three times. And the final advance is split into 299 and 1. Asserting “nothing yet” at 299 ms and “exactly one call” at 300 ms checks the boundary from both sides. If you only tick(1000) and check for one call, a debounce that waits 30 ms or 900 ms passes just as well.

The clock you forgot to fake: Date.now

A cooldown doesn’t schedule anything. It just compares timestamps:

export function createCooldown(gapMs) {
  let last = -Infinity;
  return function tryRun() {
    const now = Date.now();
    if (now - last < gapMs) return false;
    last = now;
    return true;
  };
}
JavaScript

If you fake only setTimeout and then tick a full second, the cooldown still refuses to run:

test('only setTimeout mocked: the cooldown never expires', (t) => {
  t.mock.timers.enable({ apis: ['setTimeout'] });
  const tryRun = createCooldown(1000);
  assert.equal(tryRun(), true);
  t.mock.timers.tick(1000);
  assert.equal(tryRun(), true);
});
JavaScript
✖ only setTimeout mocked: the cooldown never expires (1.489917ms)
  AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:

  false !== true
Text

tick(1000) moved the fake timer clock. Date.now() still reads the real wall clock, which advanced by about a millisecond. The code is correct; the test is measuring the wrong clock. Add Date to the faked APIs and fix the starting time:

test('Date mocked too: the cooldown follows the fake clock', (t) => {
  t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: 0 });
  const tryRun = createCooldown(1000);
  assert.equal(tryRun(), true);
  t.mock.timers.tick(999);
  assert.equal(tryRun(), false);
  t.mock.timers.tick(1);
  assert.equal(tryRun(), true);
});
JavaScript
✔ Date mocked too: the cooldown follows the fake clock (0.172417ms)
Text

The reverse trap is just as common. Code that measures time with performance.now() or process.hrtime() ignores a faked Date, because Node’s mock timers don’t touch those. If your production code reads time in several ways, fake timers get fragile, and passing a clock object into the code (see the note on clock injection linked below) is usually the cleaner design.

Timers inside async code

Retry with exponential backoff mixes promises and timers, and that’s where fake clocks surprise people:

export async function withRetry(op, { attempts = 3, baseMs = 100 } = {}) {
  for (let i = 0; ; i++) {
    try {
      return await op();
    } catch (err) {
      if (i + 1 >= attempts) throw err;
      await new Promise((resolve) => setTimeout(resolve, baseMs * 2 ** i));
    }
  }
}
JavaScript

The obvious test calls withRetry, ticks 100 then 200, and awaits the result:

test('ticking straight away: the backoff timer does not exist yet', async (t) => {
  t.mock.timers.enable({ apis: ['setTimeout'] });
  const { op } = flakyOp(2);           // fails twice with "503 Service Unavailable"
  const result = withRetry(op, { attempts: 3, baseMs: 100 });
  t.mock.timers.tick(100);
  t.mock.timers.tick(200);
  assert.equal(await result, 'ok');
});
JavaScript
✖ ticking straight away: the backoff timer does not exist yet (2.477417ms)
  'Promise resolution is still pending but the event loop has already resolved'
Text

Walk through it. withRetry calls op(), which returns a rejected promise. The catch block, and the setTimeout inside it, only run after the current synchronous code finishes and the microtask queue drains. The test’s two tick calls run before that, so they advance a clock with nothing scheduled on it. By the time the backoff timer is created, nobody will ever tick again, and the result never settles. With faked timers there’s no real handle keeping Node alive either, so the runner gives up and cancels the test. It can take its neighbours down too: with the corrected test below placed in the same file, the runner reported that one as cancelled as well, without ever starting it.

The fix is to let pending promise callbacks run before each tick. A real setImmediate (deliberately left out of the faked APIs) works as a “flush” because it runs after the microtask queue is empty:

const flush = () => new Promise((resolve) => setImmediate(resolve));

test('retries after 100 ms, then 200 ms, then succeeds', async (t) => {
  t.mock.timers.enable({ apis: ['setTimeout'] });
  const { op, calls } = flakyOp(2);
  const result = withRetry(op, { attempts: 3, baseMs: 100 });

  await flush();
  assert.equal(calls(), 1);
  t.mock.timers.tick(99);
  await flush();
  assert.equal(calls(), 1);
  t.mock.timers.tick(1);
  await flush();
  assert.equal(calls(), 2);
  t.mock.timers.tick(200);

  assert.equal(await result, 'ok');
  assert.equal(calls(), 3);
});
JavaScript
✔ retries after 100 ms, then 200 ms, then succeeds (2.15875ms)
Text

This version also proves the backoff schedule rather than only the final result: no retry at 99 ms, the second attempt at 100 ms, the third after a further 200 ms. Jest and Sinon ship async variants (jest.advanceTimersByTimeAsync, clock.tickAsync) that interleave promise flushing with timer firing for you; Node’s runner leaves it to you.

Cleaning up after yourself

Timers enabled through the test context, t.mock.timers, are restored automatically when that test ends. Inside the test setTimeout is the mock; in the next test it’s the native function again. The top-level mock.timers imported from node:test stays enabled until you call mock.timers.reset(), so a forgotten reset leaks a frozen clock into every later test in the file. Prefer the context version unless you have a reason not to.

Interview practice

A session should show a “you’ll be signed out soon” banner after 14 minutes of inactivity and sign the user out at 15 minutes. Any activity resets both. The implementation stores lastActivity = Date.now() and runs a setInterval every 30 seconds that checks how long it’s been. How would you test it?

Answer

Because the code uses both a timer and Date.now(), I’d fake both: enable({ apis: ['setInterval', 'Date'], now: 0 }). Faking only setInterval would make the interval fire while Date.now() stayed put, so the session would never look idle and every test would fail in a confusing way.

Then I’d test the boundaries, not just “eventually”. Advance to 13 minutes 59 seconds and assert there’s no banner; advance to 14 minutes and assert it appears. One subtlety: the check only runs every 30 seconds, so the banner can appear up to 30 seconds late. I’d ask whether that’s acceptable. If the requirement is “at 14 minutes”, the test should drive the clock in 30-second steps and accept the first check at or after 14:00, and the spec should say so. For the reset, I’d advance to 14:30 (banner showing), record activity, advance another 14 minutes and confirm the user is still signed in. Finally, I’d avoid runAll() here. With only a self-rescheduling interval queued it says nothing useful about elapsed time (Node fires it once, Jest aborts after 100,000 timers), whereas explicit tick calls make the 14- and 15-minute marks visible in the test.

More in Software Testing

read ✓Software Testing · hard

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 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