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.

Table 1. Test runner features covered on this page
Feature Stability (Node.js 26 docs) Version notes

node:test: test(), describe()/suite(), it(), hooks, subtests, node --test

2 Stable

Added in v18.0.0 and v16.17.0; marked stable in v20.0.0. suite() and describe() were added in v22.0.0 and v20.13.0; it() became a true alias of test() in v19.8.0 and v18.16.0.

node:assert and node:assert/strict

2 Stable

Strict assertion mode since v9.9.0; node:assert/strict since v15.0.0. In legacy mode, assert.deepEqual(), equal(), notDeepEqual() and notEqual() are Stability 3 (Legacy).

assert.partialDeepStrictEqual()

Stable

Added in v23.4.0 and v22.13.0; stable since v24.0.0 and v22.17.0.

t.plan(), t.assert

Stable

t.assert added in v22.2.0 and v20.15.0. t.plan() is no longer experimental since v23.4.0 and v22.13.0; its wait option arrived in v23.9.0 and v22.15.0.

t.waitFor(), assert.register()

Stable

Added in v23.7.0 and v22.14.0.

skip, todo, only options and shorthands

Stable

Shorthands in v20.2.0 and v18.17.0. --test-only is needed unless isolation is off.

expectFailure option, it.expectFailure()

Stable

Added in v25.5.0 and v24.14.0. Not available on Node.js 22.

--test-name-pattern, --test-skip-pattern

Stable

--test-name-pattern since v18.11.0; --test-skip-pattern since v22.1.0.

--test-concurrency, --test-timeout, --test-shard, --test-force-exit

Stable

--test-shard since v20.5.0 and v18.19.0; --test-concurrency since v21.0.0, v20.10.0 and v18.19.0; --test-timeout since v21.2.0 and v20.11.0; --test-force-exit since v22.0.0 and v20.14.0.

--test-isolation

Stable

Added as --experimental-test-isolation in v22.8.0; renamed in v23.6.0. On Node.js 22.23 you still spell it --experimental-test-isolation.

--test-rerun-failures

Stable

Added in v24.7.0. Not available on Node.js 22 (node: bad option).

--test-reporter, --test-reporter-destination, node:test/reporters

Stable

Added in v19.6.0 and v18.15.0; reporters exposed at node:test/reporters since v19.9.0 and v18.17.0. Default reporter on a non-TTY stdout is spec since v23.0.0 (it was tap).

run() and the TestsStream events

Stable

run() added in v18.9.0 and v16.19.0; the isolation, rerunFailuresFilePath and env options came in later releases (see the "What changed recently" table).

--watch together with --test

1 Experimental

Added in v19.2.0 and v18.13.0. The general node --watch flag is Stable since v22.0.0 and v20.13.0; the test-runner integration is documented as Experimental.

Code coverage (--experimental-test-coverage and the threshold flags)

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.

--test-global-setup

1.0 Early development

Added in v24.0.0.

--test-randomize, --test-random-seed

1.0 Early development

Added in v26.1.0; backported to v24.16.0. Not available on Node.js 22.

Test tags and --experimental-test-tag-filter

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.

mock.module()

1.0 Early development

Needs --experimental-test-module-mocks; see the mocking page.

getTestContext(), t.log()

Stable

getTestContext() added in v26.1.0 (v24.19.0); t.log() added in v26.6.0.

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).

flowchart TD cli["node --test with flags and optional glob arguments"] --> find["find test files: default patterns or the globs you passed"] find --> shard{"--test-shard set?"} shard -- "yes" --> slice["keep only this shard's files"] shard -- "no" --> mode{"isolation mode"} slice --> mode mode -- "process (default)" --> children["one child process per file, at most --test-concurrency at a time"] mode -- "none" --> inproc["import every file into the runner process, one at a time"] children --> events["test events from every file"] inproc --> events events --> stream["TestsStream: the single ordered event stream"] stream --> reporters["reporters: spec, dot, tap, junit, lcov or your own"] reporters --> dest["stdout, stderr or a file"] stream --> code["exit code: 1 if a test failed, 0 otherwise"]
Figure 1. From `node --test` to the exit code

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.

Table 2. Default test file patterns on Node.js 26
Pattern Matches Does not match

**/*.test.{cjs,mjs,js}

src/isbn.test.js

src/isbn.spec.js

**/*-test.{cjs,mjs,js}

src/isbn-test.js

src/isbn-tests.js

**/*_test.{cjs,mjs,js}

src/isbn_test.js

src/helper.js

**/test-*.{cjs,mjs,js}

src/test-isbn.js

src/mytest-isbn.js

**/test.{cjs,mjs,js}

src/test.js

src/tests.js

**/test/**/*.{cjs,mjs,js}

test/a.js, test/nested/b.mjs, test/c.cjs

tests/a.js (the directory must be named test)

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.

Default discovery versus explicit globs
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
Files executed by the plain 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 --.

Wiring the runner into npm scripts
{
  "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.

The function under test: an ISBN-13 validator
// 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;
}
test/isbn.test.js: a suite with three tests
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' });
  });
});
Result on Node.js 26 with 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.

test/hooks.test.js: the order in which hooks and tests run
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.

test/hooks-scope.test.js: hooks at the file level and in nested suites
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'));
  });
});

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.

test/nesting.test.js: a parent test, its hooks and two subtests
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', () => {});
});

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.

test/table.test.js: one subtest per data row
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.

A plan that counts t.assert calls
import 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);
});
Mixing the imported 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
});
Result on Node.js 26
✖ 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.

test timeouts, 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 });
});
Result on Node.js 26
✖ 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.

Table 3. The assertions you will use every day
Assertion What it checks

assert.strictEqual(actual, expected)

Primitives or identity, compared with Object.is().

assert.deepStrictEqual(actual, expected)

Recursive equality of enumerable own properties, including the prototypes' identity, Map and Set contents, Error names, messages and causes. Property order does not matter; array order does.

assert.partialDeepStrictEqual(actual, expected)

Only the properties present in expected are compared (a superset of `deepStrictEqual’s rules). Handy for API responses with generated ids and timestamps.

assert.ok(value), assert.match(string, regexp), assert.doesNotMatch(string, regexp)

Truthiness; a regular expression must match; a regular expression must not match.

assert.throws(fn, expected), assert.doesNotThrow(fn)

expected may be an error class, a RegExp, an object of properties to compare, or a validation function.

assert.rejects(promiseOrFn, expected), assert.doesNotReject(promiseOrFn)

The asynchronous twins: always await them, otherwise a failure becomes an unhandled rejection after the test is over.

test/assertions.test.js (Node.js 26: the function message in the last test needs v26.0.0)
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
  );
});

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.

A failing 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()).

Table 4. The markers and what each one does to the run
Marker Behaviour Effect on the exit code

skip (option, it.skip(), t.skip())

The test is not run. A string is shown as the reason. t.skip() does not stop the function, so return after calling it.

None.

todo (option, it.todo(), t.todo())

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.

expectFailure (option, it.expectFailure())

The test must throw to pass and fails if it does not. A RegExp, function, object or error restricts which failure is accepted, and { label, match } supplies both a reason and a matcher. Added in v25.5.0 and v24.14.0.

A test that unexpectedly succeeds fails the run. skip beats it, and todo beats it too.

only (option, it.only(), describe.only())

With --test-only (or with isolation off), only tests marked only run. Everything below a marked suite runs unless a descendant is marked too.

Unselected tests are omitted from the output.

test/markers.test.js: skip, todo, expectFailure and a todo test without a body
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');
});
Result on Node.js 26 and 24 (exit code 0)
✔ 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.

test/only.test.js
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', () => {});
});
With and without --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).

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.

test/names.test.js
import { describe, it } from 'node:test';

describe('isbn', () => {
  it('accepts hyphens', () => {});
  it('rejects letters', () => {});
});
describe('shelf', () => {
  it('accepts duplicates?', () => {});
  it('rejects duplicates', () => {});
});
Selecting by name (Node.js 26; only the matching lines are shown)
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.

test/tags.test.js
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', () => {});
Filtering by tag (Node.js 26)
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)

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.

Table 5. Flags of node --test
Flag Effect Stability / since

--test-concurrency=n

How many test files run at once. Defaults to os.availableParallelism() - 1; it is ignored (one at a time) when isolation is none.

Stable, v21.0.0

--test-isolation=process|none

process (default): one child process per file. none: every file is imported into the runner’s process and the files run one after another.

Stable, v22.8.0 (--experimental-test-isolation on 22)

--test-timeout=ms

Default timeout for every test; individual timeout options override it. Default Infinity.

Stable, v21.2.0

--test-force-exit

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

--test-shard=index/total

Split the matching files into total parts and run part index. Not combinable with watch mode.

Stable, v20.5.0

--test-rerun-failures=file

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

--test-randomize, --test-random-seed=n

Randomize file order and test order; the seed is printed and a seed replays the order. Not supported with --watch or --test-rerun-failures.

1.0 Early development, v26.1.0 (v24.16.0)

--test-global-setup=module

A module exporting globalSetup() and/or globalTeardown(), run once before and after all tests.

1.0 Early development, v24.0.0

--test-only, --test-name-pattern, --test-skip-pattern

Selection, as described above.

Stable

--test-reporter, --test-reporter-destination

Output format and where it goes; see the next section.

Stable

--test-update-snapshots, --experimental-test-coverage, --test-coverage-*, --experimental-test-module-mocks

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).

Table 6. Six files that each take 300 ms, on a machine with availableParallelism() of 10 (Node.js 26)
Command Run time (the duration_ms line)

node --test --test-concurrency=1

about 2.1 s

node --test --test-concurrency=2

about 1.1 s

node --test --test-concurrency=6

about 0.35 s

node --test --test-isolation=none

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.

iso/a-writes.test.js and iso/b-reads.test.js
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);
});
Same files, two modes (Node.js 26, 24 and 22 behaved the same)
$ 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.

Six files, three shards (Node.js 26)
$ 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
A GitHub Actions matrix that runs one shard per job
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

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.

rerun/flaky.test.js
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');
});
Two runs against the same state file (Node.js 26)
$ 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.

Randomization and replay (Node.js 26; the first seed is random)
$ 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.

global-setup.js and a test that uses it
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.

watch/sum.test.js and watch/sum.js
// 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.

Table 7. Built-in reporters
Reporter Output Typical use

spec

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.

tap

The Test Anything Protocol. The default for non-TTY output before v23.0.0, so on Node.js 22.23.3 the output of node --test | cat is TAP, which I observed.

Feeding tools that read TAP.

dot

One . per passed test and X per failure.

Very large suites, quiet logs.

junit

JUnit XML.

CI dashboards that show test results (GitHub Actions summaries, Jenkins, GitLab).

lcov

An LCOV coverage file; it reports no test results, so pair it with another reporter, and use it with --experimental-test-coverage.

Coverage services; see the mocking page.

Choosing and combining reporters (Node.js 26)
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
out/junit.xml (trimmed; paths shortened)
<?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 ./).

reporters/one-line.js
// 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`;
    }
  }
}
demo/mixed.demo.js
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 }, () => {});
Using it on a file with a pass, a failure and a skip (identical output on Node.js 26, 24 and 22)
$ 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.

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.

scripts/run-tests.js: a runner script with a summary and an exit code (identical on Node.js 26, 24 and 22)
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;
Output (stdout and stderr merged; trimmed)
  ✔ accepts a valid ISBN with hyphens (0.30ms)
  ✔ starts empty (0.31ms)
  ...
summary: 14 passed, 0 failed
Table 8. The TestsStream events a reporter or listener can see
Event When it is emitted

'test:enqueue', 'test:dequeue'

A test is queued and when it starts executing (execution order).

'test:start', 'test:pass', 'test:fail'

Declaration-ordered: a test or suite starts reporting, then ends. data.details.type is 'suite' for suites; data.skip and data.todo mark the markers; a failure carries details.error, which wraps the original in cause.

'test:complete'

The execution-order twin of pass and fail; details.passed says which.

'test:plan', 'test:diagnostic'

Plan counts and diagnostic messages (t.diagnostic()), buffered into declaration order.

'test:log'

t.log(message, data), emitted immediately (v26.6.0).

'test:stdout', 'test:stderr'

Output that a test file wrote, forwarded by the runner.

'test:summary'

One per file with process isolation, plus a final cumulative one whose file is undefined; it carries counts and success, which is false for a failing test or an unmet coverage threshold.

'test:coverage'

When coverage is enabled.

'test:interrupted'

The run received SIGINT (v25.7.0).

'test:watch:drained', 'test:watch:restarted'

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.

Table 9. Kinds of tests and how they look with the Node.js runner
Kind What it exercises In Bookshelf

Unit

One function or class, with its collaborators replaced by test doubles. Milliseconds, no I/O.

isValidIsbn13(), Shelf, the notifier with a fake mailer.

Integration

Several real parts together: code and a real database, a router and its handlers.

The API with an in-memory node:sqlite database (see SQLite from Node.js).

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 fetch(); later, the same tests against a container (see Production Deployment).

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.

Table 10. Test doubles and the Node.js tools that build them
Double Purpose With node:test

Dummy

Fills a parameter that is never used.

A plain {} or null.

Stub

Returns canned answers (or errors) to drive the code down a path.

mock.fn(impl) or mockImplementationOnce() on the mock’s mock property.

Spy

Records how it was called while the real behaviour stays.

mock.fn() and mock.method(object, 'name'); inspect .mock.calls and .mock.callCount().

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 .mock.calls; there is no expectation DSL.

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.

src/notifier.js
// 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;
}
test/doubles.test.js: a spy, a stub, a fake and an auto-restored method spy
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.

slug.test.js, written before the code
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');
  });
});
Red: a first implementation that only lowercases (Node.js 26)
✖ lowercases and joins words with hyphens (0.53ms)
  + 'efficient node'
  - 'efficient-node'
✖ drops punctuation (0.43ms)
ℹ pass 0
ℹ fail 2
Green: the least code that satisfies both tests
export function slugify(title) {
  return title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
}
Green (Node.js 26)
✔ 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.

test/server.test.js
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();
  });
});
Result (identical on Node.js 26, 24 and 22)
  ✔ 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.

sum.test.ts
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.

Table 11. A practical comparison
Aspect node:test Jest Vitest Mocha

Installation

None: part of Node.js.

npm install --save-dev jest.

npm install --save-dev vitest.

npm install --save-dev mocha.

Globals

None: import test, describe, it, hooks.

Provides them as globals.

Imports by default; globals via a config option.

Provides them as globals.

Assertions

node:assert, strict form recommended.

expect() matchers.

expect(), Jest-compatible, Chai-style available.

Bring your own (node:assert or Chai).

Mocking

mock.fn(), mock.method(), mock.timers, mock.module() (Early development).

jest.fn(), jest.spyOn(), jest.mock() with module hoisting.

vi.fn(), vi.spyOn(), vi.mock().

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 (--test-rerun-failures reruns failures by hand).

jest.retryTimes().

A retry option.

A retries option.

Watch mode

--test --watch (Experimental).

Built in.

Built in; the default in a terminal.

--watch.

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.

Running the codemod
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).

A Mocha test before migration
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();
  });
});
The same test after applying the documented transformations
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

Table 12. Test runner changes by version, newest first (from the API history and the experiments above)
Version Change

v26.6.0

t.log(message, data) emits 'test:log' immediately, in execution order; entryFile is added to events forwarded from child processes.

v26.3.0

parentId is added to events that carry a testId.

v26.2.0 (also v24.19.0)

Test tags and --experimental-test-tag-filter (Early development), plus the testTagFilters option of run(); v26.2.0 marks stream.compose() stable.

v26.1.0 (v24.16.0 for the flags)

--test-randomize and --test-random-seed; getTestContext() (v24.19.0); t.passed and t.attempt on suites; the test runner publishes diagnostics_channel events for OpenTelemetry-style tracing.

v25.8.0 (v24.15.0)

t.workerId and NODE_TEST_WORKER_ID.

v25.7.0

The 'test:interrupted' event on SIGINT.

v25.6.0 (v24.14.0)

The env option of run().

v25.5.0 (v24.14.0)

expectFailure.

v25.0.0

assert.CallTracker removed (DEP0173). In the assert module, promises are no longer considered equal unless they are the same instance, and invalid dates are considered equal.

v24.7.0

--test-rerun-failures and the rerunFailuresFilePath option.

v24.0.0 (v22.17.0)

assert.partialDeepStrictEqual() becomes Stable; --test-global-setup (Early development).

v23.8.0

Per the Node.js Learn guide, t.test() is awaited automatically; I observed this on 24.21 and 26.10 but not on 22.23.

v23.6.0

--experimental-test-isolation renamed --test-isolation.

v23.4.0 (v22.13.0)

t.plan() and snapshot testing are no longer experimental.

v23.0.0

The default reporter on a non-TTY stdout changes from tap to spec; cwd and coverage options added to run().

v22.8.0

Test isolation option (--experimental-test-isolation), isolation in run().

v22.2.0 to v22.3.0

t.plan() and t.assert (22.2); snapshot testing (22.3); mock.module() (22.3, still Early development).

v20.0.0

The test runner becomes Stable.

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

Table 13. Common mistakes with the Node.js test runner
Mistake Fix

Forgetting await on t.test()

Await every subtest; Node.js 22 cancels the ones still running when the parent finishes.

Using the imported assert with t.plan()

Use t.assert.*; the plan counts only assertions made through the context.

Importing node:assert instead of node:assert/strict

Legacy equal() and deepEqual() coerce with ==; use the strict import.

Passing a directory to node --test

Pass a glob ("test/*/.js") or rely on the default patterns; a directory name ran zero tests on Node.js 26 and failed on 24 and 22.

Putting helpers in test/

Every file under test/ is run as a test; keep helpers elsewhere or name them so no pattern matches.

Committing .only

Run with --test-only locally and let CI run without it, so a stray only fails loudly instead of silently narrowing the run.

Expecting todo to fail the build

A failing TODO test never changes the exit code; use expectFailure (Node.js 24.14 or later) to make a known failure self-checking.

A hard-coded port in server tests

Listen on port 0 and read server.address().port.

Not closing servers and handles

Close in after() or t.after(); --test-force-exit hides the leak rather than fixing it.

Tests that share state under --test-isolation=none

Keep the default process isolation unless you have measured a start-up problem.

Using retries as a flakiness cure

node:test has none; fix the race, or use --test-rerun-failures knowingly.

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 run() or a custom reporter.

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