Testing all 2 written
Proving your code does what you claim, with the test runner that ships in Node.
41 Testing with node:test, run for real
Every panel in this course ends with check() lines, and the build
refuses to publish a page if one fails. That is testing: code that runs your code and says whether it
did what you claimed. Node has a test runner built in, node --test, and an assertion
library, node:assert, so you can start with nothing installed.
A first test
maths.mjs
export function add(a, b) {
return a + b;
}
export function average(numbers) {
if (numbers.length === 0) throw new RangeError("average of an empty list");
return numbers.reduce((sum, n) => sum + n, 0) / numbers.length;
}
maths.test.mjs
import { test, describe, it } from "node:test";
import assert from "node:assert/strict";
import { add, average } from "./maths.mjs";
test("add puts two numbers together", () => {
assert.equal(add(2, 3), 5);
});
describe("average", () => {
it("averages a list", () => {
assert.equal(average([1, 2, 3, 4]), 2.5);
});
it("refuses an empty list", () => {
assert.throws(() => average([]), RangeError);
});
});
The terminal, as the build ran it
node --test
✔ add puts two numbers together (1.034627ms)
▶ average
✔ averages a list (0.260253ms)
✔ refuses an empty list (0.490571ms)
✔ average (1.909148ms)
ℹ tests 3
ℹ suites 1
ℹ pass 3
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 131.359052
A test is a function passed to test() with a description. Inside it, an
assertion such as assert.equal(actual, expected) throws if the two differ, and
a test passes if it finishes without throwing. describe and it are the same
thing with a grouping, for when several tests are about one function. Import from
node:assert/strict, always: the plain node:assert keeps an older, loose mode
in which 1 equals "1".
With no arguments, node --test finds test files by name — anything ending
.test.mjs (or .test.js, -test.mjs, _test.mjs), and
everything inside a folder called test — and runs each file in its own process, so one
file cannot leak state into another.
A test that finds a bug
price.mjs
export function formatPrice(pence) {
return `£${Math.floor(pence / 100)}.${pence % 100}`;
}
price.fixed.mjs
export function formatPrice(pence) {
return `£${Math.floor(pence / 100)}.${String(pence % 100).padStart(2, "0")}`;
}
price.test.mjs
import { test } from "node:test";
import assert from "node:assert/strict";
import { formatPrice } from "./price.mjs";
test("formats pounds and pence", () => {
assert.equal(formatPrice(1250), "£12.50");
});
test("formats whole pounds", () => {
assert.equal(formatPrice(1200), "£12.00");
});
test("formats single-digit pence", () => {
assert.equal(formatPrice(1205), "£12.05");
});
The terminal, as the build ran it
node --test # exits 1 ✔ formats pounds and pence (1.070887ms) ✖ formats whole pounds (1.132251ms) ✖ formats single-digit pence (0.238389ms) ℹ tests 3 ℹ suites 0 ℹ pass 1 ℹ fail 2 ℹ cancelled 0 ℹ skipped 0 ℹ todo 0 ℹ duration_ms 131.355274 ✖ failing tests: test at price.test.mjs:9:1 ✖ formats whole pounds (1.132251ms) AssertionError [ERR_ASSERTION]: Expected values to be strictly equal: '£12.0' !== '£12.00' at TestContext.<anonymous> (file:///app/price.test.mjs:10:10) at Test.runInAsyncScope (node:async_hooks:226:14) at Test.run (node:internal/test_runner/test:1397:25) at Test.processPendingSubtests (node:internal/test_runner/test:969:18) at Test.postRun (node:internal/test_runner/test:1537:19) at Test.run (node:internal/test_runner/test:1462:12) at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) { generatedMessage: true, code: 'ERR_ASSERTION', actual: '£12.0', expected: '£12.00', operator: 'strictEqual', diff: 'simple' } test at price.test.mjs:13:1 ✖ formats single-digit pence (0.238389ms) AssertionError [ERR_ASSERTION]: Expected values to be strictly equal: '£12.5' !== '£12.05' at TestContext.<anonymous> (file:///app/price.test.mjs:14:10) at Test.runInAsyncScope (node:async_hooks:226:14) at Test.run (node:internal/test_runner/test:1397:25) at Test.processPendingSubtests (node:internal/test_runner/test:969:18) at Test.postRun (node:internal/test_runner/test:1537:19) at Test.run (node:internal/test_runner/test:1462:12) at async Test.processPendingSubtests (node:internal/test_runner/test:969:7) { generatedMessage: true, code: 'ERR_ASSERTION', actual: '£12.5', expected: '£12.05', operator: 'strictEqual', diff: 'simple' } cp price.fixed.mjs price.mjs node --test ✔ formats pounds and pence (1.065026ms) ✔ formats whole pounds (0.167103ms) ✔ formats single-digit pence (0.164569ms) ℹ tests 3 ℹ suites 0 ℹ pass 3 ℹ fail 0 ℹ cancelled 0 ℹ skipped 0 ℹ todo 0 ℹ duration_ms 127.258587
formatPrice looked right, and it was right for the first example anyone would try.
The other two tests found that 1205 % 100 is 5, not "05". A
failure report gives you three things: which test failed, what the value was against what was
expected ('£12.0' !== '£12.00' here; for objects, a line-by-line diff marked
+ actual and - expected, as the next panel shows), and the file and line of
the assertion. The run also exits 1, and
that exit code is what lets a script, a git hook or a CI server refuse to continue. The fixed version
pads the pence to two digits, and the same three tests pass.
Notice which examples were worth writing. The first test only confirmed what the author already believed. The useful ones are at the edges: zero, one digit, empty lists, the largest value, the wrong type.
The assertions you will use
example.mjs
import assert from "node:assert/strict";
const outcome = {};
function attempt(label, work) {
try {
work();
outcome[label] = "passed";
console.log(`${label}: passed`);
} catch (err) {
outcome[label] = err.name;
console.log(`${label}: ${err.name}`);
console.log(" " + err.message.split("\n").join("\n "));
}
}
attempt("equal, numbers", () => assert.equal(0.1 + 0.2, 0.3));
attempt("equal, objects", () => assert.equal({ id: 1 }, { id: 1 }));
attempt("deepEqual, objects", () => assert.deepEqual({ id: 1, tags: ["a"] }, { id: 1, tags: ["a"] }));
attempt("deepEqual, 1 and \"1\"", () => assert.deepEqual({ id: 1 }, { id: "1" }));
attempt("throws, by class", () => assert.throws(() => JSON.parse("{"), SyntaxError));
attempt("throws, by shape", () => assert.throws(() => { throw new RangeError("empty list"); },
{ name: "RangeError", message: /empty/ }));
attempt("match", () => assert.match("user-42", /^user-\d+$/));
check("0.1 + 0.2 is not 0.3, as lesson 5 said", outcome["equal, numbers"], "AssertionError");
check("equal compares objects by identity", outcome["equal, objects"], "AssertionError");
check("deepEqual compares them by content", outcome["deepEqual, objects"], "passed");
check("and, being strict, tells 1 from \"1\"", outcome["deepEqual, 1 and \"1\""], "AssertionError");
check("throws, matched by class and by shape", [outcome["throws, by class"], outcome["throws, by shape"]], ["passed", "passed"]);
What node printed, when the build ran it
node example.mjs
equal, numbers: AssertionError
Expected values to be strictly equal:
+ actual - expected
+ 0.30000000000000004
- 0.3
^
equal, objects: AssertionError
Values have same structure but are not reference-equal:
{
id: 1
}
deepEqual, objects: passed
deepEqual, 1 and "1": AssertionError
Expected values to be strictly deep-equal:
+ actual - expected
{
+ id: 1
- id: '1'
}
throws, by class: passed
throws, by shape: passed
match: passed
ok 0.1 + 0.2 is not 0.3, as lesson 5 said
ok equal compares objects by identity
ok deepEqual compares them by content
ok and, being strict, tells 1 from "1"
ok throws, matched by class and by shape
equal is ===, so two objects with the same contents are not equal, because
they are two objects (lesson 14). deepEqual compares contents, all the way down, and in
strict mode the types too. throws takes a function — not the result of calling one, which
would throw before assert could catch it — and checks the error by class, or by a shape
of name and message. The failure messages are exactly what the test runner prints when a test
fails.
Async tests, and the missing await
users.mjs
export async function loadUser(id) {
if (id < 0) throw new RangeError(`no user can have id ${id}`);
return { id, name: "Ada" };
}
forgot.test.mjs
import { test } from "node:test";
import assert from "node:assert/strict";
import { loadUser } from "./users.mjs";
// Wrong: loadUser(0) does not reject. This test should fail.
test("rejects an id of 0, without await", () => {
assert.rejects(loadUser(0), RangeError);
});
awaited.test.mjs
import { test } from "node:test";
import assert from "node:assert/strict";
import { loadUser } from "./users.mjs";
// The same wrong test, awaited.
test("rejects an id of 0, with await", async () => {
await assert.rejects(loadUser(0), RangeError);
});
The terminal, as the build ran it
node --test # exits 1
✖ rejects an id of 0, with await (1.807318ms)
✔ rejects an id of 0, without await (1.595734ms)
ℹ Error: Test "rejects an id of 0, without await" at forgot.test.mjs:6:1 generated asynchronous activity after the test ended. This activity created the error "AssertionError [ERR_ASSERTION]: Missing expected rejection (RangeError)." and would have caused the test to fail, but instead triggered an unhandledRejection event.
✖ forgot.test.mjs (108.375676ms)
ℹ tests 3
ℹ suites 0
ℹ pass 1
ℹ fail 2
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 128.357843
✖ failing tests:
test at awaited.test.mjs:6:1
✖ rejects an id of 0, with await (1.807318ms)
AssertionError [ERR_ASSERTION]: Missing expected rejection (RangeError).
at async TestContext.<anonymous> (file:///app/awaited.test.mjs:7:3)
at async Test.run (node:internal/test_runner/test:1404:7)
at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: undefined,
operator: 'rejects',
diff: 'simple'
}
test at forgot.test.mjs:1:1
✖ forgot.test.mjs (108.375676ms)
'test failed'
Both tests are wrong, and only one is reported as a failing test. assert.rejects
returns a promise, and without await the test function returns straight away, having
thrown nothing, so the runner marks it ✔. The assertion only failed afterwards, when the
test was already over. Node noticed, reported "asynchronous activity after the test ended" and failed
the file, so the run still exits 1 — but the report points at the wrong place, and other
test runners are not always so careful. The awaited version fails where it should, with a clear
message. Make a test async and await every promise in it.
Testing a server
The real prize is testing a whole program from the outside, the way a client uses it. These tests
start lesson 37's to-do API — the same app.mjs, byte for byte — and send it real HTTP
requests.
app.mjs
import { createServer } from "node:http";
export class HttpError extends Error {
constructor(status, message) {
super(message);
this.status = status;
}
}
// ---- JSON in and out ------------------------------------------------------
async function readJson(req, limit = 10_000) {
if (!req.headers["content-type"]?.startsWith("application/json")) {
throw new HttpError(415, "send JSON, with Content-Type: application/json");
}
let size = 0;
const chunks = [];
for await (const chunk of req) {
size += chunk.length;
if (size > limit) throw new HttpError(413, `the body is over ${limit} bytes`);
chunks.push(chunk);
}
try {
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
} catch {
throw new HttpError(400, "the body is not valid JSON");
}
}
function send(res, status, body, headers = {}) {
if (body === undefined) {
res.writeHead(status, headers);
return res.end();
}
const text = JSON.stringify(body);
res.writeHead(status, {
"content-type": "application/json; charset=utf-8",
"content-length": Buffer.byteLength(text),
...headers,
});
res.end(text);
}
// ---- validation -------------------------------------------------------------
function checkTodo(input, { partial = false } = {}) {
if (typeof input !== "object" || input === null || Array.isArray(input)) {
throw new HttpError(400, "send a JSON object");
}
const unknown = Object.keys(input).filter((key) => key !== "title" && key !== "done");
if (unknown.length > 0) throw new HttpError(400, `unknown field: ${unknown.join(", ")}`);
if (!partial || "title" in input) {
if (typeof input.title !== "string" || input.title.trim() === "") {
throw new HttpError(400, "title must be a non-empty string");
}
}
if ("done" in input && typeof input.done !== "boolean") {
throw new HttpError(400, "done must be true or false");
}
return input;
}
// ---- the app ------------------------------------------------------------------
export function createApp() {
const todos = new Map(); // the data lives here, one store per app
let nextId = 1;
const routes = [];
const route = (method, pathname, handler) =>
routes.push({ method, pattern: new URLPattern({ pathname }), handler });
const find = (id) => {
const todo = todos.get(Number(id));
if (!todo) throw new HttpError(404, `no todo with id ${id}`);
return todo;
};
route("GET", "/todos", ({ url }) => {
let list = [...todos.values()];
const done = url.searchParams.get("done");
if (done !== null) list = list.filter((todo) => String(todo.done) === done);
return { status: 200, body: list };
});
route("POST", "/todos", async ({ req }) => {
const input = checkTodo(await readJson(req));
const todo = { id: nextId++, title: input.title.trim(), done: input.done ?? false };
todos.set(todo.id, todo);
return { status: 201, body: todo, headers: { location: `/todos/${todo.id}` } };
});
route("GET", "/todos/:id(\\d+)", ({ params }) => ({ status: 200, body: find(params.id) }));
route("PATCH", "/todos/:id(\\d+)", async ({ req, params }) => {
const todo = find(params.id);
const changes = checkTodo(await readJson(req), { partial: true });
if ("title" in changes) todo.title = changes.title.trim();
if ("done" in changes) todo.done = changes.done;
return { status: 200, body: todo };
});
route("DELETE", "/todos/:id(\\d+)", ({ params }) => {
find(params.id);
todos.delete(Number(params.id));
return { status: 204 };
});
return createServer(async (req, res) => {
try {
const url = new URL(req.url, "http://localhost");
const matches = routes.filter((r) => r.pattern.test({ pathname: url.pathname }));
if (matches.length === 0) throw new HttpError(404, `nothing at ${url.pathname}`);
const match = matches.find((r) => r.method === req.method);
if (!match) {
res.setHeader("allow", matches.map((r) => r.method).join(", "));
throw new HttpError(405, `${req.method} is not allowed here`);
}
const params = match.pattern.exec({ pathname: url.pathname }).pathname.groups;
const { status, body, headers } = await match.handler({ req, url, params });
send(res, status, body, headers);
} catch (err) {
if (err instanceof HttpError) return send(res, err.status, { error: err.message });
console.error(err);
send(res, 500, { error: "internal error" });
}
});
}
test/helpers.mjs
import { once } from "node:events";
import { createApp } from "../app.mjs";
// A new, empty app on a free port.
export async function startApp() {
const server = createApp().listen(0, "127.0.0.1");
await once(server, "listening");
return { server, base: `http://127.0.0.1:${server.address().port}` };
}
test/app.test.mjs
import { describe, it, beforeEach, afterEach } from "node:test";
import assert from "node:assert/strict";
import { startApp } from "./helpers.mjs";
describe("the todo API", () => {
let app;
beforeEach(async () => { app = await startApp(); }); // fresh for every test
afterEach(() => app.server.close());
const post = (body) => fetch(`${app.base}/todos`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
it("starts empty", async () => {
const res = await fetch(`${app.base}/todos`);
assert.equal(res.status, 200);
assert.deepEqual(await res.json(), []);
});
it("creates a todo and says where it is", async () => {
const res = await post({ title: "Buy tea" });
assert.equal(res.status, 201);
assert.equal(res.headers.get("location"), "/todos/1");
assert.deepEqual(await res.json(), { id: 1, title: "Buy tea", done: false });
});
it("refuses fields it does not know", async () => {
const res = await post({ title: "Pay me", owner: "admin" });
assert.equal(res.status, 400);
assert.match((await res.json()).error, /unknown field: owner/);
});
it("answers 405, with an allow header, for the wrong method", async () => {
const res = await fetch(`${app.base}/todos`, { method: "PUT" });
assert.equal(res.status, 405);
assert.equal(res.headers.get("allow"), "GET, POST");
});
it("forgets a deleted todo", async () => {
await post({ title: "Temporary" });
assert.equal((await fetch(`${app.base}/todos/1`, { method: "DELETE" })).status, 204);
assert.equal((await fetch(`${app.base}/todos/1`)).status, 404);
});
});
package.json
{
"name": "todo-api",
"version": "1.0.0",
"type": "module",
"scripts": {
"test": "node --test \"test/**/*.test.mjs\""
}
}
The terminal, as the build ran it
node --test ▶ the todo API ✔ starts empty (44.016146ms) ✔ creates a todo and says where it is (13.919845ms) ✔ refuses fields it does not know (4.839871ms) ✔ answers 405, with an allow header, for the wrong method (4.813279ms) ✔ forgets a deleted todo (6.486712ms) ✔ the todo API (75.882532ms) ✔ test/helpers.mjs (118.239574ms) ℹ tests 6 ℹ suites 1 ℹ pass 6 ℹ fail 0 ℹ cancelled 0 ℹ skipped 0 ℹ todo 0 ℹ duration_ms 241.562284 node --test --test-name-pattern="refuses|405" ▶ the todo API ✔ refuses fields it does not know (46.705544ms) ✔ answers 405, with an allow header, for the wrong method (9.774239ms) ✔ the todo API (58.158943ms) ✔ test/helpers.mjs (118.989256ms) ℹ tests 3 ℹ suites 1 ℹ pass 3 ℹ fail 0 ℹ cancelled 0 ℹ skipped 0 ℹ todo 0 ℹ duration_ms 245.925645 npm test > todo-api@1.0.0 test > node --test "test/**/*.test.mjs" ▶ the todo API ✔ starts empty (43.919089ms) ✔ creates a todo and says where it is (13.824957ms) ✔ refuses fields it does not know (4.996562ms) ✔ answers 405, with an allow header, for the wrong method (4.917873ms) ✔ forgets a deleted todo (5.959329ms) ✔ the todo API (75.429673ms) ℹ tests 5 ℹ suites 1 ℹ pass 5 ℹ fail 0 ℹ cancelled 0 ℹ skipped 0 ℹ todo 0 ℹ duration_ms 252.918174
beforeEach runs before every test in its describe, and here it starts a
brand-new app on a free port, so every test begins with an empty store. That is why "creates a todo"
can expect id 1 whichever tests ran before it. Tests that share state pass or fail depending on the
order they happen to run in, which is the most common reason for a test suite that fails "randomly";
--test-randomize shuffles the order to flush such tests out. afterEach closes
the server again, or the test process would never exit, for the reason lesson 36 showed.
Look at the first run's report again: it lists test/helpers.mjs as a passing test.
Every file inside test/ counts as a test file, helpers included; this one did nothing
when run on its own, so it passed, but a helper that did work at import time would really run. The
test script in package.json names a pattern instead — quoted, so that Node
expands it rather than the shell — and npm test ran only the real test file.
--test-name-pattern runs just the tests whose names match, which is what you want while
working on one of them.
While you work. node --test --watch reruns the tests every time you
save a file, and test.only(...) with --test-only runs just the tests you
have marked. Take the .only out before committing.
node --testfinds*.test.mjsfiles and everything intest/, and runs each file in its own process.- Use
node:assert/strict.equalfor values,deepEqualfor objects and arrays,throwsandrejectsfor errors. - A failure shows actual against expected and a line number, and makes the run exit 1.
awaitevery async assertion, in anasynctest.- Give each test fresh state with
beforeEach, and clean up withafterEach. - Test the edges: zero, empty, one digit, the wrong type.
42 Mocks, fake timers and coverage
Some code is hard to test as it stands. It sends emails, calls another company's API, or waits thirty minutes before doing anything. A test cannot send real emails, should not depend on someone else's server being up, and cannot wait half an hour. Mocks stand in for the awkward parts, fake timers let a test move the clock, and coverage shows which lines your tests never ran at all.
A function that records its calls
signup.mjs
export async function signUp(email, { users, sendEmail }) {
if (!email.includes("@")) throw new Error(`not an email address: ${email}`);
if (users.has(email)) return { created: false };
users.add(email);
await sendEmail(email, "Welcome!", "Thanks for signing up.");
return { created: true };
}
signup.test.mjs
import { test, mock } from "node:test";
import assert from "node:assert/strict";
import { signUp } from "./signup.mjs";
test("a new user gets one welcome email", async () => {
const sendEmail = mock.fn(async () => {}); // records every call, sends nothing
await signUp("ada@example.com", { users: new Set(), sendEmail });
assert.equal(sendEmail.mock.callCount(), 1);
assert.deepEqual(sendEmail.mock.calls[0].arguments,
["ada@example.com", "Welcome!", "Thanks for signing up."]);
});
test("signing up twice does not email twice", async () => {
const sendEmail = mock.fn(async () => {});
const users = new Set(["ada@example.com"]);
const result = await signUp("ada@example.com", { users, sendEmail });
assert.deepEqual(result, { created: false });
assert.equal(sendEmail.mock.callCount(), 0);
});
test("a bad address never reaches the mailer", async () => {
const sendEmail = mock.fn(async () => {});
await assert.rejects(signUp("not-an-email", { users: new Set(), sendEmail }), /not an email/);
assert.equal(sendEmail.mock.callCount(), 0);
});
The terminal, as the build ran it
node --test
✔ a new user gets one welcome email (2.258838ms)
✔ signing up twice does not email twice (0.258807ms)
✔ a bad address never reaches the mailer (0.641636ms)
ℹ tests 3
ℹ suites 0
ℹ pass 3
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 130.675144
signUp does not import a mailer; it is handed one, as sendEmail. That
small decision is what makes it testable. The tests pass in mock.fn(), a function that
does nothing except remember how it was called, and then ask it: were you called, how many times,
with what? The second and third tests are the ones that matter in practice. They prove that an email
is not sent twice to the same person, and not sent for an address that failed
validation — things you could never check by watching a real inbox.
Passing dependencies in, instead of reaching for them from inside, is called dependency injection. It sounds grander than it is: a parameter. In a real program the caller passes the real mailer; in a test, a mock.
Replacing fetch
weather.mjs
export async function temperature(city, { baseUrl }) {
const url = `${baseUrl}/forecast?city=${encodeURIComponent(city)}`;
const res = await fetch(url, { signal: AbortSignal.timeout(2000) });
if (!res.ok) throw new Error(`the weather service answered ${res.status}`);
const data = await res.json();
return Math.round(data.current.celsius);
}
weather.test.mjs
import { test } from "node:test";
import assert from "node:assert/strict";
import { temperature } from "./weather.mjs";
const baseUrl = "http://localhost:8080"; // never contacted: fetch is replaced
test("rounds the temperature", async (t) => {
const fetch = t.mock.method(globalThis, "fetch",
async () => Response.json({ current: { celsius: 18.6 } }));
assert.equal(await temperature("Paris", { baseUrl }), 19);
assert.equal(fetch.mock.callCount(), 1);
});
test("encodes the city name in the URL", async (t) => {
const fetch = t.mock.method(globalThis, "fetch",
async () => Response.json({ current: { celsius: 25 } }));
await temperature("São Paulo", { baseUrl });
assert.equal(fetch.mock.calls[0].arguments[0], `${baseUrl}/forecast?city=S%C3%A3o%20Paulo`);
});
test("reports a service that is down", async (t) => {
t.mock.method(globalThis, "fetch", async () => new Response("busy", { status: 503 }));
await assert.rejects(temperature("Paris", { baseUrl }), /answered 503/);
});
The terminal, as the build ran it
node --test
✔ rounds the temperature (31.375765ms)
✔ encodes the city name in the URL (0.529298ms)
✔ reports a service that is down (0.919595ms)
ℹ tests 3
ℹ suites 0
ℹ pass 3
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 158.225821
Sometimes the dependency is a global you cannot pass in, like fetch.
t.mock.method(object, name, replacement) swaps the method out for the length of one test
and puts the real one back when the test ends, so no test can leak its fake into the next. The fake
returns a real Response, so temperature runs exactly the code it would run
against the real service — status check, JSON parsing, rounding — and nothing is sent anywhere. That
also makes the unhappy paths easy: a 503 is one line, where with a real service you would have to wait
for it to break.
The trade-off is that a mock only knows what you told it. If the real service renames
celsius to temp_c, every one of these tests still passes. Mock the edges of
your program that you do not control, keep a few tests that run against the real thing (as lesson 41's
server tests did against a real server), and do not mock your own code just to reach a line.
Moving the clock
time.mjs
export const SESSION_LENGTH = 30 * 60 * 1000; // thirty minutes
export function createSessions() {
const sessions = new Map();
return {
start(user) {
const id = crypto.randomUUID();
sessions.set(id, { user, expires: Date.now() + SESSION_LENGTH });
return id;
},
user(id) {
const session = sessions.get(id);
if (!session || Date.now() >= session.expires) return null;
return session.user;
},
};
}
// Call fn only once calls have stopped for `ms` -- a search box, say.
export function debounce(fn, ms) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), ms);
};
}
time.test.mjs
import { test, mock } from "node:test";
import assert from "node:assert/strict";
import { createSessions, debounce } from "./time.mjs";
test("a session lasts thirty minutes, and not a second more", (t) => {
t.mock.timers.enable({ apis: ["Date"], now: new Date("2026-09-30T09:00:00Z") });
const sessions = createSessions();
const id = sessions.start("ada");
t.mock.timers.tick(29 * 60 * 1000);
assert.equal(sessions.user(id), "ada");
t.mock.timers.tick(60 * 1000);
assert.equal(new Date().toISOString(), "2026-09-30T09:30:00.000Z");
assert.equal(sessions.user(id), null);
});
test("debounce waits for the typing to stop", (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
const search = mock.fn();
const onType = debounce(search, 300);
onType("t");
onType("te");
onType("tea");
t.mock.timers.tick(299);
assert.equal(search.mock.callCount(), 0);
t.mock.timers.tick(1);
assert.equal(search.mock.callCount(), 1);
assert.deepEqual(search.mock.calls[0].arguments, ["tea"]);
});
The terminal, as the build ran it
node --test --test-timeout=2000
✔ a session lasts thirty minutes, and not a second more (6.955325ms)
✔ debounce waits for the typing to stop (1.60171ms)
ℹ tests 2
ℹ suites 0
ℹ pass 2
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 136.642547
t.mock.timers.enable() replaces the clock for one test. From then on,
Date.now() and setTimeout only move when the test calls
tick(ms), and they move instantly: the first test lived through thirty minutes, and the
second through 300 ms of typing, in the few milliseconds the report shows. The run used
--test-timeout=2000, which fails any test that takes longer than two seconds, so its
passing is the proof that nothing really waited.
Fake time also makes the edges exact. The session test checks the last minute precisely — valid at 29 minutes, gone at 30 — and the debounce test checks that 299 ms is not enough and 300 ms is. With real timers you would sleep "a bit longer than 300 ms" and hope the machine was not busy, which is the classic flaky test.
Coverage: what the tests never ran
src/price.mjs
export function priceWithTax(pence, { country }) {
if (!Number.isInteger(pence) || pence < 0) {
throw new RangeError("price must be a whole number of pence");
}
if (country === "GB") return Math.round(pence * 1.2);
if (country === "DE") return Math.round(pence * 1.19);
if (country === "US") {
return pence;
}
throw new Error(`no tax rule for ${country}`);
}
test/price.test.mjs
import { test } from "node:test";
import assert from "node:assert/strict";
import { priceWithTax } from "../src/price.mjs";
test("adds UK VAT", () => {
assert.equal(priceWithTax(1000, { country: "GB" }), 1200);
});
test("refuses a fractional price", () => {
assert.throws(() => priceWithTax(9.99, { country: "GB" }), RangeError);
});
The terminal, as the build ran it
node --test --experimental-test-coverage ✔ adds UK VAT (2.276016ms) ✔ refuses a fractional price (0.578617ms) ℹ tests 2 ℹ suites 0 ℹ pass 2 ℹ fail 0 ℹ cancelled 0 ℹ skipped 0 ℹ todo 0 ℹ duration_ms 143.234993 ℹ start of coverage report ℹ ----------------------------------------------------------- ℹ file | line % | branch % | funcs % | uncovered lines ℹ ----------------------------------------------------------- ℹ src | | | | ℹ price.mjs | 54.55 | 80.00 | 100.00 | 6-10 ℹ ----------------------------------------------------------- ℹ all files | 54.55 | 80.00 | 100.00 | ℹ ----------------------------------------------------------- ℹ end of coverage report node --test --experimental-test-coverage --test-coverage-lines=90 # exits 1 ✔ adds UK VAT (2.306322ms) ✔ refuses a fractional price (0.571087ms) ℹ tests 2 ℹ suites 0 ℹ pass 2 ℹ fail 0 ℹ cancelled 0 ℹ skipped 0 ℹ todo 0 ℹ duration_ms 145.324219 ℹ Error: 54.55% line coverage does not meet threshold of 90%. ℹ start of coverage report ℹ ----------------------------------------------------------- ℹ file | line % | branch % | funcs % | uncovered lines ℹ ----------------------------------------------------------- ℹ src | | | | ℹ price.mjs | 54.55 | 80.00 | 100.00 | 6-10 ℹ ----------------------------------------------------------- ℹ all files | 54.55 | 80.00 | 100.00 | ℹ ----------------------------------------------------------- ℹ end of coverage report
With coverage on, Node records which lines of your code ran during the tests. Both tests passed,
and the report shows that they only ever ran just over half of price.mjs: lines 6 to
10, the German and American rules and the unknown-country error, never ran at all. A bug in any of
them would ship with a green test run. --test-coverage-lines=90 turns that into a
failure — the second run exits 1 — which is how a project stops coverage quietly falling.
Read the number the right way round. Low coverage proves that code is untested. High coverage does not prove it is tested well: a line can run without any assertion checking what it did. Use the report to find the gaps, not as a score. Coverage is still marked experimental in this version of Node, hence the flag's name.
Two more. t.assert.snapshot(value) saves a value to a file the first
time (run with --test-update-snapshots) and fails if it ever changes, which suits large
outputs such as a rendered page. And mock.module() replaces a whole imported module,
but in this version only behind --experimental-test-module-mocks. Passing the
dependency in, as signUp did, needs no flag at all.
mock.fn()records its calls:callCount()andcalls[i].arguments.- Pass dependencies in as parameters, and a test can pass a mock.
t.mock.method(globalThis, "fetch", ...)replaces a method for one test and restores it afterwards.t.mock.timers.enable()andtick(ms)move time instantly and exactly.--experimental-test-coverageshows lines no test ran;--test-coverage-linesenforces a minimum.- Mock what you do not control. Coverage finds gaps; it does not measure quality.