Mocks, spies and fake timers

Intermediate60 min

Test code that depends on time, randomness and the network by replacing them with controlled stand-ins: mock.fn spies, mock.method, MockTimers for setTimeout and Date, and snapshot tests.

What you will learn
  • Record and inspect calls with mock.fn, and script its results
  • Replace a method or a global such as Math.random and fetch for the length of one test
  • Control setTimeout, setInterval and Date with mock timers instead of waiting
  • Write a snapshot test, create and update its snapshot file, and judge when a snapshot is the right tool
  • Recognise the limits of mocking, including the imports that mock timers cannot reach
On this page

The tests of the previous lesson called pure functions. Real code reads the clock, rolls dice, calls a server, waits two seconds before retrying. A test that depends on those is slow (it really waits), flaky (it passes today and fails tomorrow) or impossible (it needs the internet). The remedy is a controlled stand-in, a test double:

  • a spy does its job but records how it was called;
  • a stub returns a fixed answer;
  • a fake is a simplified working replacement, such as a clock you control.

node:test has all three in the object mock. Every example is a test file, run with node --test as in the previous lesson; a piped run on Node 22 prints TAP unless you add --test-reporter=spec.

Spies with mock.fn

mock.fn(implementation) returns a function that calls the implementation and records calls on its .mock property: callCount(), and calls with the arguments, result or error, and this of each call. mockImplementationOnce(fn, onCall) changes what one call does, counted from 0.

This function retries a task up to some number of attempts, and throws the last error if all fail.

mt_retry.mjs
export async function retry(task, attempts) {
let lastError;
for (let attempt = 0; attempt < attempts; attempt++) {
try {
return await task();
} catch (error) {
lastError = error;
}
}
throw lastError;
}

We need a task that fails twice and then works, and a way to count calls. A scripted spy does both: it returns "ok" by default, and calls 0 and 1 are scripted to throw.

mt_retry.test.mjs
import { describe, it, mock } from "node:test";
import assert from "node:assert/strict";
import { retry } from "./mt_retry.mjs";
describe("retry", () => {
it("tries again after a failure", async () => {
const task = mock.fn(() => "ok");
task.mock.mockImplementationOnce(() => { throw new Error("first"); }, 0);
task.mock.mockImplementationOnce(() => { throw new Error("second"); }, 1);
assert.equal(await retry(task, 5), "ok");
assert.equal(task.mock.callCount(), 3);
});

The second test makes every attempt fail and looks at the last recorded call.

mt_retry.test.mjs (continued)
it("throws the last error when every attempt fails", async () => {
const task = mock.fn(() => { throw new Error("always"); });
await assert.rejects(retry(task, 2), { message: "always" });
assert.equal(task.mock.callCount(), 2);
assert.equal(task.mock.calls[1].error.message, "always");
});
});
Output
▶ retry
✔ tries again after a failure
✔ throws the last error when every attempt fails
✔ retry
ℹ tests 2
ℹ suites 1
ℹ pass 2
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0

Counting calls checks what the return value cannot show: that the function stopped after the first success and never exceeded the allowed attempts.

Replacing a method or a global

mock.method(object, "name", implementation) swaps a method for a spy and restores the original later. It works on globals too, because they are properties of globalThis. With t.mock (the tracker of the test context t) everything is restored automatically when that test ends. Prefer it; with the imported mock you must call mock.restoreAll() yourself, for example in afterEach, or one test’s mock leaks into the next.

First, randomness. This function picks a random item:

mt_pick.mjs
export function pickRandom(items) {
return items[Math.floor(Math.random() * items.length)];
}

Math.random() returns a number from 0 up to but not including 1, so Math.floor(Math.random() * n) is a whole number from 0 to n - 1. If the test decides which number comes back, the result is known in advance.

mt_pick.test.mjs
import { it } from "node:test";
import assert from "node:assert/strict";
import { pickRandom } from "./mt_pick.mjs";
const items = ["red", "green", "blue"];
it("picks the first item for a random value near 0", (t) => {
t.mock.method(Math, "random", () => 0);
assert.equal(pickRandom(items), "red");
});
it("picks the last item for a random value near 1", (t) => {
t.mock.method(Math, "random", () => 0.99);
assert.equal(pickRandom(items), "blue");
});
it("has the real Math.random back afterwards", () => {
assert.equal(Math.random.mock, undefined);
});
Output
✔ picks the first item for a random value near 0
✔ picks the last item for a random value near 1
✔ has the real Math.random back afterwards
ℹ tests 3
ℹ suites 0
ℹ pass 3
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0

The third test shows the automatic restore: the real Math.random has no .mock property, so the earlier mocks are gone.

Math.random suits dice and shuffling, never secrets: use crypto.randomUUID() or crypto.getRandomValues() for ids and tokens. Better still, take the random source as a parameter with the real one as default, and the test passes () => 0.5 without a mock.

mt_random.mjs
const pick = (items, random = Math.random) =>
items[Math.floor(random() * items.length)];
console.log(pick(["a", "b", "c"], () => 0));
console.log(pick(["a", "b", "c"], () => 0.99));
const bytes = crypto.getRandomValues(new Uint8Array(4));
console.log(crypto.randomUUID().length, bytes.length);
Output
a
c
36 4

The same tools replace the network. This function loads a user from a web API with fetch:

mt_api.mjs
export async function getUser(id) {
const response = await fetch(`https://api.example.test/users/${id}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
}

The test replaces the global fetch with a stub that returns a ready-made Response, then checks the result and the address requested. Nothing leaves the machine.

mt_api.test.mjs
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { getUser } from "./mt_api.mjs";
describe("getUser", () => {
it("returns the parsed JSON body", async (t) => {
const reply = () => new Response(JSON.stringify({ name: "Ada" }));
const fetchMock = t.mock.method(globalThis, "fetch", async () => reply());
assert.deepEqual(await getUser(7), { name: "Ada" });
assert.equal(fetchMock.mock.callCount(), 1);
const [address] = fetchMock.mock.calls[0].arguments;
assert.equal(address, "https://api.example.test/users/7");
});

The second test asks for something a real server makes hard: a 404 on demand.

mt_api.test.mjs (continued)
it("throws on an HTTP error", async (t) => {
const notFound = async () => new Response("", { status: 404 });
t.mock.method(globalThis, "fetch", notFound);
await assert.rejects(getUser(8), { message: "HTTP 404" });
});
});
Output
▶ getUser
✔ returns the parsed JSON body
✔ throws on an HTTP error
✔ getUser
ℹ tests 2
ℹ suites 1
ℹ pass 2
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0

Mocking makes rare situations (errors, timeouts, odd data) easy to test. But a test full of mocks proves only that your code calls the mocks as expected: replace the edges (clock, network, disk) and keep the logic real.

Fake timers

Code that waits is the hardest to test: a retry with a 30-second back-off would make a test take 30 seconds. (Timer behaviour is in the event loop lesson.) Mock timers replace setTimeout, setInterval and Date with versions that move only when you say so: tick(ms) advances the clock and runs everything that falls due.

Switch them on per test with t.mock.timers.enable({ apis: [...] }), naming "setTimeout", "setInterval", "setImmediate" and/or "Date". They are stable from Node 23.1; Node 22 runs them the same way but prints an experimental warning.

The debounce function from the higher-order functions lesson waits until calls stop for a given time:

mt_debounce.mjs
export function debounce(fn, delay) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}

With fake timers the test takes microseconds and can check the exact moment: 99 ms is too early, 100 ms is just right.

mt_debounce.test.mjs
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { debounce } from "./mt_debounce.mjs";
describe("debounce", () => {
it("runs once, with the latest arguments, after the quiet period", (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
const spy = t.mock.fn();
const save = debounce(spy, 100);
save("a");
t.mock.timers.tick(50);
save("b");
t.mock.timers.tick(99);
assert.equal(spy.mock.callCount(), 0);

One more millisecond completes the quiet period.

mt_debounce.test.mjs (continued)
t.mock.timers.tick(1);
assert.equal(spy.mock.callCount(), 1);
assert.deepEqual(spy.mock.calls[0].arguments, ["b"]);
});
});
Output
▶ debounce
✔ runs once, with the latest arguments, after the quiet period
✔ debounce
ℹ tests 1
ℹ suites 1
ℹ pass 1
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0

"a" was cancelled by the second call, which restarted the countdown: 149 ms have passed since the first call but only 99 since the second.

Date is controlled the same way, and now sets where the clock starts. A cache that forgets entries after a time-to-live depends on the current time:

mt_cache.mjs
export class TtlCache {
#store = new Map();
constructor(ttlMs) {
this.ttlMs = ttlMs;
}
set(key, value) {
this.#store.set(key, { value, expires: Date.now() + this.ttlMs });
}

The get method deletes an expired entry and reports it as missing.

mt_cache.mjs (continued)
get(key) {
const entry = this.#store.get(key);
if (!entry) return undefined;
if (Date.now() >= entry.expires) {
this.#store.delete(key);
return undefined;
}
return entry.value;
}
}

The test sets the clock to 0, stores a value, and moves time forward in two steps.

mt_cache.test.mjs
import { it } from "node:test";
import assert from "node:assert/strict";
import { TtlCache } from "./mt_cache.mjs";
it("forgets an entry when its time-to-live has passed", (t) => {
t.mock.timers.enable({ apis: ["Date"], now: 0 });
const cache = new TtlCache(1000);
cache.set("a", 1);
t.mock.timers.tick(999);
assert.equal(cache.get("a"), 1);
t.mock.timers.tick(1);
assert.equal(cache.get("a"), undefined);
});
Output
✔ forgets an entry when its time-to-live has passed
ℹ tests 1
ℹ suites 0
ℹ pass 1
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0

Promises and fake timers

When a timer fires and resolves a promise, the code after the await runs in a microtask, which waits until your test function gives up control. If you tick and assert at once, that code has not run. Let the microtasks run first by awaiting something; setImmediate, which is not mocked here, is a convenient place to wait.

mt_poll.mjs
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export async function waitFor(check, interval, tries) {
for (let attempt = 0; attempt < tries; attempt++) {
if (check()) return attempt;
await wait(interval);
}
throw new Error("gave up");
}

The test ticks the fake clock and flushes microtasks between steps.

mt_poll.test.mjs
import { it } from "node:test";
import assert from "node:assert/strict";
import { waitFor } from "./mt_poll.mjs";
const flush = () => new Promise((resolve) => setImmediate(resolve));
it("polls on a timer until the check passes", async (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
let ready = false;
const result = waitFor(() => ready, 1000, 5);
await flush();
t.mock.timers.tick(1000);
await flush();
ready = true;
t.mock.timers.tick(1000);
assert.equal(await result, 2);
});
Output
✔ polls on a timer until the check passes
ℹ tests 1
ℹ suites 0
ℹ pass 1
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0

The first check happens at once, the second after one fake second, the third after two; then ready is true and waitFor returns 2. No real second passed.

Two limits. A function imported by name still points to the original, so import { setTimeout as sleep } from "node:timers/promises" in the code under test takes real time; use the global setTimeout or a default import. And while tick runs due callbacks, Date.now() already shows the end of the tick (observed on Node 22, 24 and 26), so tick in smaller steps if a callback must see the exact moment.

Snapshot tests

Some results are too big to write by hand. A snapshot test records the result once in a file next to the test, and later fails if it differs.

t.assert.snapshot(value) compares a value with its stored snapshot. The first time, or whenever you accept new output, run the tests with --test-update-snapshots to write the file:

Terminal window
node --test --test-update-snapshots mt_report.test.mjs

The snapshot goes into mt_report.test.mjs.snapshot, which you commit with the code. Snapshot testing needs Node 22.3 or later and is stable from 22.13 and 23.4. The function to test turns orders into a small text report:

mt_report.mjs
export function renderReport(orders) {
const row = (o) => `${o.id.padEnd(4)} ${String(o.total).padStart(6)}`;
const lines = orders.map(row);
const sum = orders.reduce((total, o) => total + o.total, 0);
return ["ID TOTAL", ...lines, `SUM ${String(sum).padStart(7)}`].join("\n");
}

The test takes a snapshot of the report for two lists of orders.

mt_report.test.mjs
import { it } from "node:test";
import { renderReport } from "./mt_report.mjs";
const orders = [
{ id: "a1", total: 120 },
{ id: "b22", total: 5 },
];
it("renders the report", (t) => {
t.assert.snapshot(renderReport(orders));
});
it("renders an empty report", (t) => {
t.assert.snapshot(renderReport([]));
});
Output
✔ renders the report
✔ renders an empty report
ℹ tests 2
ℹ suites 0
ℹ pass 2
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0

After the first run with the flag, the snapshot file holds one entry per assertion, named after the test and a counter. These are its real contents:

exports[`renders an empty report 1`] = `
"ID TOTAL\\nSUM 0"
`;
exports[`renders the report 1`] = `
"ID TOTAL\\na1 120\\nb22 5\\nSUM 125"
`;

A string is stored as one line with line breaks written as \\n; objects and arrays are stored as indented JSON, which reads better.

If the report format changes, the test fails and shows the difference. If the change is wanted, run with --test-update-snapshots again and read the diff of the .snapshot file before committing; if not, you found a bug. Snapshots suit large, stable output, not timestamps, random ids or paths, and a 200-line snapshot nobody reads tests nothing. For a small result, write the expected value with assert.equal.

Common pitfalls

  • Forgetting to restore a mock made with the imported mock. Use t.mock, or mock.restoreAll() in afterEach.
  • Mocking too much. A test where everything is replaced checks nothing about how the parts work together.
  • Asserting right after tick in async code. Let the microtasks run first.
  • Expecting timers imported by name to be mocked, or enabling too few. If the code calls setInterval and you enabled only "setTimeout", the test waits for real.
  • Snapshots of unstable values such as dates and random ids, or accepting a new snapshot without reading it.
  • Mocks that drift from the real thing. Keep a few tests against the real service.

Exercises

ExerciseA countdown on a fake clock

This function calls onTick(remaining) once a second and onDone() when the count reaches zero. It returns a function that stops the countdown.

mt_ex_countdown.mjs
export function countdown(seconds, onTick, onDone) {
let remaining = seconds;
const timer = setInterval(() => {
remaining--;
if (remaining > 0) {
onTick(remaining);
} else {
clearInterval(timer);
onDone();
}
}, 1000);
return () => clearInterval(timer);
}

Test it with mock timers and spies in one test called counts down once a second. For countdown(3, ...): after 1 second onTick was called with 2; after 2 seconds it has been called with 1; onDone has not run before the 3rd second and has run once after it.

Run it with this command, and compare with the expected report.

Terminal window
node --test mt_ex_countdown.test.mjs

Expected output:

✔ counts down once a second
ℹ tests 1
ℹ suites 0
ℹ pass 1
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
Show solution

setInterval must be among the enabled APIs. The spies record the calls, and ticks of 1000 ms move the clock one second at a time.

mt_ex_countdown.test.mjs
import { it } from "node:test";
import assert from "node:assert/strict";
import { countdown } from "./mt_ex_countdown.mjs";
it("counts down once a second", (t) => {
t.mock.timers.enable({ apis: ["setInterval"] });
const onTick = t.mock.fn();
const onDone = t.mock.fn();
countdown(3, onTick, onDone);
t.mock.timers.tick(1000);
assert.deepEqual(onTick.mock.calls[0].arguments, [2]);
t.mock.timers.tick(1000);
assert.deepEqual(onTick.mock.calls[1].arguments, [1]);
assert.equal(onDone.mock.callCount(), 0);
t.mock.timers.tick(1000);
assert.equal(onDone.mock.callCount(), 1);
assert.equal(onTick.mock.callCount(), 2);
});
Output
✔ counts down once a second
ℹ tests 1
ℹ suites 0
ℹ pass 1
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ExercisePredictable identifiers

This function makes an identifier from the current time and a random number:

mt_ex_id.mjs
export function makeId() {
return `${Date.now()}-${Math.floor(Math.random() * 1000)}`;
}

Write a test called combines the clock and a random number that mocks both: the clock starts at 5000 milliseconds and Math.random returns 0.25. Assert that makeId() is "5000-250", and that Math.random was called exactly once.

Run it with this command, and compare with the expected report.

Terminal window
node --test mt_ex_id.test.mjs

Expected output:

✔ combines the clock and a random number
ℹ tests 1
ℹ suites 0
ℹ pass 1
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
Show solution

Mock timers with apis: ["Date"] and now: 5000 fix the clock, t.mock.method fixes the random number, and the returned spy counts its calls.

mt_ex_id.test.mjs
import { it } from "node:test";
import assert from "node:assert/strict";
import { makeId } from "./mt_ex_id.mjs";
it("combines the clock and a random number", (t) => {
t.mock.timers.enable({ apis: ["Date"], now: 5000 });
const random = t.mock.method(Math, "random", () => 0.25);
assert.equal(makeId(), "5000-250");
assert.equal(random.mock.callCount(), 1);
});
Output
✔ combines the clock and a random number
ℹ tests 1
ℹ suites 0
ℹ pass 1
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ExerciseSnapshot of a CSV

Here is a function that writes rows as CSV text:

mt_ex_csv.mjs
export function toCsv(rows) {
const quote = (text) => `"${text.replaceAll('"', '""')}"`;
const cell = (v) => (/[",\n]/.test(v) ? quote(v) : String(v));
return rows.map((row) => row.map(cell).join(",")).join("\n");
}

Write a test called quotes cells that need it that takes a snapshot of toCsv([["name", "note"], ["Ada", "likes \"math\", logic"], ["Alan", "plain"]]). Run it once with --test-update-snapshots, then once without. Then open the .snapshot file and see how the text is stored.

Run it twice, as the two commands show.

Terminal window
node --test --test-update-snapshots mt_ex_csv.test.mjs
node --test mt_ex_csv.test.mjs

Expected output of the second run:

✔ quotes cells that need it
ℹ tests 1
ℹ suites 0
ℹ pass 1
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
Show solution

The first run creates the snapshot file. Open it: the CSV text is stored as a single escaped line, with every line break written as \\n and every quote escaped, which is hard to read. That is a fair warning about snapshots of multi-line strings: for a small result like this one, a plain assert.equal against a template literal would document the expected text better.

mt_ex_csv.test.mjs
import { it } from "node:test";
import { toCsv } from "./mt_ex_csv.mjs";
it("quotes cells that need it", (t) => {
const rows = [
["name", "note"],
["Ada", 'likes "math", logic'],
["Alan", "plain"],
];
t.assert.snapshot(toCsv(rows));
});
Output
✔ quotes cells that need it
ℹ tests 1
ℹ suites 0
ℹ pass 1
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0

Summary

  • A test double replaces something awkward (time, randomness, the network). mock.fn makes a spy; mock.method replaces a method or a global such as Math.random or fetch. Use t.mock so mocks are undone when the test ends.
  • Take random sources as parameters; use crypto for secrets.
  • t.mock.timers.enable({ apis }) and tick(ms) control timers and Date without waiting (stable from Node 23.1; a warning on 22). In async code, let microtasks run after each tick.
  • t.assert.snapshot stores output in <test file>.snapshot (Node 22.3 and later, stable from 22.13). Update with --test-update-snapshots, and read the diff.
  • Mock the edges, not the logic.

Next: automatic style checks, linters and formatters.