Automated tests check that your code still does what it should after every change. Node (20 or newer; 22 LTS recommended) ships a complete test runner, so you need no packages: put tests in files named *.test.js, import describe/it from node:test and assert from node:assert/strict, and run node --test. Vitest and Jest use almost the same describe/it/expect style, so the skills carry over.
Good unit tests are small and specific: one behaviour per test, with a name that says what should happen. Test the boundaries (74 and 75, empty lists) and the error cases with assert.throws and assert.rejects. A table of input → expected output keeps many cases readable.
Code that talks to the network, the clock or storage is easier to test when the dependency is passed in as a parameter: a test can pass a fake. mock.fn() creates a fake that records its calls, mock.method(obj, "name") replaces a method (such as globalThis.fetch), and mock.timers controls setTimeout so a test never really waits. --experimental-test-coverage shows which lines your tests never ran.
{
"name": "grades",
"type": "module",
"private": true,
"scripts": {
"test": "node --test",
"test:watch": "node --test --watch",
"coverage": "node --test --experimental-test-coverage"
}
}export function grade(score) {
if (!Number.isFinite(score) || score < 0 || score > 100) throw new RangeError(`invalid score: ${score}`);
if (score >= 75) return "A";
if (score >= 65) return "B";
if (score >= 45) return "C";
if (score >= 30) return "D";
return "F";
}
export function average(scores) {
if (scores.length === 0) return null;
return Math.round((scores.reduce((a, b) => a + b, 0) / scores.length) * 10) / 10;
}
// Takes its dependency (fetchJson) as a parameter, so tests can pass a fake.
export async function loadClassAverage(fetchJson, formId) {
const students = await fetchJson(`/api/forms/${formId}/results`);
return average(students.map((s) => s.score));
}import { describe, it, mock } from "node:test";
import assert from "node:assert/strict";
import { average, grade, loadClassAverage } from "./grades.js";
describe("grade", () => {
it("uses the boundaries from the school scale", () => {
const cases = [[100, "A"], [75, "A"], [74, "B"], [65, "B"], [45, "C"], [30, "D"], [29, "F"], [0, "F"]];
for (const [score, expected] of cases) assert.equal(grade(score), expected, `score ${score}`);
});
it("rejects impossible scores", () => {
assert.throws(() => grade(101), RangeError);
assert.throws(() => grade(Number.NaN), /invalid score/);
});
});
describe("average", () => {
it("rounds to one decimal place", () => assert.equal(average([88, 79, 71]), 79.3));
it("returns null for an empty list", () => assert.equal(average([]), null));
});
describe("loadClassAverage", () => {
it("fetches the form's results and averages them", async () => {
const fakeFetch = mock.fn(async () => [{ score: 80 }, { score: 60 }]);
assert.equal(await loadClassAverage(fakeFetch, 4), 70);
assert.equal(fakeFetch.mock.callCount(), 1);
assert.deepEqual(fakeFetch.mock.calls[0].arguments, ["/api/forms/4/results"]);
});
it("passes errors through", async () => {
const failing = async () => { throw new Error("offline"); };
await assert.rejects(loadClassAverage(failing, 4), /offline/);
});
});Key points
node --testfinds and runs*.test.jsfiles with no extra packages.- Test boundaries and failures as well as the happy path, using
assert.throws/rejects. - Pass dependencies in, and use
mock.fn,mock.methodandmock.timersto keep tests fast and isolated.
Exercise
Write debounce(fn, waitMs) and searchStudents(query) (which fetches /api/students?q=... but skips queries shorter than 2 characters). Test the debounce with mock.timers without really waiting, and test the search by replacing globalThis.fetch with mock.method, including an error status.
Show solution
Try the exercise yourself first — then compare your approach with this one.
mock.timers.enable replaces setTimeout with a fake clock, and tick(299) moves it forward instantly, so the test proves that nothing fires before 300 ms and that only the last call runs. mock.method(globalThis, "fetch", ...) swaps in a fake fetch that returns a real Response, and mock.restoreAll() in afterEach puts the real one back.
export function debounce(fn, waitMs) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), waitMs);
};
}
export async function searchStudents(query) {
const q = query.trim();
if (q.length < 2) return [];
const res = await fetch(`/api/students?q=${encodeURIComponent(q)}`);
if (!res.ok) throw new Error(`search failed: ${res.status}`);
return res.json();
}import { afterEach, describe, it, mock } from "node:test";
import assert from "node:assert/strict";
import { debounce, searchStudents } from "./search.js";
afterEach(() => mock.restoreAll());
describe("debounce", () => {
it("calls once, with the last arguments, after the wait", () => {
mock.timers.enable({ apis: ["setTimeout"] });
const spy = mock.fn();
const debounced = debounce(spy, 300);
debounced("a");
debounced("am");
mock.timers.tick(299);
assert.equal(spy.mock.callCount(), 0); // still waiting
debounced("ami"); // restarts the wait
mock.timers.tick(300);
assert.equal(spy.mock.callCount(), 1);
assert.deepEqual(spy.mock.calls[0].arguments, ["ami"]);
mock.timers.reset();
});
});
describe("searchStudents", () => {
it("skips the request for short queries", async () => {
const fetchMock = mock.method(globalThis, "fetch", async () => assert.fail("should not fetch"));
assert.deepEqual(await searchStudents(" a "), []);
assert.equal(fetchMock.mock.callCount(), 0);
});
it("encodes the query and returns the JSON body", async () => {
const fetchMock = mock.method(globalThis, "fetch", async () => Response.json([{ name: "Amina Hassan" }]));
assert.deepEqual(await searchStudents("Amina H"), [{ name: "Amina Hassan" }]);
assert.equal(fetchMock.mock.calls[0].arguments[0], "/api/students?q=Amina%20H");
});
it("throws on an error status", async () => {
mock.method(globalThis, "fetch", async () => new Response("oops", { status: 500 }));
await assert.rejects(searchStudents("Juma"), /search failed: 500/);
});
});