AdvancedJavaScript · Lesson 4 of 9

Testing JavaScript

Unit tests with Node's built-in test runner: assertions, table tests, async code, mocks and coverage.

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.

package.jsonJSON
{
  "name": "grades",
  "type": "module",
  "private": true,
  "scripts": {
    "test": "node --test",
    "test:watch": "node --test --watch",
    "coverage": "node --test --experimental-test-coverage"
  }
}
grades.jsJavaScript
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));
}
grades.test.jsJavaScript
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 --test finds and runs *.test.js files 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.method and mock.timers to 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.

search.jsJavaScript
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();
}
search.test.jsJavaScript
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/);
  });
});

Check your understanding

  1. Which command runs every *.test.js file with Node's built-in runner?

  2. How do you check that an async function rejects?

  3. Why pass fetchJson into loadClassAverage instead of calling fetch directly?

  4. What does mock.timers.tick(300) do?

Ask AI