Testing with node:test

Intermediate60 min

Write and run unit tests with Node's built-in test runner, using describe and it, node:assert/strict, hooks, async tests, skipping, filtering, watch mode and coverage reports.

What you will learn
  • Write a test file with test, describe and it, and run it with node --test
  • Choose the right assertion from node:assert/strict and read a failure report
  • Use beforeEach to give every test a fresh starting state
  • Test asynchronous code and errors with async tests, assert.rejects and assert.throws
  • Skip, filter and re-run tests, and read a coverage report to find untested code
On this page

A test is a small program that calls your code and checks the answer. You could do that by hand, but you would stop after a few times. A test does it every time, in a second, and shouts when something that used to work does not, so you can change code without fear. What is worth testing is in the testing mindset lesson; this one is about the JavaScript tool.

Node’s own test runner, the module node:test, has been stable since Node 20 and needs no installation. Vitest and Jest look similar, so they will feel familiar afterwards.

Your first test

Start with the code to be tested, an ordinary module that exports a function.

nt_slug.mjs
export function slugify(text) {
return text
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
}

A test file imports the code and the runner. test(name, fn) registers a test. Inside it, assertions check values: an assertion does nothing when it holds and throws when it does not. A test passes if its function finishes without throwing. The assertions come from node:assert/strict.

Save this as nt_slug.test.mjs and run it with node --test nt_slug.test.mjs. The runner starts the file, collects the results and prints this report:

nt_slug.test.mjs
import { test } from "node:test";
import assert from "node:assert/strict";
import { slugify } from "./nt_slug.mjs";
test("lowercases and joins words with hyphens", () => {
assert.equal(slugify("Hello World"), "hello-world");
});
test("drops punctuation and trims", () => {
assert.equal(slugify(" Node.js: a tour! "), "node-js-a-tour");
});
test("an empty string stays empty", () => {
assert.equal(slugify(""), "");
});
Output
✔ lowercases and joins words with hyphens
✔ drops punctuation and trims
✔ an empty string stays empty
ℹ tests 3
ℹ suites 0
ℹ pass 3
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0

Each line is a test, and the check mark means it passed. The real report also shows durations; the listings leave them out so that they are stable. If any test fails, the runner exits with a non-zero code, which is how CI systems know.

The report format depends on your Node. Listings here use the spec reporter. From Node 23, node --test prints spec whether or not the output goes to a terminal. On Node 22 a terminal gets spec, but a pipe (| tee, CI logs) gets TAP, a different plain-text format, so add --test-reporter=spec to compare. --test-reporter=dot prints a compact line of dots.

Assertions

node:assert/strict is the module to import: the strict mode of the older node:assert, where every comparison is strict. The loose assert.equal(1, "1") passes, because it uses ==; the strict module makes equal mean ===.

Assertion Passes when
assert.equal(a, b) Object.is(a, b): like ===, except NaN equals NaN
assert.notEqual(a, b) the values differ
assert.deepEqual(a, b) objects and arrays have the same contents, compared strictly
assert.ok(value, message) the value is truthy
assert.throws(fn, expected) calling fn throws an error that matches expected
assert.rejects(promise, expected) the promise rejects with a matching error

A failed assertion throws an AssertionError that says what was expected and what was received. This program prints the first line of three messages, and shows the two modules disagreeing.

nt_assert.mjs
import assert from "node:assert/strict";
import loose from "node:assert";
function firstLine(fn) {
try {
fn();
} catch (error) {
return error.message.split("\n")[0];
}
}
console.log(firstLine(() => assert.equal(1 + 1, 3)));
console.log(firstLine(() => assert.deepEqual({ a: 1 }, { a: "1" })));
console.log(firstLine(() => assert.ok(0, "zero is falsy")));
loose.equal("1", 1);
console.log("the loose module accepts '1' == 1");
assert.throws(() => assert.equal("1", 1), { code: "ERR_ASSERTION" });
console.log("the strict module does not");
Output
Expected values to be strictly equal:
Expected values to be strictly deep-equal:
zero is falsy
the loose module accepts '1' == 1
the strict module does not

assert.throws takes the function, not its result, because the call must happen inside the assertion. Its second argument can be an error class, a regular expression for the message, or an object whose properties must match.

Grouping tests with describe and it

describe(name, fn) groups tests, and it(name, fn) is another name for test that reads like a sentence: “describe a Cart; it starts empty”. Groups nest. A good name states the behaviour: “rejects negative prices” says more than “test add 3”.

Tests must not depend on each other, so each starts from a known state. The hooks beforeEach, afterEach, before and after run set-up and clean-up code around a group. The class under test:

nt_cart.mjs
export class Cart {
#items = [];
add(name, price, quantity = 1) {
if (price < 0) throw new RangeError("price must not be negative");
this.#items.push({ name, price, quantity });
return this;
}
get count() {
return this.#items.reduce((sum, item) => sum + item.quantity, 0);
}
total() {
return this.#items.reduce((sum, i) => sum + i.price * i.quantity, 0);
}
}

beforeEach creates a new cart before every test, so a cart filled by one test never leaks into the next.

nt_cart.test.mjs
import { describe, it, beforeEach } from "node:test";
import assert from "node:assert/strict";
import { Cart } from "./nt_cart.mjs";
describe("Cart", () => {
let cart;
beforeEach(() => {
cart = new Cart();
});
it("starts empty", () => {
assert.equal(cart.count, 0);
assert.equal(cart.total(), 0);
});

The tests of add form a group of their own, nested in the first.

nt_cart.test.mjs (continued)
describe("add", () => {
it("counts quantities", () => {
cart.add("pen", 2, 3).add("ink", 5);
assert.equal(cart.count, 4);
});
it("sums price times quantity", () => {
cart.add("pen", 2, 3).add("ink", 5);
assert.equal(cart.total(), 11);
});
it("rejects negative prices", () => {
assert.throws(() => cart.add("pen", -1), {
name: "RangeError",
message: "price must not be negative",
});
});
});
});
Output
▶ Cart
✔ starts empty
▶ add
✔ counts quantities
✔ sums price times quantity
✔ rejects negative prices
✔ add
✔ Cart
ℹ tests 4
ℹ suites 2
ℹ pass 4
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0

The report mirrors the structure. Beware floating point: 0.1 + 0.2 is not exactly 0.3, so test money in whole cents or compare with a tolerance.

Async tests

A test function may be async, or return a promise; the runner waits for it, and the test fails if the promise rejects. For code that should reject, use assert.rejects and await it.

nt_lookup.mjs
const users = new Map([[1, "Ada"], [2, "Alan"]]);
export async function findUser(id) {
await new Promise((resolve) => setTimeout(resolve, 5));
if (!users.has(id)) throw new Error(`no user ${id}`);
return { id, name: users.get(id) };
}

The tests check both outcomes.

nt_lookup.test.mjs
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { findUser } from "./nt_lookup.mjs";
describe("findUser", () => {
it("resolves with the user", async () => {
assert.deepEqual(await findUser(1), { id: 1, name: "Ada" });
});
it("rejects for an unknown id", async () => {
await assert.rejects(findUser(9), { message: "no user 9" });
});
});
Output
▶ findUser
✔ resolves with the user
✔ rejects for an unknown id
✔ findUser
ℹ tests 2
ℹ suites 1
ℹ pass 2
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0

Do not forget the await. If an async test does not wait for assert.rejects(...), the test finishes before the check does. An un-awaited assertion is the classic way to write a test that always passes.

What a failure looks like

The file below tests slugify again, with two expectations that are wrong on purpose.

nt_fail.test.mjs
import { test } from "node:test";
import assert from "node:assert/strict";
import { slugify } from "./nt_slug.mjs";
test("collapses repeated separators", () => {
assert.equal(slugify("a -- b"), "a--b");
});
test("splits into words", () => {
assert.deepEqual(slugify("v2 beta").split("-"), ["v2", "bata"]);
});
test("keeps digits", () => {
assert.equal(slugify("Route 66"), "route-66");
});
Output
✖ collapses repeated separators
✖ splits into words
✔ keeps digits
ℹ tests 3
ℹ suites 0
ℹ pass 1
ℹ fail 2
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0

Below the summary, the real runner prints a failing tests section with the details. It is cut from the listings because it holds paths and stack traces that depend on your machine. Without those, it looks like this:

✖ failing tests:
test at nt_fail.test.mjs:5:1
✖ collapses repeated separators
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
'a-b' !== 'a--b'
test at nt_fail.test.mjs:9:1
✖ splits into words
AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal:
+ actual - expected
[
'v2',
+ 'beta'
- 'bata'
]

The actual value is left of !== and the expected one right. For objects and arrays, a diff shows + for what was received and - for what was expected. Read the first failure first.

Skipping, planning and filtering

A broken or unwritten test is better marked than deleted, because the report keeps reminding you. it.skip(name, fn), or the option { skip: true } (or a string giving the reason), does not run it. it.todo(name) records a planned test.

nt_skip.test.mjs
import { describe, it } from "node:test";
import assert from "node:assert/strict";
describe("flags", () => {
it("runs normally", () => {
assert.ok(true);
});
it.skip("is skipped", () => {
assert.fail("never runs");
});
it("is skipped with a reason", { skip: "needs a database" }, () => {
assert.fail("never runs");
});
it.todo("is planned");
});
Output
▶ flags
✔ runs normally
﹣ is skipped # SKIP
﹣ is skipped with a reason # needs a database
✔ is planned # TODO
✔ flags
ℹ tests 4
ℹ suites 1
ℹ pass 1
ℹ fail 0
ℹ cancelled 0
ℹ skipped 2
ℹ todo 1

To run only some tests while you work, use the filters:

Terminal window
node --test --test-name-pattern="Cart" # only names matching this regex
node --test --test-skip-pattern="slow" # everything except matching names
node --test --test-only # only { only: true } / it.only tests
node --test --watch # re-run when files change

--watch re-runs the affected tests on every save. Remove only markers before you commit.

node --test with no file names searches the current directory (not node_modules) for files named *.test.js, *.test.mjs or *.test.cjs (also -test and _test endings) or test-*, a file called test.js, and any script inside a folder called test. slug.spec.mjs is not found. Each test file runs in its own process.

Requires Node.js 26 or later Node 26.2 added test tags (a tags option and --experimental-test-tag-filter). They are in early development (stability 1), so prefer names and --test-name-pattern for anything that must work on every Node version.

How much did the tests cover?

A coverage report says which lines and branches ran while the tests ran. Switch it on with --experimental-test-coverage. The flag name still says experimental, and the options that narrow the report, --test-coverage-include and --test-coverage-exclude, need Node 22.5. This function has three outcomes, and its test covers two.

nt_grade.mjs
export function grade(score) {
if (score < 0 || score > 100) {
throw new RangeError("score out of range");
}
if (score >= 50) return "pass";
return "fail";
}

The test checks the two ordinary results.

nt_grade.test.mjs
import { test } from "node:test";
import assert from "node:assert/strict";
import { grade } from "./nt_grade.mjs";
test("passes at 50 and fails below", () => {
assert.equal(grade(50), "pass");
assert.equal(grade(49), "fail");
});

The test results print first; this is the coverage table that follows them.

Terminal window
$ node --test --experimental-test-coverage nt_grade.test.mjs
ℹ start of coverage report
ℹ -------------------------------------------------------------
ℹ file | line % | branch % | funcs % | uncovered lines
ℹ -------------------------------------------------------------
ℹ nt_grade.mjs | 71.43 | 75.00 | 100.00 | 3-4
ℹ -------------------------------------------------------------
ℹ all files | 71.43 | 75.00 | 100.00 |
ℹ -------------------------------------------------------------
ℹ end of coverage report

The columns give the percentage of lines, branches (the two sides of an if, ?: or ??) and functions that ran, then the lines that never ran: 3 and 4, the throw, because no test sent a bad score. Node 22 also lists the test file as a row.

Treat the report with care:

  • It shows what was not tested, never that the tested parts are right. A test with no assertions gives 100 per cent.
  • A target such as “90 per cent” breeds tests written to please the number. Use the report to find forgotten cases.
  • /* node:coverage ignore next */ skips a line, and /* node:coverage disable */ and /* node:coverage enable */ fence a region.

Common pitfalls

  • Tests that depend on each other or on order. Give every test its own state with beforeEach.
  • A missing await in an async test, so that the check runs after the test has ended.
  • Importing node:assert instead of node:assert/strict, which silently allows 1 == "1".
  • Passing the result to assert.throws. Pass a function: assert.throws(() => f(x)).
  • Testing the implementation. Check behaviour that callers can see; a test that breaks on every refactor protects nothing.
  • Real clocks, networks and random numbers in tests. The next lesson replaces them.
  • Leaving only in the code. The rest of the suite then does not run.
  • Comparing floating-point results with equal.

Exercises

ExerciseCatch every bug

This function is supposed to return the median of an array of numbers: the middle value when the numbers are sorted, or the mean of the two middle values when there is an even number of them. It has several bugs. Save it as nt_ex_median.mjs.

nt_ex_median.mjs
export function median(numbers) {
const sorted = numbers.sort();
const middle = Math.floor(sorted.length / 2);
return sorted[middle];
}

Write a test file with exactly these five tests, in this order, in a describe("median", ...) group: middle of an odd count, mean of the middle two for an even count, sorts numbers, not text, does not change its input, and throws for an empty array (it should throw a RangeError). Run it against the function above. Every test that checks a bug must fail, and only the first one passes.

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

Terminal window
node --test nt_ex_median.test.mjs

Expected output:

▶ median
✔ middle of an odd count
✖ mean of the middle two for an even count
✖ sorts numbers, not text
✖ does not change its input
✖ throws for an empty array
✖ median
ℹ tests 5
ℹ suites 1
ℹ pass 1
ℹ fail 4
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
Show solution

The default sort() compares as text and sorts in place, and the function ignores even lengths and empty arrays. Each test targets one of these. [10, 9, 2] sorts to [10, 2, 9] as text, so the middle value would be 2 instead of 9.

nt_ex_median.test.mjs
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { median } from "./nt_ex_median.mjs";
describe("median", () => {
it("middle of an odd count", () => {
assert.equal(median([3, 1, 2]), 2);
});
it("mean of the middle two for an even count", () => {
assert.equal(median([1, 2, 3, 4]), 2.5);
});
it("sorts numbers, not text", () => {
assert.equal(median([10, 9, 2]), 9);
});
it("does not change its input", () => {
const input = [3, 1, 2];
median(input);
assert.deepEqual(input, [3, 1, 2]);
});
it("throws for an empty array", () => {
assert.throws(() => median([]), RangeError);
});
});
Output
▶ median
✔ middle of an odd count
✖ mean of the middle two for an even count
✖ sorts numbers, not text
✖ does not change its input
✖ throws for an empty array
✖ median
ℹ tests 5
ℹ suites 1
ℹ pass 1
ℹ fail 4
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ExerciseTest an async function

Here is an async function that doubles a number after a short wait and rejects with a TypeError for anything that is not a number.

nt_ex_double.mjs
export async function doubleLater(value) {
await new Promise((resolve) => setTimeout(resolve, 2));
if (typeof value !== "number") throw new TypeError("number expected");
return value * 2;
}

Write a describe("doubleLater", ...) with two tests: doubles a number (the result for 21 is 42) and rejects other types (the promise rejects with a TypeError whose message is number expected, for the input "21"). Both must pass.

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

Terminal window
node --test nt_ex_double.test.mjs

Expected output:

▶ doubleLater
✔ doubles a number
✔ rejects other types
✔ doubleLater
ℹ tests 2
ℹ suites 1
ℹ pass 2
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
Show solution

Both tests are async, and the second one awaits assert.rejects. Remove the await and the test would still pass, whatever the function did.

nt_ex_double.test.mjs
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { doubleLater } from "./nt_ex_double.mjs";
describe("doubleLater", () => {
it("doubles a number", async () => {
assert.equal(await doubleLater(21), 42);
});
it("rejects other types", async () => {
await assert.rejects(doubleLater("21"), {
name: "TypeError",
message: "number expected",
});
});
});
Output
▶ doubleLater
✔ doubles a number
✔ rejects other types
✔ doubleLater
ℹ tests 2
ℹ suites 1
ℹ pass 2
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ExerciseA table of cases

When the same check applies to many inputs, write the cases as data and make one test for each. Subtests are created with t.test inside a test, where t is the test context that the runner passes as the argument. Test this function, which turns a number into the words Fizz, Buzz, FizzBuzz or the number as text:

nt_ex_fizz.mjs
export function fizzbuzz(n) {
if (n % 15 === 0) return "FizzBuzz";
if (n % 3 === 0) return "Fizz";
if (n % 5 === 0) return "Buzz";
return String(n);
}

Create one subtest per case in [[1, "1"], [3, "Fizz"], [5, "Buzz"], [15, "FizzBuzz"]], named fizzbuzz(1) and so on, inside a test called fizzbuzz.

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

Terminal window
node --test nt_ex_fizz.test.mjs

Expected output:

▶ fizzbuzz
✔ fizzbuzz(1)
✔ fizzbuzz(3)
✔ fizzbuzz(5)
✔ fizzbuzz(15)
✔ fizzbuzz
ℹ tests 5
ℹ suites 0
ℹ pass 5
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
Show solution

The loop awaits each subtest, so the subtests run one after another. The parent test counts as one test, and each subtest as another, which is why the total is 5.

nt_ex_fizz.test.mjs
import { test } from "node:test";
import assert from "node:assert/strict";
import { fizzbuzz } from "./nt_ex_fizz.mjs";
test("fizzbuzz", async (t) => {
const cases = [[1, "1"], [3, "Fizz"], [5, "Buzz"], [15, "FizzBuzz"]];
for (const [input, expected] of cases) {
await t.test(`fizzbuzz(${input})`, () => {
assert.equal(fizzbuzz(input), expected);
});
}
});
Output
▶ fizzbuzz
✔ fizzbuzz(1)
✔ fizzbuzz(3)
✔ fizzbuzz(5)
✔ fizzbuzz(15)
✔ fizzbuzz
ℹ tests 5
ℹ suites 0
ℹ pass 5
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0

Summary

  • node:test is Node’s built-in runner, stable since Node 20. A test is a function that passes if it does not throw. Run files with node --test.
  • Import assertions from node:assert/strict. Use equal, deepEqual, ok, throws and rejects; read the diff in a failure message.
  • describe and it group and name tests, and beforeEach gives every test fresh state.
  • Async tests can await; always await assert.rejects.
  • skip, todo, --test-name-pattern, --test-only and --watch control what runs. The runner finds *.test.*, test-*, *-test, *_test files and files in test folders.
  • --experimental-test-coverage prints which lines and branches ran; read it for forgotten cases, not as a score. Requires Node.js 26 or later Tags select tests by label, in early development.

Next: replacing clocks, random numbers and network calls with controlled stand-ins, and snapshot tests.