Emmanuel Akinfulubi

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, and a test file for it

ran at build time

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

A failing run, a fix, and a passing one

ran at build time

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

equal, deepEqual, throws and match

ran at build time

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

The same wrong test, with and without await

ran at build time

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.

The to-do API from lesson 37, tested over HTTP

ran at build time

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 --test finds *.test.mjs files and everything in test/, and runs each file in its own process.
  • Use node:assert/strict. equal for values, deepEqual for objects and arrays, throws and rejects for errors.
  • A failure shows actual against expected and a line number, and makes the run exit 1.
  • await every async assertion, in an async test.
  • Give each test fresh state with beforeEach, and clean up with afterEach.
  • 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

mock.fn in place of a mailer

ran at build time

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

mock.method on globalThis.fetch

ran at build time

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

mock.timers: thirty minutes in a few milliseconds

ran at build time

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

A coverage report, and a threshold

ran at build time

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() and calls[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() and tick(ms) move time instantly and exactly.
  • --experimental-test-coverage shows lines no test ran; --test-coverage-lines enforces a minimum.
  • Mock what you do not control. Coverage finds gaps; it does not measure quality.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.