The Test Runner
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Node.js 26 (26.10.0, released 2026-09-21, the Current line that becomes LTS on 2026-10-28), with the differences of Node.js 24
"Krypton" (24.21.0, Active LTS) and Node.js 22 "Jod" (22.23.3, Maintenance) called out; versions and stability levels were checked against the official
documentation on 2026-10-04, and every code example below was run on Node.js 26 and, wherever the text reports a difference or a result, on 24 and 22 as well. The test runner is the part of Node.js that
finds test files, runs them, and reports the results, with no dependency to install: you write functions with node:test, check values with
node:assert, and start everything with node --test. node:test has been Stability 2 (Stable) since v20.0.0 (it was added in v18.0.0 and v16.17.0), and
node:assert is Stable too. Several of the features around the edges are not: watch mode and code coverage are Stability 1 (Experimental), while test tags,
global setup and teardown, randomized order and module mocks are Stability 1.0 (Early development) and are labelled where they appear. Mocks, snapshots and
coverage have their own page, Mocking, Snapshots & Coverage; this page is the foundation it builds on.
The running scenario is the Bookshelf application that grows across this section, described in
Introduction & Architecture; tests for its HTTP API appear near the end, and the same API is built in
Building a REST API.
The stability and version map
The table lists what this page uses, with the stability index printed in the Node.js 26 documentation and the versions in which each feature arrived, so you can tell what an LTS line you are running has.
| Feature | Stability (Node.js 26 docs) | Version notes |
|---|---|---|
|
2 Stable |
Added in v18.0.0 and v16.17.0; marked stable in v20.0.0. |
|
2 Stable |
Strict assertion mode since v9.9.0; |
|
Stable |
Added in v23.4.0 and v22.13.0; stable since v24.0.0 and v22.17.0. |
|
Stable |
|
|
Stable |
Added in v23.7.0 and v22.14.0. |
|
Stable |
Shorthands in v20.2.0 and v18.17.0. |
|
Stable |
Added in v25.5.0 and v24.14.0. Not available on Node.js 22. |
|
Stable |
|
|
Stable |
|
|
Stable |
Added as |
|
Stable |
Added in v24.7.0. Not available on Node.js 22 ( |
|
Stable |
Added in v19.6.0 and v18.15.0; reporters exposed at |
|
Stable |
|
|
1 Experimental |
Added in v19.2.0 and v18.13.0. The general |
Code coverage ( |
1 Experimental |
Covered in Mocking, Snapshots & Coverage. |
Snapshot testing |
Stable |
Added in v22.3.0; no longer experimental since v23.4.0 and v22.13.0. |
|
1.0 Early development |
Added in v24.0.0. |
|
1.0 Early development |
Added in v26.1.0; backported to v24.16.0. Not available on Node.js 22. |
Test tags and |
1.0 Early development |
Added in v26.2.0 and v24.19.0. The Node.js 24 flag takes a single tag; the Node.js 26 flag takes a boolean expression. |
|
1.0 Early development |
Needs |
|
Stable |
|
How node --test works
Running node --test does not simply load your files. The process you start is the runner: it finds the test files, decides which of them this process should run, starts each file in
its own child Node.js process (the default, called process isolation), listens to what the children report, and feeds one stream of events to the reporters. A file does not have to use
node:test at all; the runner treats it as a script and counts exit code 0 as a pass. Inside a file that does use node:test, every test runs on the same single application thread,
whatever the concurrency option says, because concurrency there means interleaving asynchronous work on the event loop, not parallel threads (see
Event Loop for why).
Because every file is a separate process by default, a crash, a global variable, or an open handle in one file cannot affect another, and the files can run in parallel on several cores. The cost is
the start-up time of one Node.js process per file. The Node.js 26 documentation also says that child processes inherit Node.js options from the parent, including options from environment
variables such as NODE_OPTIONS and from configuration files, except for the options the runner manages itself (--test, the coverage and reporter flags, --watch, the randomization flags
and the tag filter). That is how a --import setup file reaches every test file.
Finding the test files
With no arguments, node --test searches the current directory tree (skipping node_modules) and runs every file whose path matches one of these patterns.
| Pattern | Matches | Does not match |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Unless you pass --no-strip-types, the same six patterns are also matched for cts, mts and ts, so a *.test.ts file runs directly through Node.js type stripping (see
TypeScript in Node.js). A file that merely matches a pattern is executed even if it never imports node:test; that is why a helper placed
under a test/ directory is run as a test file too. Put shared helpers outside test/ or give them a name that no pattern matches.
To pick files yourself, pass one or more glob patterns as the final arguments, in double quotes so that the shell does not expand them (the quotes also keep the command portable). In the following scratch project, which holds one file per pattern plus decoys, the commands show the difference.
node --test # the default patterns
node --test "src/**/*.spec.js" "lib/*.js" # only what the globs select
node --test "test/**/*.{js,mjs,cjs}" # a directory is not walked: use a glob
node --test run (output sorted)src/math-test.js src/math.test.js src/math_test.js src/test-math.js src/test.js
src/z.test.ts test/a.js test/c.cjs test/nested/b.mjs test/types.ts
(not run: src/helper.js, src/math.spec.js, lib/utils.js, node_modules/pkg/index.test.js)
Passing a directory name is a common mistake. In my runs (observed, not documented), node --test test ran zero tests and printed a summary of zero on Node.js 26, while on Node.js 24 and 22 the name was
treated as a file to execute and failed with MODULE_NOT_FOUND. Use a glob such as "test//*.js" instead. A related trap: node --test "test/" also matches the directories themselves, which
then fail. The command normally goes in package.json, where npm test runs it and any extra arguments go after --.
{
"type": "module",
"scripts": {
"test": "node --test",
"test:watch": "node --test --watch",
"test:ci": "node --test --test-reporter=spec --test-reporter=junit --test-reporter-destination=stdout --test-reporter-destination=junit.xml"
}
}
A failing test sets the process exit code to 1, so any CI system can tell success from failure without parsing output. Setup that every test file needs (registering a loader, adding a global polyfill) goes into a
module that you preload for the children with node --import ./test/setup.mjs --test, a pattern the Learn guide recommends keeping in small, purpose-specific setup files so that cheap unit tests do not pay for
expensive environment mocks.
Your first tests: test, describe and it
A test is one function. It passes when it returns normally (or its returned promise fulfills) and fails when it throws (or the promise rejects). A third style gives the function a callback as the
second argument: calling it with a truthy first argument fails the test, and a test that takes a callback and also returns a promise fails. describe() groups tests into a suite and it() is
the test function with the name you would expect there; describe is an alias of suite and it is an alias of test. The three styles are equivalent, so choose by taste and stay consistent in a codebase.
The first example tests the ISBN check that the Bookshelf uses to reject malformed books.
// Validates an ISBN-13: 13 digits (hyphens/spaces allowed) whose weighted sum is divisible by 10.
export function isValidIsbn13(input) {
if (typeof input !== 'string') throw new TypeError('ISBN must be a string');
const digits = input.replace(/[-\s]/g, '');
if (!/^\d{13}$/.test(digits)) return false;
const sum = [...digits].reduce((acc, d, i) => acc + Number(d) * (i % 2 === 0 ? 1 : 3), 0);
return sum % 10 === 0;
}
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { isValidIsbn13 } from '../src/isbn.js';
describe('isValidIsbn13', () => {
it('accepts a valid ISBN with hyphens', () => {
assert.equal(isValidIsbn13('978-0-306-40615-7'), true);
});
it('rejects a wrong check digit', () => {
assert.equal(isValidIsbn13('978-0-306-40615-8'), false);
});
it('throws a TypeError for non-strings', () => {
assert.throws(() => isValidIsbn13(42), { name: 'TypeError', message: 'ISBN must be a string' });
});
});
node --test test/isbn.test.js▶ isValidIsbn13
✔ accepts a valid ISBN with hyphens (0.31ms)
✔ rejects a wrong check digit (0.43ms)
✔ throws a TypeError for non-strings (0.26ms)
✔ isValidIsbn13 (1.41ms)
ℹ tests 3
ℹ suites 1
ℹ pass 3
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
The tests inside one describe() run one after another in the order they were declared, unless you ask for concurrency. The suite function itself should declare tests and hooks synchronously; the runner awaits the
tests created inside a suite for you.
Hooks: before, after, beforeEach and afterEach
Hooks are functions the runner calls around your tests. before() runs once before the first test of its suite and after() runs once after the last one; the Node.js documentation guarantees that after() runs
even when tests in the suite fail, which makes it the right place to close servers and delete temporary files. beforeEach() and afterEach() run around every test of the suite, including the tests of nested suites.
Hooks take the same options as tests (timeout, signal) and may be written with a callback. Declared at the top level of a file, outside any describe(), they apply to every test in that file.
import { describe, it, before, after, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
describe('hook order', () => {
const log = [];
before(() => log.push('before'));
after(() => {
log.push('after');
assert.deepStrictEqual(log, [
'before',
'beforeEach', 'test A', 'afterEach',
'beforeEach', 'test B', 'afterEach',
'after',
]);
});
beforeEach(() => log.push('beforeEach'));
afterEach(() => log.push('afterEach'));
it('A', () => log.push('test A'));
it('B', () => log.push('test B'));
});
The next file, also run on all three lines, pins down how hooks nest: suite hooks run once and outermost first, and the each-test hooks of every enclosing scope run before a test, outermost first.
import { describe, it, before, beforeEach, after, test } from 'node:test';
import assert from 'node:assert/strict';
const log = [];
beforeEach(() => log.push('root beforeEach')); // file level: applies to every test in the file
after(() => assert.deepStrictEqual(log, [
'root beforeEach', 'top-level test',
'outer before', 'inner before', // suite hooks run once, outermost first
'root beforeEach', 'outer beforeEach', // each-test hooks run outermost first
'inner test',
]));
test('top-level test', () => log.push('top-level test'));
describe('outer', () => {
before(() => log.push('outer before'));
beforeEach(() => log.push('outer beforeEach'));
describe('inner', () => {
before(() => log.push('inner before'));
it('inner test', () => log.push('inner test'));
});
});
Source: Node.js API: Hooks
A hook that throws fails the tests it guards: in an experiment where a beforeEach() threw, the test it guarded was reported as failed and never ran. Keep hooks small and deterministic, and keep their work proportional to what every
test needs; an expensive beforeEach() multiplies by the number of tests.
Subtests
The context object t that the runner passes to every test function has a t.test() method that creates a subtest. This builds hierarchies inside a test, shares setup between the subtests (the parent’s code runs once), and
lets you generate cases in a loop. The context also has its own hooks, t.before(), t.beforeEach(), t.after() and t.afterEach(), which apply to the subtests of that one test.
import { test } from 'node:test';
import assert from 'node:assert/strict';
test('parent', async (t) => {
const seen = [];
t.before(() => seen.push('parent before'));
t.beforeEach((sub) => seen.push(`beforeEach for ${sub.name}`));
t.after(() => assert.deepStrictEqual(seen, [
'parent before',
'beforeEach for first',
'beforeEach for second',
]));
await t.test('first', () => {});
await t.test('second', () => {});
});
Source: Node.js API: Subtests
The documentation says to await every subtest, because a parent does not wait for its subtests (unlike tests declared inside a suite) and cancels the ones still pending when it finishes; a failing subtest also fails its parent.
That is what happens on Node.js 22: in my run of a test that called t.test() without await, Node.js 22.23.3 reported the subtest as cancelled with the message test did not finish before its parent and was cancelled.
On Node.js 24.21 and 26.10 the same file passed, because newer lines wait for outstanding subtests (the Learn guide dates this change to v23.8.0). Do not depend on that: write await t.test(…) and the code works on every line.
import test from 'node:test';
import assert from 'node:assert/strict';
import { isValidIsbn13 } from '../src/isbn.js';
const cases = [
['978-0-306-40615-7', true],
['978 0 306 40615 7', true],
['9780306406158', false],
['not an isbn', false],
];
test('isValidIsbn13 table', async (t) => {
for (const [input, expected] of cases) {
// t.test, not test: the subtest belongs to this parent
await t.test(`isValidIsbn13(${JSON.stringify(input)})`, () => {
assert.equal(isValidIsbn13(input), expected);
});
}
});
Counting assertions: t.plan() and t.assert
t.plan(count) declares how many assertions and subtests the test must run; the test fails if the number differs. It exists for code where an assertion sits in a callback or event handler that might never fire, which would
otherwise leave the test green. Plans count only assertions made through t.assert, the object that exposes the functions of node:assert bound to the current test; the plain imported assert is invisible to the plan. The
wait option controls whether the check happens at once (the default, false), waits as long as needed (true), or waits up to a number of milliseconds.
t.assert callsimport test from 'node:test';
import { Readable } from 'node:stream';
test('every chunk is checked', (t, done) => {
const chunks = ['a', 'b', 'c'];
const expected = [...chunks];
t.plan(chunks.length); // the test fails unless exactly three t.assert calls happen
Readable.from(chunks)
.on('data', (chunk) => t.assert.strictEqual(chunk, expected.shift()))
.on('end', done);
});
test('a plan can wait for late assertions', (t) => {
t.plan(1, { wait: 1_000 }); // wait up to one second for the assertion
setTimeout(() => t.assert.ok(true), 50);
});
assert into a plan does not count (files named *.demo.js match no default pattern, so they run only when you name them)import test from 'node:test';
import assert from 'node:assert/strict';
test('plan ignores the imported assert', (t) => {
t.plan(1);
assert.ok(true); // not tracked: only t.assert.* counts
});
✖ plan ignores the imported assert (0.30ms)
'plan expected 1 assertions but received 0'
Asynchronous tests, timeouts and polling
An async function, a function that returns a promise, and a function that takes a callback are all valid. Each test can also be bounded: the timeout option fails the test after a number of milliseconds (the default is
Infinity), --test-timeout sets a default for all tests, and t.signal is an AbortSignal that aborts when the test is cancelled or times out, so pass it to anything cancellable. The documentation is explicit that a timeout is not a
reliable cancellation mechanism: a test that blocks the thread in a synchronous loop prevents the scheduled cancellation from running. For eventual conditions, t.waitFor() polls an assertion function until it stops
throwing (default interval 50 ms, default timeout 1000 ms) and returns its value; prefer it to a hand-written sleep() followed by an assertion.
t.signal and t.waitFor() (demo/timeouts.demo.js)import { test } from 'node:test';
import assert from 'node:assert/strict';
import { setTimeout as sleep } from 'node:timers/promises';
test('hangs past its timeout', { timeout: 100 }, async (t) => {
// t.signal aborts when the test times out, so the timer is cancelled too
await sleep(5_000, undefined, { signal: t.signal });
});
test('polls until an eventual condition holds', async (t) => {
let ready = false;
setTimeout(() => { ready = true; }, 120);
await t.waitFor(() => assert.equal(ready, true), { interval: 20, timeout: 1_000 });
});
✖ hangs past its timeout (101.54ms)
✔ polls until an eventual condition holds (127.40ms)
ℹ tests 2
ℹ pass 1
ℹ fail 0
ℹ cancelled 1
'test timed out after 100ms'
A timed-out test is counted as cancelled, not failed, in the summary, yet the run still ends with exit code 1. Work that outlives its test is another source of confusion: once a test function returns, the result is reported
at once, a subtest created afterwards fails, and an uncaughtException from a finished test is attributed to it and reported as a diagnostic. The documentation calls this extraneous asynchronous activity; the cure is to await
everything a test starts, and to close what it opens in t.after().
Assertions with node:assert/strict
The test runner has no expect(). Assertions come from node:assert, and the module should be imported in its strict form, node:assert/strict: in strict assertion mode the loosely named methods behave like their
strict counterparts (assert.equal() is assert.strictEqual(), assert.deepEqual() is assert.deepStrictEqual()), failure messages for objects include a colored diff, and the coercion surprises of legacy mode disappear. The legacy mode (import assert from 'node:assert')
compares with == in equal(), deepEqual(), notEqual() and notDeepEqual(), and the Node.js documentation marks those four Stability 3 (Legacy) and points to the strict names.
| Assertion | What it checks |
|---|---|
|
Primitives or identity, compared with |
|
Recursive equality of enumerable own properties, including the prototypes' identity, |
|
Only the properties present in |
|
Truthiness; a regular expression must match; a regular expression must not match. |
|
|
|
The asynchronous twins: always |
import { test } from 'node:test';
import assert from 'node:assert/strict'; // strict mode: equal() means strictEqual(), deepEqual() means deepStrictEqual()
import { Shelf } from '../src/shelf.js';
test('assertion tour', async () => {
assert.strictEqual(1 + 1, 2); // Object.is() comparison
assert.deepStrictEqual({ a: [1, { b: 2 }] }, { a: [1, { b: 2 }] });
assert.notDeepStrictEqual([1, 2], [2, 1]); // order matters in arrays
assert.partialDeepStrictEqual({ id: 7, title: 'Dune', tags: ['sf', 'classic'] }, { title: 'Dune', tags: ['sf'] });
assert.ok('non-empty strings are truthy');
assert.match('978-0-306-40615-7', /^\d{3}(-\d+){4}$/);
assert.doesNotMatch('Dune', /\d/); // passes when the string does NOT match
assert.throws(() => new Shelf().add(null), TypeError); // class, regexp, object or validation function
await assert.rejects(Promise.reject(new RangeError('page out of range')), { name: 'RangeError' });
await assert.doesNotReject(Promise.resolve('fine'));
});
test('strict mode rejects coercion that legacy mode accepts', async () => {
const legacy = await import('node:assert'); // the legacy-mode default export
legacy.default.equal(1, '1'); // passes: 1 == '1'
assert.throws(() => assert.equal(1, '1'), { code: 'ERR_ASSERTION' });
legacy.default.deepEqual({ n: 1 }, { n: '1' }); // passes: deepEqual() compares leaves with ==
assert.throws(() => assert.deepEqual({ n: 1 }, { n: '1' }), { code: 'ERR_ASSERTION' });
});
test('a custom message can be a function evaluated only on failure', () => {
assert.throws(
() => assert.strictEqual(1, 2, (actual, expected) => `wanted ${expected}, got ${actual}`),
{ message: /^wanted 2, got 1/ }, // the generated diff is appended after your text
);
});
Source: Node.js API: Strict assertion mode; Node.js API: assert.partialDeepStrictEqual(); Node.js API: assert.rejects()
Two version notes from running that file. First, the first two tests passed on Node.js 26, 24 and 22. Second, the last test passed only on Node.js 26: on 24.21 and 22.23 assert.strictEqual() did not accept a function as its message
(the documentation lists the printf-style and function forms as added to deepStrictEqual() in v25.1.0 and to equal() in v26.0.0), so I treat that form as Node.js 26 only. Also notice that a custom message does not replace the generated diff: in my
run the text was followed by the usual 1 !== 2 comparison, so match the start of the message rather than the whole string. The Node.js 26 documentation warns, in its legacy-mode section, that assert.deepEqual(/a/gi, new Date()) does not throw; in my runs on
26, 24 and 22 that call threw, so the example in the docs seems stale, but the lesson stands: use the strict import and stop worrying about which loose comparisons pass. assert.CallTracker, which counted calls, was removed in v25.0.0 (DEP0173); use
mock.fn() and its mock.callCount() instead, as shown in the section on test doubles.
A failing strict assertion prints what you need to repair it. The diff below is from a real red step of the TDD example later on the page.
assert.equal() on Node.js 26✖ lowercases and joins words with hyphens (0.53ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
+ actual - expected
+ 'efficient node'
- 'efficient-node'
^
Skipping, todo, only, expected failures and name patterns
While a feature is unfinished, or a test is known to be broken, you need to keep the suite green without deleting the test. The runner has four markers, all available as options on test() and describe() and as shorthand methods (it.skip(), it.todo(), it.only(), it.expectFailure()), and as methods on the context (t.skip(), t.todo()).
| Marker | Behaviour | Effect on the exit code |
|---|---|---|
|
The test is not run. A string is shown as the reason. |
None. |
|
The test runs, but a failure is not counted as a failure. If a test is both TODO and skipped, the TODO is ignored. |
None: a failing TODO test leaves the exit code at 0, though its error text is still printed. |
|
The test must throw to pass and fails if it does not. A |
A test that unexpectedly succeeds fails the run. |
|
With |
Unselected tests are omitted from the output. |
import { describe, it, test } from 'node:test';
import assert from 'node:assert/strict';
test('runs normally', () => {});
test('skipped by option', { skip: 'no network in CI' }, () => {
throw new Error('never executed');
});
test('skipped at runtime', (t) => {
t.skip('feature flag is off');
// skip() does not stop the function; return yourself
});
test('todo still executes but cannot fail the run', { todo: 'ISBN-10 support' }, () => {
assert.equal(1 + 1, 3);
});
it.expectFailure('known bug: trims too much', () => {
assert.equal(' a '.trim().length, 2);
});
describe('search', () => {
it('finds by title', () => {});
it('finds by author', () => {});
it.todo('finds by ISBN');
});
✔ runs normally (0.25ms)
﹣ skipped by option (0.04ms) # no network in CI
﹣ skipped at runtime (0.08ms) # feature flag is off
⚠ todo still executes but cannot fail the run (0.36ms) # ISBN-10 support
✔ known bug: trims too much (0.08ms) # EXPECTED FAILURE
▶ search
✔ finds by title (0.05ms)
✔ finds by author (0.03ms)
✔ finds by ISBN (0.03ms) # TODO
✔ search (0.29ms)
ℹ tests 8
ℹ pass 4
ℹ skipped 2
ℹ todo 2
On Node.js 22.23.3 the same file ended with exit code 1, because it.expectFailure is not a function surfaced as an uncaught exception; keep expectFailure out of code that still runs on 22. The skip-and-todo behaviour was identical on all three lines.
only is for the inner loop of working on one test; it should never be committed, and --test-only is what keeps the flag from being silently honoured or ignored. The next file is run both ways.
import { describe, it, test } from 'node:test';
test('not selected', () => { throw new Error('should not run'); });
test('selected', { only: true }, () => {});
describe.only('selected suite', () => {
it('runs 1', () => {});
it('runs 2', () => {});
});
--test-only (Node.js 26)$ node --test --test-only test/only.test.js
✔ selected (0.28ms)
▶ selected suite
✔ runs 1 (0.05ms)
✔ runs 2 (0.31ms)
✔ selected suite (0.49ms)
ℹ tests 3
ℹ pass 3
$ node --test test/only.test.js
✖ not selected (0.26ms)
✔ selected (0.05ms)
... (the suite's tests also run)
ℹ tests 4
ℹ pass 3
ℹ fail 1
If you use only on subtests, every ancestor has to be marked too, or the filter removes the parent before it can run the child; inside a test, t.runOnly(true) switches the same behaviour on for its own subtests (it is a no-op without --test-only).
Source: Node.js API: only tests
Name patterns
--test-name-pattern runs only tests whose name matches a JavaScript regular expression, and --test-skip-pattern skips matching tests. Both can be repeated, "/…/flags" syntax gives you flags such as i, and when both are given a test has to
satisfy both. A pattern is tested against the test’s own name; to select one test among several with the same name, prefix it with its ancestors' names, separated by spaces. The hooks of the selected tests still run, tests that are filtered out
are omitted from the output, and the set of files that the runner executes does not change: the filter applies after the files are loaded, so a pattern does not make a run cheaper to start.
import { describe, it } from 'node:test';
describe('isbn', () => {
it('accepts hyphens', () => {});
it('rejects letters', () => {});
});
describe('shelf', () => {
it('accepts duplicates?', () => {});
it('rejects duplicates', () => {});
});
node --test --test-name-pattern="^isbn" test/names.test.js
# runs the whole isbn suite: accepts hyphens, rejects letters
node --test --test-name-pattern="/REJECTS/i" test/names.test.js
# case-insensitive: isbn "rejects letters" and shelf "rejects duplicates"
node --test --test-name-pattern="shelf rejects duplicates" test/names.test.js
# ancestor names make the match unique: one test
node --test --test-skip-pattern="rejects" test/names.test.js
# everything except the two "rejects ..." tests
node --test --test-name-pattern="shelf" --test-skip-pattern="accepts" test/names.test.js
# both must hold: only shelf "rejects duplicates"
Tags (Early development)
Names encode metadata badly, so Node.js 26.2.0 (and 24.19.0) added tags: an array of labels on a test or suite, inherited by union from enclosing suites, and a filter flag that selects tests with a boolean expression using and/&&, or/||, not/!, parentheses and * wildcards.
Tags are lowercase, may not contain whitespace or the operator characters, and untagged tests behave as if they had an empty tag set, so not flaky selects them. The Node.js 26 flag is --experimental-test-tag-filter='<expression>', and the feature is Stability 1.0 (Early development), which printed an ExperimentalWarning in my run. The Node.js 24
documentation describes a simpler form of the flag that takes one tag, so do not copy expression syntax into a project that runs on 24.
import { describe, it } from 'node:test';
describe('database', { tags: ['db'] }, () => {
it('reads a row');
it('writes a row', { tags: ['integration'] });
it('reconnects after disconnect', { tags: ['flaky'] });
});
it('pure function', { tags: ['unit'] }, () => {});
it('untagged', () => {});
Source: Node.js API: Test tags
node --test --experimental-test-tag-filter='db and not flaky' test/tags.test.js
# runs: reads a row, writes a row (2 tests)
node --test --experimental-test-tag-filter='not flaky' test/tags.test.js
# runs the two db tests, "pure function" and "untagged" (4 tests)
Source: Node.js API: Test tags
Running options: concurrency, isolation, sharding, retries and watch
Most behaviour of a run is set with command-line flags, which are mirrored as options of the run() function (see below). The list below groups the flags you will use; every flag is Stable unless the table says otherwise.
| Flag | Effect | Stability / since |
|---|---|---|
|
How many test files run at once. Defaults to |
Stable, v21.0.0 |
|
|
Stable, v22.8.0 ( |
|
Default timeout for every test; individual |
Stable, v21.2.0 |
|
Exit once all known tests finish even if handles keep the event loop alive. A band-aid for leaked servers and timers; fix the leak instead. |
Stable, v22.0.0 |
|
Split the matching files into |
Stable, v20.5.0 |
|
Persist the state of a run in a JSON file and run only the tests that have not yet passed next time. |
Stable, v24.7.0 |
|
Randomize file order and test order; the seed is printed and a seed replays the order. Not supported with |
1.0 Early development, v26.1.0 (v24.16.0) |
|
A module exporting |
1.0 Early development, v24.0.0 |
|
Selection, as described above. |
Stable |
|
Output format and where it goes; see the next section. |
Stable |
|
Snapshots, coverage and module mocks: see Mocking, Snapshots & Coverage. |
Snapshots Stable; coverage 1 Experimental; module mocks 1.0 Early development |
Concurrency and isolation
The cost of process isolation is start-up time; the benefit is that files run in parallel. In the scratch experiment each of six files sleeps 300 ms. The numbers show that --test-concurrency bounds file-level parallelism, and that
turning isolation off made the run slower (all six files ran in the same process, one worker, one after another).
| Command | Run time (the duration_ms line) |
|---|---|
|
about 2.1 s |
|
about 1.1 s |
|
about 0.35 s |
|
about 1.8 s |
Isolation none also lets tests influence each other. The following pair of files shows it: one writes a global, the other expects a clean global scope.
import { test } from 'node:test';
test('writes a global', () => {
globalThis.currentUser = 'alice';
});
import { test } from 'node:test';
import assert from 'node:assert/strict';
test('expects a clean global scope', () => {
assert.equal(globalThis.currentUser, undefined);
});
$ node --test
✔ writes a global
✔ expects a clean global scope
$ node --test --test-isolation=none (--experimental-test-isolation=none on Node.js 22)
✔ writes a global
✖ expects a clean global scope
Use none when start-up time dominates (hundreds of tiny files), and only for suites you know do not share mutable state. t.workerId (v25.8.0 and v24.15.0), and the NODE_TEST_WORKER_ID environment variable available at import time, tell a test file which of the concurrent workers it is running in, which lets you allocate a database or port per worker. Within a file, concurrency on test() and describe() is a separate setting: it interleaves asynchronous tests on the one thread.
Sharding across CI machines
--test-shard=1/3 divides the matching files into three parts and runs the first; the same command on three CI jobs with 2/3 and 3/3 runs everything exactly once. Sharding divides files, not tests, so a suite with one giant file does not benefit.
$ node --test --test-shard=1/3 runs: file a, file d
$ node --test --test-shard=2/3 runs: file b, file e
$ node --test --test-shard=3/3 runs: file c, file f
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
shard: [1, 2, 3]
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 26
- run: npm ci
- run: node --test --test-shard=${{ matrix.shard }}/3
Source: Node.js API: --test-shard
The YAML itself was not executed here (the sharding command inside it was); v7 was the latest major of both actions on 2026-10-04, so pin whatever your organisation has approved. The matrix syntax is documented by GitHub, linked in the references.
Rerunning only what failed
With --test-rerun-failures=state.json, the runner writes a JSON file listing the tests that passed on each attempt and, on the next run, executes only the tests that have not yet passed. Inside a test, t.attempt is the zero-based attempt number. The documentation warns
that keys are the file path with the line and column of the definition, so changing the order of tests or moving them between runs makes the runner believe tests passed that did not: use it with tests that run in a deterministic order. In my runs t.attempt was also
available on 24.21 and 22.23 although the 26 documentation dates it to v25.0.0; the flag itself is v24.7.0 and later and is not recognised on 22.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, writeFileSync } from 'node:fs';
test('always passes', () => {});
test('fails on the first attempt only', (t) => {
// t.attempt is zero-based and counts runs recorded in the state file
t.diagnostic(`attempt ${t.attempt}`);
assert.ok(t.attempt > 0, 'transient failure');
});
$ node --test --test-rerun-failures=state.json
✔ always passes (0.23ms)
✖ fails on the first attempt only (0.26ms)
ℹ attempt 0
ℹ pass 1
ℹ fail 1
$ node --test --test-rerun-failures=state.json
✔ always passes (0.23ms) (passed on attempt 0)
✔ fails on the first attempt only (0.09ms)
ℹ attempt 1
ℹ pass 2
ℹ fail 0
That is a tool for working through a failing suite on a developer machine or for a CI "re-run failed tests" button; do not use it to hide flaky tests, and note that it does not retry inside one run. Mocha-style retries have no equivalent in node:test.
Randomized order and global setup
Order-dependent tests are found by shuffling. --test-randomize (Early development, v26.1.0 and v24.16.0) randomizes both the order of files and of queued tests inside a file, and prints the seed so a failure can be replayed with --test-random-seed=n. Subtests that are awaited one after another keep their declaration order, because each starts only after the previous one finished; suites created with describe() stay eligible.
$ node --test --test-randomize test/order.test.js
✔ four ✔ two ✔ one ✔ three
ℹ Randomized test order seed: 297561556
$ node --test --test-random-seed=12345 test/order.test.js
✔ four ✔ one ✔ two ✔ three
ℹ Randomized test order seed: 12345
--test-global-setup (Early development, v24.0.0) names a module whose exported globalSetup() runs once before all tests and globalTeardown() once after them; if setup throws, no test runs and teardown is not called. In my run, an environment variable set inside globalSetup() was visible in the test file, which is enough to hand every file a shared temporary directory; the documentation only promises that setup runs once before all tests, so treat anything beyond that as observed behaviour.
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
let dir;
export async function globalSetup() {
dir = await mkdtemp(join(tmpdir(), 'bookshelf-'));
process.env.BOOKSHELF_DATA_DIR = dir; // inherited by the per-file child processes
}
export async function globalTeardown() {
await rm(dir, { recursive: true, force: true });
}
import test from 'node:test';
import assert from 'node:assert/strict';
test('sees the directory created by global setup', () => {
assert.match(process.env.BOOKSHELF_DATA_DIR ?? '', /bookshelf-/);
});
node --test --test-global-setup=./global-setup.js # the test sees BOOKSHELF_DATA_DIR
Watch mode
node --test --watch runs the tests, then waits for changes to the test files and to the modules they import, and reruns the affected tests; it runs until you terminate it. The --test --watch combination is Stability 1 (Experimental), while plain node --watch for scripts is Stable (see
Running Scripts, the CLI & the REPL and Directories, glob & watch). The recorded session below changed the code under test twice: the first change broke it, the second repaired it. --test-shard, --test-randomize and --test-random-seed are not supported in watch mode.
--watch-path cannot be combined with --test, so there is no way to narrow the watched paths through that flag.
// sum.js
export const sum = (a, b) => a + b;
// sum.test.js
import test from 'node:test';
import assert from 'node:assert/strict';
import { sum } from './sum.js';
test('sum adds', () => assert.equal(sum(1, 2), 3));
node --test --watch while editing sum.js (Node.js 26; trimmed)✔ sum adds (0.26ms)
ℹ tests 1 ℹ pass 1 ℹ fail 0
Restarted at 10/4/2026, 12:49:05 AM
✖ sum adds (0.66ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
-1 !== 3
Restarted at 10/4/2026, 12:49:07 AM
✔ sum adds (0.34ms)
ℹ tests 1 ℹ pass 1 ℹ fail 0
Reporters, run() and the event stream
The reporter turns the stream of test events into text. You choose one with --test-reporter, and the output goes to stdout unless you give --test-reporter-destination. The exact output of the built-in reporters is not an API: the documentation says it may change between versions and tells you to consume the events if you need to process results programmatically.
| Reporter | Output | Typical use |
|---|---|---|
|
Human-readable tree with check marks, durations and a summary. The default on a TTY, and, since v23.0.0, also the default when stdout is not a TTY. |
Local development and CI logs. |
|
The Test Anything Protocol. The default for non-TTY output before v23.0.0, so on Node.js 22.23.3 the output of |
Feeding tools that read TAP. |
|
One |
Very large suites, quiet logs. |
|
JUnit XML. |
CI dashboards that show test results (GitHub Actions summaries, Jenkins, GitLab). |
|
An LCOV coverage file; it reports no test results, so pair it with another reporter, and use it with |
Coverage services; see the mocking page. |
node --test --test-reporter=dot test/isbn.test.js test/names.test.js
# ..........
node --test --test-reporter=junit --test-reporter-destination=out/junit.xml test/isbn.test.js
# several reporters: destinations are paired with reporters in the order given
node --test --test-reporter=spec --test-reporter=junit \
--test-reporter-destination=stdout --test-reporter-destination=out/results.xml
<?xml version="1.0" encoding="utf-8"?>
<testsuites>
<testsuite name="isValidIsbn13" time="0.002061" disabled="0" errors="0" tests="3" failures="0" skipped="0" timestamp="2026-10-03T22:47:13.018Z" hostname="build-host">
<testcase name="accepts a valid ISBN with hyphens" time="0.000334" classname="isValidIsbn13" file=".../test/isbn.test.js"/>
...
</testsuite>
<!-- tests 3 -->
<!-- pass 3 -->
</testsuites>
When more than one reporter is set, every one needs a destination; with a single reporter the destination defaults to stdout. The built-ins are also importable (import { spec, tap, dot, junit, lcov } from 'node:test/reporters').
A custom reporter
A custom reporter is a module whose default export is something stream.compose() accepts: a Transform in object mode or, simplest, an async generator function that receives the event stream and yields text. Pass its path to --test-reporter, the way you would pass a specifier to import() (a relative path needs ./).
// A custom reporter: an async generator that turns TestsStream events into lines.
export default async function* oneLine(source) {
for await (const event of source) {
const { type, data } = event;
if (type === 'test:pass' && data.details.type !== 'suite') {
yield `${data.skip === undefined ? 'PASS' : 'SKIP'} ${data.name}\n`;
} else if (type === 'test:fail') {
const error = data.details.error.cause ?? data.details.error;
yield `FAIL ${data.name}: ${error.message.split('\n')[0]}\n`;
} else if (type === 'test:summary' && data.file === undefined) {
const { passed, failed, skipped } = data.counts;
yield `${passed} passed, ${failed} failed, ${skipped} skipped\n`;
}
}
}
Source: Node.js API: Custom reporters
import { test } from 'node:test';
import assert from 'node:assert/strict';
test('adds up', () => assert.equal(1 + 1, 2));
test('breaks', () => assert.equal(1 + 1, 3));
test('is skipped', { skip: true }, () => {});
$ node --test --test-reporter=./reporters/one-line.js demo/mixed.demo.js
PASS adds up
FAIL breaks: Expected values to be strictly equal:
SKIP is skipped
1 passed, 1 failed, 1 skipped
The reporter above reads data.details.error.cause, because a failed test is reported with an error that wraps the actual one, and uses the final test:summary (the one without a file) for totals. The exit code was 1, as for every failing run.
Source: Node.js API: Custom reporters
Running tests from code with run()
run(options) from node:test starts a run programmatically and returns the TestsStream. It takes the same ideas as the flags: files or globPatterns, concurrency, isolation, timeout, only, testNamePatterns and testSkipPatterns, shard, watch, rerunFailuresFilePath, coverage options and thresholds, execArgv, argv, env (v25.6.0 and v24.14.0), cwd, forceExit, signal to abort, and a setup function that receives the stream before any test runs. Use it to build custom test scripts, to run tests from a tool, or to attach your own listeners.
import { run } from 'node:test';
import { spec } from 'node:test/reporters';
import { fileURLToPath } from 'node:url';
const files = [
fileURLToPath(new URL('../test/isbn.test.js', import.meta.url)),
fileURLToPath(new URL('../test/shelf.test.js', import.meta.url)),
];
const stream = run({ files, concurrency: true, timeout: 10_000 });
let failed = 0;
stream.on('test:fail', () => { failed++; });
stream.on('test:summary', ({ counts, file }) => {
if (file === undefined) console.error(`summary: ${counts.passed} passed, ${counts.failed} failed`);
});
stream.compose(spec).pipe(process.stdout);
await new Promise((resolve) => stream.on('end', resolve));
process.exitCode = failed > 0 ? 1 : 0;
✔ accepts a valid ISBN with hyphens (0.30ms)
✔ starts empty (0.31ms)
...
summary: 14 passed, 0 failed
Source: Node.js API: run()
| Event | When it is emitted |
|---|---|
|
A test is queued and when it starts executing (execution order). |
|
Declaration-ordered: a test or suite starts reporting, then ends. |
|
The execution-order twin of pass and fail; |
|
Plan counts and diagnostic messages ( |
|
|
|
Output that a test file wrote, forwarded by the runner. |
|
One per file with process isolation, plus a final cumulative one whose |
|
When coverage is enabled. |
|
The run received SIGINT (v25.7.0). |
|
Watch mode only. |
Kinds of tests, test doubles and TDD
node:test does not care what kind of test you write; the kind decides what you set up and how fast it is. A good split puts each assertion at the lowest layer that can make it.
| Kind | What it exercises | In Bookshelf |
|---|---|---|
Unit |
One function or class, with its collaborators replaced by test doubles. Milliseconds, no I/O. |
|
Integration |
Several real parts together: code and a real database, a router and its handlers. |
The API with an in-memory |
Functional |
One feature end to end through its public interface, usually without the network. |
"Adding a book makes it appear in the list", through the HTTP handler. |
End-to-end |
The deployed shape: a real server process, real HTTP, real dependencies. |
The server listening on a port, driven with |
Test doubles
A test double replaces a collaborator so the unit under test can be exercised in isolation. The vocabulary is older than any framework; this is how it maps onto node:test.
| Double | Purpose | With node:test |
|---|---|---|
Dummy |
Fills a parameter that is never used. |
A plain |
Stub |
Returns canned answers (or errors) to drive the code down a path. |
|
Spy |
Records how it was called while the real behaviour stays. |
|
Fake |
A small working substitute (an in-memory store, a recording mailer). |
Ordinary objects you write. |
Mock |
A double with built-in expectations about calls. |
Spy plus assertions on |
The mock object that comes from node:test and its twin t.mock do the heavy lifting. Prefer t.mock: it is the same API, but the runner restores everything when the test ends, so a failed assertion cannot leave a replaced method behind for the next test. Replacing methods, getters and properties, mock timers, module mocks and snapshots are covered on
Mocking, Snapshots & Coverage; here is what a unit test needs. The code under test receives its mailer as a parameter (dependency injection by function argument), which is what makes it testable without any module mocking.
// Sends a reminder for every overdue loan; the mailer is injected, so tests can replace it.
export async function remindOverdue(loans, mailer, now = new Date()) {
const overdue = loans.filter((loan) => loan.dueDate < now);
for (const loan of overdue) {
await mailer.send(loan.member, `"${loan.title}" is overdue`);
}
return overdue.length;
}
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { remindOverdue } from '../src/notifier.js';
import { Shelf } from '../src/shelf.js';
const loans = [
{ member: 'ana', title: 'Dune', dueDate: new Date('2026-01-10') },
{ member: 'bo', title: 'Emma', dueDate: new Date('2026-03-01') },
];
test('spy: records calls without changing behaviour', async (t) => {
const send = t.mock.fn(async () => {}); // a fake function that also records calls
const count = await remindOverdue(loans, { send }, new Date('2026-02-01'));
assert.equal(count, 1);
assert.equal(send.mock.callCount(), 1);
assert.deepStrictEqual(send.mock.calls[0].arguments, ['ana', '"Dune" is overdue']);
});
test('stub: canned answers, including failures', async (t) => {
const send = t.mock.fn(async () => {});
send.mock.mockImplementationOnce(async () => { throw new Error('smtp down'); });
await assert.rejects(remindOverdue(loans, { send }, new Date('2026-12-31')), /smtp down/);
assert.equal(send.mock.callCount(), 1); // the loop stopped at the first failure
});
test('fake: a small working replacement', async () => {
const outbox = [];
const fakeMailer = { send: async (to, text) => { outbox.push({ to, text }); } };
await remindOverdue(loans, fakeMailer, new Date('2026-12-31'));
assert.deepStrictEqual(outbox.map((m) => m.to), ['ana', 'bo']);
});
test('t.mock.method is restored automatically', async (t) => {
const shelf = new Shelf();
t.mock.method(shelf, 'add'); // wraps the real method and records calls
await shelf.loadFrom({ fetchAll: async () => [{ isbn: '1', title: 'A' }] });
assert.equal(shelf.add.mock.callCount(), 1);
});
All four tests passed on Node.js 26, 24 and 22. In the second one, mockImplementationOnce() changes only the first call; the assertion that callCount() is 1 documents that the loop aborts at the first failed send, which may or may not be what you want, and the test is the place where that decision becomes explicit.
Test-driven development
TDD is a discipline of tiny cycles: write one failing test (red), write the least code that passes (green), then clean up with the tests as a safety net (refactor). The test runner suits it because a run costs tens of milliseconds, and node --test --watch reruns the affected tests on every save. The example drives a slugify() function used to build URLs from titles: the test file comes first.
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { slugify } from './slug.js';
describe('slugify', () => {
it('lowercases and joins words with hyphens', () => {
assert.equal(slugify('Efficient Node'), 'efficient-node');
});
it('drops punctuation', () => {
assert.equal(slugify('Dune: Messiah!'), 'dune-messiah');
});
});
✖ lowercases and joins words with hyphens (0.53ms)
+ 'efficient node'
- 'efficient-node'
✖ drops punctuation (0.43ms)
ℹ pass 0
ℹ fail 2
export function slugify(title) {
return title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
}
✔ lowercases and joins words with hyphens (0.29ms)
✔ drops punctuation (0.05ms)
ℹ pass 2
ℹ fail 0
The first red run in my experiment was even earlier: with no slug.js file at all the test file failed to load with ERR_MODULE_NOT_FOUND, and the runner reported the whole file as one failed test. A red caused by a missing module is a legitimate first step, but it hides which assertions you meant; a stub that returns something wrong gives the readable diff above.
Testing an HTTP server
An HTTP service is tested by starting the real server on a random port, calling it with the global fetch(), and shutting it down. The pieces: listen on port 0 so the operating system picks a free port (no clash between the test files that run in parallel, and no dependency on port 3000 being free), read the actual port from server.address(), create and close the server in before() and after(), and do not leave keep-alive sockets behind, or the process will not exit.
The Bookshelf server is the one used elsewhere in this section; createApp() returns a node:http server and an in-memory SQLite database, so each test file has its own clean data.
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { once } from 'node:events';
import { createApp } from '../src/server.js';
describe('books API', () => {
let server;
let base;
before(async () => {
({ server } = createApp()); // in-memory SQLite database
server.listen(0, '127.0.0.1'); // port 0: the OS picks a free port
await once(server, 'listening');
base = `http://127.0.0.1:${server.address().port}`;
});
after(async () => {
server.close();
server.closeAllConnections(); // do not wait for keep-alive sockets
await once(server, 'close');
});
it('starts with an empty list', async () => {
const res = await fetch(`${base}/books`);
assert.equal(res.status, 200);
assert.match(res.headers.get('content-type'), /^application\/json/);
assert.deepStrictEqual(await res.json(), []);
});
it('creates a book and lists it', async () => {
const created = await fetch(`${base}/books`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ title: 'Dune', author: 'Herbert' }),
});
assert.equal(created.status, 201);
assert.partialDeepStrictEqual(await created.json(), { title: 'Dune', author: 'Herbert' });
const list = await (await fetch(`${base}/books`)).json();
assert.equal(list.length, 1);
});
it('answers 400 to a malformed body', async () => {
const res = await fetch(`${base}/books`, { method: 'POST', body: '{not json' });
assert.equal(res.status, 400);
assert.ok((await res.json()).error);
});
it('answers 404 to an unknown path', async () => {
const res = await fetch(`${base}/nope`);
assert.equal(res.status, 404);
await res.body?.cancel();
});
});
✔ starts with an empty list (8.15ms)
✔ creates a book and lists it (2.65ms)
✔ answers 400 to a malformed body (0.81ms)
✔ answers 404 to an unknown path (0.66ms)
✔ books API (16.57ms)
ℹ pass 4
ℹ fail 0
Four details in that file matter. partialDeepStrictEqual ignores the generated id, so the test does not depend on database order. The 404 test cancels the unread response body (res.body?.cancel()), because an unread body keeps the connection busy. server.closeAllConnections() closes idle keep-alive connections that fetch() leaves open, which otherwise makes close() wait. And binding to 127.0.0.1 explicitly avoids the IPv4 versus IPv6 resolution mismatch that a localhost base URL can produce on machines where the name resolves to ::1 first.
A test that needs a database, a queue or another service is an integration test and should say so in its name and its folder; the sibling pages on databases and queues show how to start those dependencies in containers. Frameworks bring their own shortcuts (Fastify’s inject() injects a request without opening a socket, described on
Web Frameworks: Express & Fastify), and the NestJS and Next.js sections have their own testing pages, which use Jest or Vitest (see
Testing in NestJS and Testing in Next.js). The same test shape applies to a server you build in
Building a REST API and to the lower-level API of HTTP Server Fundamentals.
TypeScript tests
With type stripping stable, node --test runs *.test.ts files as they are: no compile step, no ts-node. In my runs a sum.test.ts file with parameter and return types passed on Node.js 26, 24 and 22.23 without a warning. Remember what type stripping does not do (no enums, namespaces or parameter properties, no .tsx, no type-checking), and keep type-checking in a separate tsc --noEmit step. The details are on
TypeScript in Node.js.
import test from 'node:test';
import assert from 'node:assert/strict';
const add = (a: number, b: number): number => a + b;
test('runs TypeScript directly', () => {
assert.equal(add(2, 3), 5);
});
node:test versus Jest, Vitest and Mocha
Nothing here makes one runner the winner. The built-in runner is a good default for libraries, command-line tools and back-end services, because it needs no installation, no configuration file and no transformation step, and it runs the code the way Node.js runs it. Jest, Vitest and Mocha earn their dependency when you need what they add. I only verified the Node.js column by running it; the others describe each project’s documented, long-standing behaviour as of 2026-10-04 (npm latest: Jest 30.5.2, Vitest 5.0.3, Mocha 12.0.3), so check their documentation for the version you use.
| Aspect | node:test |
Jest | Vitest | Mocha |
|---|---|---|---|---|
Installation |
None: part of Node.js. |
|
|
|
Globals |
None: import |
Provides them as globals. |
Imports by default; globals via a config option. |
Provides them as globals. |
Assertions |
|
|
|
Bring your own ( |
Mocking |
|
|
|
Bring your own (for example Sinon). |
Snapshots |
Built in, stored in files (stable). |
Built in. |
Built in. |
Bring your own. |
Coverage |
V8 coverage built in (Experimental). |
Built in (Babel or V8 providers). |
Built in (V8 or Istanbul providers). |
External tools such as c8 or nyc. |
TypeScript |
Type stripping, erasable syntax only. |
Needs a transformer (Babel, ts-jest, SWC). |
Handled by Vite’s esbuild transform. |
Needs a loader such as tsx or ts-node. |
Browser or DOM |
None: no DOM environment. |
jsdom environment. |
jsdom, happy-dom, and a browser mode. |
Bring your own. |
Parallelism |
Files in separate processes (default). |
Files in workers or processes. |
Files in workers or threads. |
Serial by default; a parallel mode exists. |
Retries |
None built in ( |
|
A |
A |
Watch mode |
|
Built in. |
Built in; the default in a terminal. |
|
Pick the built-in runner for a new Node.js library or service whose tests are plain JavaScript or erasable TypeScript; Vitest for a project that already builds with Vite or tests UI components; Jest where you already have a large Jest suite (including React Native) and the migration is not worth the churn. The Jest runner, matchers and mocking are described in Unit Testing with Jest, and are not repeated here.
Migrating from Mocha
Mocha and node:test have almost the same describe/it/hook shape, so migration is mostly mechanical. The Node.js project publishes a codemod, @nodejs/mocha-to-node-test-runner, that targets Mocha 8.x suites and node:test on Node.js 22 and 24. It adds the node:test imports for the globals a file uses, turns done callbacks into the (t, done) signature, rewrites this.skip() to t.skip() and this.timeout(n) to a { timeout: n } option, keeps your function style, supports both CommonJS and ESM files, and removes mocha and @types/mocha from package.json. Its documented limitation is that node:test has no equivalent of Mocha’s retry option.
npx codemod @nodejs/mocha-to-node-test-runner
I tried that command in the scratch project. It downloaded the codemod and then asked for interactive permission for file-system and child-process access, and it exited because the shell had no TTY, so it did not modify anything. I did not work around that prompt; the "after" listing below was written by hand to follow the documented rules, and then run on Node.js 26, 24 and 22 (all three tests passed, and the third was skipped when CI was set).
import assert from 'node:assert/strict';
import { Shelf } from '../src/shelf.js';
describe('Shelf', function () {
this.timeout(500);
let shelf;
beforeEach(function () {
shelf = new Shelf();
});
it('adds a book', function () {
shelf.add({ isbn: '1', title: 'A' });
assert.equal(shelf.size, 1);
});
it('loads asynchronously (done style)', function (done) {
shelf.loadFrom({ fetchAll: async () => [{ isbn: '2', title: 'B' }] }).then((n) => {
assert.equal(n, 1);
done();
}, done);
});
it('skips on CI', function () {
if (process.env.CI) this.skip();
});
});
import assert from 'node:assert/strict';
import { describe, it, beforeEach } from 'node:test';
import { Shelf } from '../src/shelf.js';
describe('Shelf', { timeout: 500 }, function () {
let shelf;
beforeEach(function () {
shelf = new Shelf();
});
it('adds a book', function () {
shelf.add({ isbn: '1', title: 'A' });
assert.equal(shelf.size, 1);
});
it('loads asynchronously (done style)', function (t, done) {
shelf.loadFrom({ fetchAll: async () => [{ isbn: '2', title: 'B' }] }).then((n) => {
assert.equal(n, 1);
done();
}, done);
});
it('skips on CI', function (t) {
if (process.env.CI) t.skip();
});
});
Beyond what the codemod does, a migration checklist covers the rest of the Mocha ecosystem: a Chai or should.js assertion is rewritten with node:assert/strict; Sinon spies and stubs become mock.fn() and mock.method(); .mocharc options become command-line flags or an npm script (--grep is --test-name-pattern, --reporter is --test-reporter, --exit roughly corresponds to --test-force-exit, --require is --import, --watch is --watch); and a done-style test that also returns a promise has to pick one, because node:test fails a test that does both.
What changed recently
| Version | Change |
|---|---|
v26.6.0 |
|
v26.3.0 |
|
v26.2.0 (also v24.19.0) |
Test tags and |
v26.1.0 (v24.16.0 for the flags) |
|
v25.8.0 (v24.15.0) |
|
v25.7.0 |
The |
v25.6.0 (v24.14.0) |
The |
v25.5.0 (v24.14.0) |
|
v25.0.0 |
|
v24.7.0 |
|
v24.0.0 (v22.17.0) |
|
v23.8.0 |
Per the Node.js Learn guide, |
v23.6.0 |
|
v23.4.0 (v22.13.0) |
|
v23.0.0 |
The default reporter on a non-TTY stdout changes from |
v22.8.0 |
Test isolation option ( |
v22.2.0 to v22.3.0 |
|
v20.0.0 |
The test runner becomes Stable. |
Source: Node.js API: Test runner (history tables); Node.js API: Command-line options (history tables)
The Learn guide on using the test runner (the page this section cites for patterns) still shows --experimental-test-snapshots for snapshot testing. Snapshot testing has been stable since v23.4.0 and v22.13.0 and needs no experimental flag (the old name was still accepted without error by Node.js 26.10.0 in my check; the flag to update snapshots is --test-update-snapshots). The same guide’s ServiceWorker example calls before and after without importing them; import every hook you use.
Where the books are outdated
Testing is one of the clearest places where print falls behind, because the runner went from experimental to stable in v20.0.0 and has gained features in nearly every release since. Samer Buna’s Efficient Node.js (O’Reilly, 2025) has a chapter on testing Node that does cover node:test and node:assert, test doubles, TDD, --watch and coverage gates, and a later chapter that surveys Mocha, Chai and Jest as the tools of the trade. What it cannot cover are the later additions: partialDeepStrictEqual() as a stable assertion, awaited-by-default subtests, t.waitFor(), expectFailure, tags, randomized order and replayable seeds, --test-rerun-failures, JUnit and custom reporters as first-class output, the Experimental-to-Stable changes for snapshots and t.plan(), and the removal of assert.CallTracker in Node.js 25. Where a flag or option differs from this page, trust this page and the Node.js documentation. Jonathan Wexler’s Node.js Projects (O’Reilly, 2025) is organised around applications rather than around testing; its appendix on tools reaches for nodemon to restart a server, which node --watch (Stable since v22.0.0) now does, and node --test --watch does for tests. If you follow either book, use node:assert/strict rather than the legacy assert, import describe, it and the hooks explicitly (the runner has no globals), and prefer t.mock over mock so that doubles restore themselves.
A last reading note on one assertion helper, because it is easy to invert in your head: assert.doesNotMatch(string, regexp) passes when the string does not match the pattern (it is the negation of assert.match()), as the run of test/assertions.test.js above confirms.
Pitfalls
| Mistake | Fix |
|---|---|
Forgetting |
Await every subtest; Node.js 22 cancels the ones still running when the parent finishes. |
Using the imported |
Use |
Importing |
Legacy |
Passing a directory to |
Pass a glob ( |
Putting helpers in |
Every file under |
Committing |
Run with |
Expecting |
A failing TODO test never changes the exit code; use |
A hard-coded port in server tests |
Listen on port 0 and read |
Not closing servers and handles |
Close in |
Tests that share state under |
Keep the default process isolation unless you have measured a start-up problem. |
Using |
|
A test that cannot fail |
Make sure the assertion would fail if the code were wrong: write the failing test first (TDD) and watch it fail for the reason you expect. |
Relying on the exact text of a reporter |
The output of built-in reporters is not stable; consume events through |
Treating Early development features as stable |
Tags, global setup, randomization and module mocks may change in minor releases; pin your Node.js version and re-test when upgrading. |
References
-
Node.js Learn: Discovering Node.js’s test runner, https://nodejs.org/learn/test-runner/introduction
-
Node.js Learn: Using Node.js’s test runner, https://nodejs.org/learn/test-runner/using-test-runner
-
Node.js Learn: Mocking in tests, https://nodejs.org/learn/test-runner/mocking
-
Node.js Learn: Mocha to Node.js Test Runner, https://nodejs.org/learn/userland-migrations/mocha-to-node-test-runner
-
Node.js API: Test runner, https://nodejs.org/docs/latest/api/test.html
-
Node.js API: Test runner, Running tests from the command line, https://nodejs.org/docs/latest/api/test.html#running-tests-from-the-command-line
-
Node.js API: Test runner, Test runner execution model, https://nodejs.org/docs/latest/api/test.html#test-runner-execution-model
-
Node.js API: Test runner, Subtests, https://nodejs.org/docs/latest/api/test.html#subtests
-
Node.js API: Test runner, Skipping tests, https://nodejs.org/docs/latest/api/test.html#skipping-tests
-
Node.js API: Test runner, TODO tests, https://nodejs.org/docs/latest/api/test.html#todo-tests
-
Node.js API: Test runner, Expecting tests to fail, https://nodejs.org/docs/latest/api/test.html#expecting-tests-to-fail
-
Node.js API: Test runner,
onlytests, https://nodejs.org/docs/latest/api/test.html#only-tests -
Node.js API: Test runner, Filtering tests by name, https://nodejs.org/docs/latest/api/test.html#filtering-tests-by-name
-
Node.js API: Test runner, Test tags, https://nodejs.org/docs/latest/api/test.html#test-tags
-
Node.js API: Test runner, Rerunning failed tests, https://nodejs.org/docs/latest/api/test.html#rerunning-failed-tests
-
Node.js API: Test runner, Randomizing tests execution order, https://nodejs.org/docs/latest/api/test.html#randomizing-tests-execution-order
-
Node.js API: Test runner, Global setup and teardown, https://nodejs.org/docs/latest/api/test.html#global-setup-and-teardown
-
Node.js API: Test runner, Watch mode, https://nodejs.org/docs/latest/api/test.html#watch-mode
-
Node.js API: Test runner, Test reporters, https://nodejs.org/docs/latest/api/test.html#test-reporters
-
Node.js API: Test runner, Custom reporters, https://nodejs.org/docs/latest/api/test.html#custom-reporters
-
Node.js API: Test runner, Multiple reporters, https://nodejs.org/docs/latest/api/test.html#multiple-reporters
-
Node.js API: Test runner, run(), https://nodejs.org/docs/latest/api/test.html#runoptions
-
Node.js API: Test runner, Class TestsStream, https://nodejs.org/docs/latest/api/test.html#class-testsstream
-
Node.js API: Test runner, context.plan(), https://nodejs.org/docs/latest/api/test.html#contextplancountoptions
-
Node.js API: Test runner, context.waitFor(), https://nodejs.org/docs/latest/api/test.html#contextwaitforcondition-options
-
Node.js API: Test runner, Mocking, https://nodejs.org/docs/latest/api/test.html#mocking
-
Node.js API: Assert, Strict assertion mode, https://nodejs.org/docs/latest/api/assert.html#strict-assertion-mode
-
Node.js API: Assert, assert.partialDeepStrictEqual(), https://nodejs.org/docs/latest/api/assert.html#assertpartialdeepstrictequalactual-expected-message
-
Node.js API: Assert, assert.rejects(), https://nodejs.org/docs/latest/api/assert.html#assertrejectsasyncfn-error-message
-
Node.js API: Command-line options, --test, https://nodejs.org/docs/latest/api/cli.html#--test
-
Node.js API: Command-line options, --test-concurrency, https://nodejs.org/docs/latest/api/cli.html#--test-concurrency
-
Node.js API: Command-line options, --test-isolation, https://nodejs.org/docs/latest/api/cli.html#--test-isolationmode
-
Node.js API: Command-line options, --test-shard, https://nodejs.org/docs/latest/api/cli.html#--test-shard
-
Node.js API: Command-line options, --watch, https://nodejs.org/docs/latest/api/cli.html#--watch
-
Node.js API: Deprecated APIs, DEP0173 assert.CallTracker, https://nodejs.org/docs/latest/api/deprecations.html#dep0173-the-assertcalltracker-class
-
GitHub Docs: Running variations of jobs in a workflow (matrix), https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/running-variations-of-jobs-in-a-workflow
-
Jest, https://jestjs.io/
-
Vitest, https://vitest.dev/
-
Mocha, https://mochajs.org/
-
O’Reilly: Efficient Node.js, Samer Buna, https://www.oreilly.com/library/view/efficient-node-js/9781098145187
-
O’Reilly: Node.js Projects, Jonathan Wexler, https://www.oreilly.com/library/view/node-js-projects/9781098173135/