node:test
test([name[, options[, fn]]]) The node:test module is Node’s built-in test runner. It ships in the runtime, has no dependencies, and covers what most projects need: test functions, suites, hooks, subtests, mocks, mock timers, snapshot assertions, and code coverage. The runner itself reached stable status in v20.0.0; two pieces (mock.module() and test tags) are still labelled Stability 1.0 in v26.x, and coverage is still Stability 1.
Import the module with the node: scheme. require('test') does not work; only the namespaced form is registered.
import test from 'node:test'; // default export: test, it, describe, suite
import { describe, it, mock } from 'node:test';
Quick start
Create a test file with the suffix .test.js, write at least one test() call inside, and then run node --test from the project root. The runner discovers the file, executes each test, and prints the result to stdout. The default reporter is the human-readable spec format on a TTY and raw TAP version 13 when stdout is piped elsewhere, so the same test file works for both local runs and CI logs without any configuration.
// math.test.js
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
test('adds two numbers', () => {
assert.strictEqual(2 + 3, 5);
});
Run the runner from the project root. The output below is the non-TTY TAP form; in an interactive terminal the same command produces a one-line-per-test spec view instead.
$ node --test
TAP version 13
# Subtest: adds two numbers
ok 1 - adds two numbers
---
duration_ms: 1.2
...
# tests 1
# pass 1
# fail 0
node --test discovers files matching **/*.test.{cjs,mjs,js}, **/test-*.{cjs,mjs,js}, **/test.{cjs,mjs,js}, **/test/**/*.{cjs,mjs,js}, and the matching -test.* / _test.* variants. With TypeScript files (when not running with --no-strip-types), the same patterns under .cts, .mts, and .ts are included.
Defining tests
test() and its alias it() register a test case. describe() and its alias suite() register a group of tests. Each accepts an optional name, an options object, and a fn.
import { test, it, describe, suite } from 'node:test';
test('top-level test', () => {});
it('alias for test', () => {});
describe('a suite', () => {
it('child test', () => {});
it.skip('skipped', () => {});
it.runOnly(true);
});
Test functions come in three styles: synchronous, promise-returning, and callback-style. Pick one per test, since mixing the callback form with a returned Promise is a failure; the runner cannot tell which result signal to honour, so the test always fails no matter which path would have succeeded.
// 1. Sync — passes unless it throws
test('sync', () => {
throw new Error('fails');
});
// 2. Promise — passes on resolve, fails on reject
test('async', async () => {
const data = await fetch('https://example.test/api').then(r => r.json());
if (!data.ok) throw new Error('not ok');
});
// 3. Callback — passes if done() is called with a falsy first argument
test('callback', (t, done) => {
setImmediate(done);
});
The test function receives a TestContext (the t argument) that exposes assertions, hooks, subtests, and metadata. When you use the callback form, t is the first argument and the Node-style done callback is the second. The same four lifecycle hooks that are available at suite scope (before, after, beforeEach, afterEach) are also exposed on t, which scopes them to the surrounding test() block. That is the right shape when one block of tests owns a resource that should not leak to siblings.
import { test } from 'node:test';
test('with a scoped hook', async (t) => {
let dir;
t.before(async () => {
const { mkdtemp } = await import('node:fs/promises');
const { tmpdir } = await import('node:os');
const { join } = await import('node:path');
dir = await mkdtemp(join(tmpdir(), 'scoped-'));
});
t.after(async () => {
const { rm } = await import('node:fs/promises');
await rm(dir, { recursive: true, force: true });
});
await t.test('uses the directory', async () => {
if (!dir) throw new Error('before hook did not run');
});
});
Options reference
The options object on test(), it(), describe(), and suite() accepts the same keys.
| Option | Type | Default | Notes |
|---|---|---|---|
concurrency | number | boolean | false (top level) / null (inherited) | true schedules subtests concurrently; false runs them serially. Added in v18.0.0. |
timeout | number (ms) | Infinity | Fails the test if it does not finish in time. Added in v18.7.0 / v16.17.0. |
signal | AbortSignal | — | Aborting the signal cancels the test. Added in v18.8.0 / v16.18.0. |
skip | boolean | string | false | A string is recorded as the reason. |
only | boolean | false | Filter to .only tests. Requires --test-only or an .only ancestor. |
expectFailure | boolean | string | RegExp | Function | Error | false | The test must throw to pass. Added in v25.5.0 / v24.14.0. |
tags | string[] | [] | Experimental since v26.2.0. Hooks inherit their suite’s tags rather than declaring new ones. |
plan | number | — | Exact number of t.assert calls plus subtests expected. |
expectFailure also accepts an object form { label, match } to combine a reason with an assert.throws-style matcher. When skip and expectFailure are both set, skip wins.
test('throws TypeError', {
expectFailure: { label: 'expected', match: TypeError },
}, () => {
throw new TypeError('expected');
});
Subtests and suites
describe() (and its alias suite()) automatically awaits every test and subtest registered inside it. test() does not. Subtests created with t.test() are cancelled if the parent test function returns first, and the only way to keep them alive is to await the returned promise.
import { test, describe } from 'node:test';
test('top-level', async (t) => {
await t.test('a', () => {
// runs before the parent finishes
});
// t.test() is fire-and-forget if you do not await it,
// and the runner will fail the parent when it exits first.
});
describe('suite', () => {
// Tests here are awaited automatically; no `await` needed.
it('a', () => {});
it('b', () => {});
});
Marking one nested subtest with .only does nothing on its own: every ancestor test() and describe() between the top of the file and that subtest must also be marked .only, or the runner must be invoked with --test-only.
Hooks
before, after, beforeEach, and afterEach run around tests in the surrounding suite. after runs even when tests fail, which makes it the right place to close connections and release handles.
import { describe, it, before, after, beforeEach, afterEach } from 'node:test';
import { mkdtemp, rm, writeFile, readFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
describe('temp file', () => {
let dir;
let file;
before(async () => {
dir = await mkdtemp(join(tmpdir(), 'hook-'));
file = join(dir, 'data.txt');
});
after(async () => {
await rm(dir, { recursive: true, force: true });
});
beforeEach(async () => {
await writeFile(file, 'reset\n');
});
it('appends a line', async () => {
const current = await readFile(file, 'utf8');
await writeFile(file, current + 'appended\n');
});
});
The same four hooks are exposed on t (t.before, t.after, t.beforeEach, t.afterEach), which scopes them to the surrounding test() block. The options argument accepts signal and timeout for any hook.
Assertions, plans, and snapshots
t.assert mirrors node:assert/strict, so t.assert.strictEqual(a, b) behaves the same as assert.strictEqual from the strict assertion module. Use it instead of bare assert when you pair it with t.plan(); only assertions routed through t.assert are counted.
import { test } from 'node:test';
test('plan counts assertions', (t) => {
t.plan(2);
t.assert.strictEqual(1 + 1, 2);
t.assert.ok(true);
});
t.plan(count, { wait }) takes an optional wait option: true waits indefinitely, false checks at function end, and a number is a millisecond timeout. Skip t.plan if you intend to use plain assert; the plan will pass no matter how many bare assert calls fire.
Snapshot tests serialize a value and compare it against a file next to the test (default <testFile>.snapshot). On the first run, pass --test-update-snapshots to write the file.
import { test } from 'node:test';
test('serialized config', (t) => {
t.assert.snapshot({
host: 'localhost',
port: 3000,
flags: ['--test', '--watch'],
});
});
t.assert.fileSnapshot(value, path) writes the snapshot to a path of your choosing. To swap the default JSON.stringify serializer for something that handles cycles, Map, Set, or BigInt, call snapshot.setDefaultSnapshotSerializers([...]) from the top-level snapshot export.
Mocking
mock is a MockTracker instance, exported at the top level and attached to every TestContext as t.mock. Mocks bound to a context are restored automatically when the test ends. Mocks created through the top-level mock keep their association until you call mock.reset() or mock.restoreAll().
mock.fn and mock.method
mock.fn(original, implementation, options) wraps a function so you can inspect calls and substitute behavior. mock.method(object, name, implementation, options) does the same for a method on an object, with getter / setter flags for intercepting accessors.
import { test, mock } from 'node:test';
import { strict as assert } from 'node:assert';
test('stubs Date.now', () => {
const now = mock.method(Date, 'now', () => 1700000000000);
assert.strictEqual(Date.now(), 1700000000000);
assert.strictEqual(now.mock.callCount(), 1);
assert.deepStrictEqual(now.mock.calls[0].arguments, []);
now.mock.restore();
});
The mock object on every call exposes calls (an array of { arguments, error, result, stack, target, this }), callCount(), mockImplementation(fn), mockImplementationOnce(fn, onCall?), resetCalls(), and restore(). callCount() is a getter-backed integer, which is cheaper than reading calls.length for hot paths.
mock.property
mock.property(object, name, value) mocks a plain property on an object and exposes a MockPropertyContext with accesses (an array of { type: 'get' | 'set', value, stack }), accessCount(), and the same mockImplementationOnce shape. Added in v24.3.0 / v22.20.0.
mock.module
mock.module(specifier, options) replaces a module’s exports for the duration of the test. It is still Stability 1.0 in v26.x, requires the --experimental-test-module-mocks CLI flag, and ignores asynchronous module customization hooks.
// app.test.js
import { test, mock } from 'node:test';
import { strict as assert } from 'node:assert';
test('uses a fake config', async () => {
mock.module('./config.js', {
exports: { default: { env: 'test' }, port: 0 },
});
const { env } = (await import('./app.js')).default;
assert.strictEqual(env, 'test');
});
options.exports is the modern replacement for the deprecated defaultExport and namedExports shapes. Set options.cache: true if you want the mock to land in the CommonJS require cache as well.
Mock timers and dates
mock.timers is a MockTimers instance exposed on both the top-level mock and t.mock. Enabling it patches setTimeout, setInterval, setImmediate, and Date on the global object, on node:timers, and on node:timers/promises. With no argument, everything is mocked; pass { apis: ['setInterval'] } to scope the patch.
import { test, mock } from 'node:test';
import { strict as assert } from 'node:assert';
test('debounce fires after tick', () => {
mock.timers.enable({ apis: ['setTimeout'] });
let called = 0;
setTimeout(() => called++, 100);
mock.timers.tick(100);
assert.strictEqual(called, 1);
mock.timers.reset();
});
tick(ms) advances the clock synchronously and fires any timers that have come due. runAll() fires every pending timer and jumps Date to the latest scheduled time. setTime(ms) adjusts the mocked Date without firing timers scheduled in the past. The instance is itself disposable: mock.timers[Symbol.dispose]() calls reset().
One trap worth surfacing: destructured imports of setTimeout from node:timers are not patched. The same applies to node:timers/promises. Use the global, or read the function off the module namespace.
// bad: setTimeout still points at the real function
import { setTimeout } from 'node:timers';
mock.timers.enable();
setTimeout(() => called++, 50);
mock.timers.tick(50); // nothing fires
// good: use the namespace, then call through it
import * as nodeTimers from 'node:timers';
mock.timers.enable();
nodeTimers.setTimeout(() => called++, 50);
mock.timers.tick(50); // the callback runs
Running tests programmatically
The run(options) function returns a TestsStream, a Readable stream of structured events. Use it to embed the runner inside another tool, to roll a custom reporter, or to wire the runner into a long-lived process.
import { run } from 'node:test';
import { tap } from 'node:test/reporters';
run({ files: ['./tests'] })
.compose(tap)
.pipe(process.stdout);
The most useful events on the stream:
| Event | When | Fields |
|---|---|---|
test:start | A test begins, declaration order | name, file, line, column, nesting, testId |
test:complete | A test ends, execution order | same as test:start plus details.passed, details.duration_ms, details.error.cause |
test:pass / test:fail | Pass / fail edge | details.attempt, details.error.cause |
test:diagnostic | t.diagnostic() call | level: 'info' | 'warn' | 'error', message |
test:plan | Subtest plan completes | count |
test:summary | Final per-file plus cumulative | counts.tests, counts.suites, counts.passed, counts.failed, counts.skipped, counts.cancelled, counts.topLevel, duration_ms, success |
test:coverage | After summary, if coverage is on | per-file, totals, threshold result |
run() accepts an option for almost every CLI flag. Two worth highlighting: run({ isolation: 'none' }) runs everything in the current process and lets test files share state (default is 'process', one child per file), and run({ watch: true }) enables watch mode, which is incompatible with --test-randomize and --test-reporter=junit.
getTestContext() returns the current TestContext or SuiteContext from inside a test or hook, or undefined outside. It is the easy way to grab t.tags or t.workerId from a helper that does not receive t as an argument.
Code coverage
Enable coverage with the CLI flag or the run() option.
node --test --experimental-test-coverage --test-reporter=lcov --test-reporter-destination=lcov.info
Pass the same flags as options to run() to drive the runner programmatically. The function returns a TestsStream; compose it with a built-in reporter and pipe the output wherever you need it, or wire it into a long-lived process that runs tests on demand.
import { run } from 'node:test';
run({
files: ['./tests'],
coverage: true,
coverageIncludeGlobs: ['src/**/*.js'],
coverageExcludeGlobs: ['**/*.test.js'],
lineCoverage: 80,
branchCoverage: 70,
functionCoverage: 80,
});
The default glob includes your test files and excludes node_modules/ and Node core. The coverageIncludeGlobs and coverageExcludeGlobs are AND’d when both are set: a file must match both to appear in the report. The threshold options (lineCoverage, branchCoverage, functionCoverage) are percentages; missing a threshold exits with code 1.
CLI flags
A subset of the most common flags. Run node --help test for the full list.
| Flag | Purpose | Added in |
|---|---|---|
--test | Discover test files and run them | v18.0.0 / v16.17.0 |
--test-name-pattern <regex> | Filter test cases by name (repeatable, AND’d) | v18.0.0 / v16.17.0 |
--test-skip-pattern <regex> | Skip test cases by name (repeatable, AND’d) | — |
--test-only | Honour .only markers | v18.0.0 / v16.17.0 |
--test-concurrency <n> | Process-level concurrency | v18.7.0 / v16.17.0 |
--test-reporter <name> | spec (TTY), tap (non-TTY), dot, junit, lcov | — |
--test-reporter-destination <path> | Reporter output path (repeatable) | — |
--test-coverage | Enable coverage | v23.0.0 / v22.10.0 |
--test-update-snapshots | Write snapshot files instead of comparing | v22.3.0 |
--test-rerun-failures <path> | Re-run only the tests in the failures file | v24.7.0 |
--test-randomize / --test-random-seed=<n> | Run tests in a random order | v26.1.0 / v24.16.0 |
--watch | Re-run on file changes (Stability 1) | v19.2.0 / v18.13.0 |
--test-isolation=process|none | Per-file child process or shared process | v22.8.0 |
--experimental-test-module-mocks | Required for mock.module() | v22.3.0 / v20.18.0 |
--experimental-test-tag-filter=<tag> | Run tests with a given tag (experimental) | v26.2.0 |
Default discovery globs match **/*.test.{cjs,mjs,js}, **/*-test.*, **/*_test.*, **/test-*.{cjs,mjs,js}, **/test.{cjs,mjs,js}, and **/test/**/*.{cjs,mjs,js}. Pass explicit globs as positional arguments to override: node --test "**/*.spec.js". Wrap them in double quotes to keep the shell from expanding the patterns first.
Reporters
The default reporter depends on the TTY. In a TTY it is spec; outside a TTY it is tap. Pick another with --test-reporter=<name>.
| Reporter | Output | Best for |
|---|---|---|
spec | Human-readable, one line per test | Local development |
tap | TAP version 13 | Pipes into other tools |
dot | One dot per test, then a summary | Watch mode |
junit | JUnit XML | CI dashboards |
lcov | lcov.info file | Coverage visualisation |
Custom reporters are ES modules that export a default async function receiving the TestsStream. The function can stream.on('test:pass', ...) and stream.on('test:fail', ...) to render whatever format it wants. The built-in reporters live at node:test/reporters and are good starting points.
Common pitfalls
A handful of behaviours that surprise people the first time they hit them.
Mixing callback and Promise fails the test. test('t', (t, done) => Promise.resolve()) always fails, even if the Promise resolves. The doc calls this out explicitly.
Subtests inside test() are cancelled if the parent returns first. await every t.test(...) call inside a parent test. describe() does the awaiting for you, which is the main reason to reach for it.
t.skip() does not abort the test function. It records state and returns. The rest of the body keeps running, so an early return is still your responsibility.
test('skip does not stop execution', (t) => {
t.skip('not ready yet');
// the line below still runs and fails the test
t.assert.ok(false);
});
t.plan ignores bare assert calls. Only t.assert.* is counted. A test that calls assert.strictEqual and exits passes its plan check no matter what.
mock.timers.enable does not patch destructured imports. import { setTimeout } from 'node:timers' keeps the real setTimeout. Use the global, or read the function off the module namespace.
import { test, mock } from 'node:test';
import { setTimeout } from 'node:timers';
test('destructured setTimeout is not patched', () => {
mock.timers.enable();
let called = 0;
setTimeout(() => called++, 50);
mock.timers.tick(50);
// called is still 0; mock.timers did not patch the local binding
});
mock.module() is experimental. It needs --experimental-test-module-mocks and is Stability 1.0. Prefer the modern options.exports shape over the deprecated defaultExport and namedExports.
coverageIncludeGlobs and coverageExcludeGlobs are AND’d. A file that matches only one of them is excluded. The default is to include test files and exclude node_modules/ and Node core.
--test-name-pattern filters tests inside running files, not files. The runner still discovers and loads every matched file, then drops the ones that fail the name filter.
--test-rerun-failures keys by file path and line:column. Adding or moving tests invalidates the failures file, because their identity changes.
Tag values are normalised to lowercase, deduplicated, and inherited. A child test’s tags are the union of its own and its suite’s. Hooks do not declare their own tags.
See also
/reference/node-modules/assert/— the assertion library that backst.assert/reference/node-modules/timers/— the scheduling functions thatmock.timerspatches/reference/built-in-objects/abortcontroller/— what to pass as thesignaloption/reference/node-modules/process/—process.exitCodesemantics when tests fail/tutorials/testing-javascript/testing-js-intro/— series entry point for testing tutorials/tutorials/testing-javascript/testing-mocking/— a deeper walkthrough of the mocking helpers/tutorials/testing-javascript/testing-coverage-and-ci/— wiring coverage into CI