jsguides

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.

OptionTypeDefaultNotes
concurrencynumber | booleanfalse (top level) / null (inherited)true schedules subtests concurrently; false runs them serially. Added in v18.0.0.
timeoutnumber (ms)InfinityFails the test if it does not finish in time. Added in v18.7.0 / v16.17.0.
signalAbortSignalAborting the signal cancels the test. Added in v18.8.0 / v16.18.0.
skipboolean | stringfalseA string is recorded as the reason.
onlybooleanfalseFilter to .only tests. Requires --test-only or an .only ancestor.
expectFailureboolean | string | RegExp | Function | ErrorfalseThe test must throw to pass. Added in v25.5.0 / v24.14.0.
tagsstring[][]Experimental since v26.2.0. Hooks inherit their suite’s tags rather than declaring new ones.
plannumberExact 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:

EventWhenFields
test:startA test begins, declaration ordername, file, line, column, nesting, testId
test:completeA test ends, execution ordersame as test:start plus details.passed, details.duration_ms, details.error.cause
test:pass / test:failPass / fail edgedetails.attempt, details.error.cause
test:diagnostict.diagnostic() calllevel: 'info' | 'warn' | 'error', message
test:planSubtest plan completescount
test:summaryFinal per-file plus cumulativecounts.tests, counts.suites, counts.passed, counts.failed, counts.skipped, counts.cancelled, counts.topLevel, duration_ms, success
test:coverageAfter summary, if coverage is onper-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.

FlagPurposeAdded in
--testDiscover test files and run themv18.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-onlyHonour .only markersv18.0.0 / v16.17.0
--test-concurrency <n>Process-level concurrencyv18.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-coverageEnable coveragev23.0.0 / v22.10.0
--test-update-snapshotsWrite snapshot files instead of comparingv22.3.0
--test-rerun-failures <path>Re-run only the tests in the failures filev24.7.0
--test-randomize / --test-random-seed=<n>Run tests in a random orderv26.1.0 / v24.16.0
--watchRe-run on file changes (Stability 1)v19.2.0 / v18.13.0
--test-isolation=process|nonePer-file child process or shared processv22.8.0
--experimental-test-module-mocksRequired 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>.

ReporterOutputBest for
specHuman-readable, one line per testLocal development
tapTAP version 13Pipes into other tools
dotOne dot per test, then a summaryWatch mode
junitJUnit XMLCI dashboards
lcovlcov.info fileCoverage 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