Built-in Test Runner
node:test — batteries included
Section titled “node:test — batteries included”Since Node 18, you can write and run tests with zero third-party dependencies. The node:test module provides test(), describe(), it(), before/after hooks, and TAP-compatible output. Pair it with node:assert for assertions.
Basic test anatomy
Section titled “Basic test anatomy”A test file is just a .js (or .mjs) file that imports node:test and node:assert:
import { test, describe, it } from 'node:test';import assert from 'node:assert/strict';
test('add returns the correct sum', function() { assert.equal(2 + 3, 5);});Run it from the terminal:
node --test math.test.jsOr run the whole suite with default discovery:
node --testWithout a path, node --test recursively discovers *.test.{js,mjs,cjs}, *-test.*, *_test.*, and test-*.* — plus every file inside any directory named test/, regardless of that file’s own name.
describe / it — grouping related tests
Section titled “describe / it — grouping related tests”describe groups related tests. it is an alias for test that reads naturally inside describe:
import { describe, it } from 'node:test';import assert from 'node:assert/strict';
describe('String utilities', function() { it('trim removes leading and trailing whitespace', function() { assert.equal(' hello '.trim(), 'hello'); });
it('split divides by delimiter', function() { assert.deepEqual('a,b,c'.split(','), ['a', 'b', 'c']); });});node --test string-utils.test.js# TAP output:# ok 1 - String utilities > trim removes leading and trailing whitespace# ok 2 - String utilities > split divides by delimiterAsync tests
Section titled “Async tests”Pass an async function to test or it. The runner awaits it — a rejected promise fails the test:
import { test } from 'node:test';import assert from 'node:assert/strict';
test('fetch-like async test', async function() { const result = await Promise.resolve(42); assert.equal(result, 42);});Lifecycle hooks — before / after / beforeEach / afterEach
Section titled “Lifecycle hooks — before / after / beforeEach / afterEach”before and after run once per describe block (or once for the whole file at the top level); beforeEach/afterEach run around every single test inside their scope:
import { describe, it, before, after, beforeEach, afterEach } from 'node:test';import assert from 'node:assert/strict';
describe('a resource pool', () => { let pool; before(() => { pool = []; console.log('before: pool created once'); }); after(() => { console.log('after: pool torn down once'); }); beforeEach(() => { pool.push('conn'); console.log('beforeEach: connection added'); }); afterEach(() => { pool.pop(); console.log('afterEach: connection removed'); });
it('has one connection during the test', () => { assert.equal(pool.length, 1); }); it('still has exactly one connection in the next test', () => { assert.equal(pool.length, 1); });});before: pool created oncebeforeEach: connection addedafterEach: connection removedbeforeEach: connection addedafterEach: connection removedafter: pool torn down once▶ a resource pool ✔ has one connection during the test ✔ still has exactly one connection in the next test✔ a resource poolℹ tests 2ℹ suites 1ℹ pass 2ℹ fail 0ℹ cancelled 0ℹ skipped 0ℹ todo 0before/after run exactly once regardless of how many tests are in the block; beforeEach/afterEach reset shared state so tests never leak into each other — notice the pool goes back to length 1 for the second test too, because afterEach popped the connection the first test added.
node:assert/strict cheat sheet
Section titled “node:assert/strict cheat sheet”// @skip-verify placeholder identifiers (actual/expected/obj1/obj2) — signature reference, not runnableimport assert from 'node:assert/strict';
assert.equal(actual, expected); // == (strict: ===)assert.notEqual(actual, expected); // !==assert.deepEqual(obj1, obj2); // deep structural equalityassert.notDeepEqual(obj1, obj2);assert.throws(fn, /pattern/); // fn must throw matching errorassert.rejects(asyncFn, /pattern/); // async fn must rejectassert.ok(value); // truthy checkAlways prefer node:assert/strict over node:assert — the strict variant uses === for scalar comparisons and avoids subtle type coercion surprises.
Live runnable demo
Section titled “Live runnable demo”The snippet below is a self-contained node:test mini-suite. It runs in node mode so you see real TAP output:
Needs the Node.js runtime — open in StackBlitz to run.
This one has no “verified output” panel on purpose: node:test’s default reporter prints a duration_ms on every line, which is different on every run — pasting one real run’s numbers into expected would make the panel misleadingly precise. The demos further down use mock/assert directly (without test()) specifically so their output is 100% deterministic and safe to show as verified.
Mocking and snapshots — stable, built in
Section titled “Mocking and snapshots — stable, built in”| Feature | Status in Node 24 / 26 |
|---|---|
mock.fn(), mock.method(), mock.timers | Stable |
mock.module() | Experimental — needs --experimental-test-module-mocks, prints a warning |
Snapshot testing — t.assert.snapshot(value) | Stable since 23.4 |
| Code coverage | Experimental — --experimental-test-coverage |
--watch for tests | Stable — no flag or warning |
Test tags — --experimental-test-tag-filter | Early development (26.2+) |
mock.fn — call tracking without a library
Section titled “mock.fn — call tracking without a library”mock.fn(implementation) wraps a function and records every call — arguments, return value, and count — on .mock. It works standalone, outside of test(), which is useful for a quick check right in this page:
Output (verified on Node 24)
result: 5
call count: 1
first call args: [ 2, 3 ]
assertion passed: called exactly oncemock.method — the trap of mocking the wrong reference
Section titled “mock.method — the trap of mocking the wrong reference”mock.method(object, 'methodName', impl) replaces object.methodName in place. It only affects call sites that look the property up on object at call time — a reference captured earlier by destructuring is a separate binding the mock never touches:
Output (verified on Node 24)
result: { id: 1, name: 'Ada' }
mock was actually called: 0
assertion passed: mocking db.save missed the real call site entirelymock.module — still experimental on Node 24
Section titled “mock.module — still experimental on Node 24”mock.module() replaces an entire module’s exports, ESM included — but on Node 24 it is gated behind --experimental-test-module-mocks and prints an ExperimentalWarning:
// @skip-verify needs --experimental-test-module-mocks, not passed by default hereimport { test, mock } from 'node:test';import assert from 'node:assert/strict';
test('mock.module replaces a module', async () => { mock.module('node:os', { namedExports: { hostname: () => 'fake-host' }, }); const os = await import('node:os'); assert.equal(os.hostname(), 'fake-host');});node --experimental-test-module-mocks mock-os.test.mjs✔ mock.module replaces a module(node:12345) ExperimentalWarning: Module mocking is an experimental feature and might change at any timeℹ tests 1ℹ suites 0ℹ pass 1ℹ fail 0ℹ cancelled 0ℹ skipped 0ℹ todo 0Without the flag, mock.module is not a function at all (TypeError: mock.module is not a function) — it does not silently no-op, so a missing flag fails loudly in CI rather than producing a false pass.
Fake timers let you test retry and timeout logic without waiting:
import { test, mock } from 'node:test';import assert from 'node:assert/strict';
// your helper — note the setTimeout is scheduled inside a .then(), not synchronouslyfunction retry(fn, options) { return fn().catch(function(err) { if (options.attempts <= 1) throw err; return new Promise(function(resolve) { setTimeout(resolve, options.delayMs); }) .then(function() { return retry(fn, { attempts: options.attempts - 1, delayMs: options.delayMs }); }); });}
test('retries call the fetcher twice', async function(t) { const fetcher = mock.fn(async function() { throw new Error('flaky'); }); mock.timers.enable({ apis: ['setTimeout'] }); // fake setTimeout
const pending = retry(fetcher, { attempts: 2, delayMs: 1000 });
await null; // let the pending .then() run and register the timer mock.timers.tick(1000); // advance the fake clock instantly
await assert.rejects(pending, /flaky/); assert.equal(fetcher.mock.callCount(), 2);
mock.timers.reset();});The setTimeout call above happens inside a .then() callback, which only runs as a microtask — it has not been registered yet at the moment tick() is called synchronously on the next line. await null yields once so that pending microtask runs and schedules the timer before tick() looks for it; skip it and this test hangs forever, because node:test has no default per-test timeout. Always pass --test-timeout=<ms> (e.g. node --test --test-timeout=2000) so a mistake like this fails fast instead of hanging CI.
Proof that skipping await null really does leave the fake clock with nothing to advance — no test wrapper, no hang, just the fake timer state:
Output (verified on Node 24)
tick() returned -- fake clock advanced, but nothing was scheduled yetInside an actual test(), that pending promise from above would never settle, await assert.rejects(pending, ...) would wait on it forever, and — with no --test-timeout set — the whole test run simply never ends.
Tests that pass while wrong
Section titled “Tests that pass while wrong”The most dangerous test is not the one that fails — it’s the one that reports green while testing nothing. node:test will not catch these for you; you have to know what to look for when reading a test file (yours or AI-generated).
No assertion at all
Section titled “No assertion at all”import { test } from 'node:test';
test('checks that createUser sets a default role', async () => { const user = { name: 'Ada' }; // BUG: forgot to call createUser() and check user.role // no assert.* call at all -- the test body just runs to completion});node --test no-assertion.test.mjs✔ checks that createUser sets a default roleℹ tests 1ℹ suites 0ℹ pass 1ℹ fail 0ℹ cancelled 0ℹ skipped 0ℹ todo 0A test that never calls assert.* (or t.assert.*) cannot fail — it “passes” by doing nothing. Reading a test file, always check that every it/test body ends in at least one assertion on the actual behavior it claims to check.
Async work that outlives the test — Node 24 actually catches this one
Section titled “Async work that outlives the test — Node 24 actually catches this one”// @expect-throw file exits non-zero -- Node 24 reports the leaked async assertion as a whole-file failureimport { test } from 'node:test';import assert from 'node:assert/strict';
test('rejects an invalid user (BUG: missing await/return)', () => { // BUG: no `return` and no `await` in front of the async work below -- // the test function itself returns undefined synchronously, so node:test // considers it "passed" before the promise below ever settles. Promise.resolve().then(() => { assert.equal(1, 2); // this failure is never seen by the test runner directly });});node --test missing-await.test.mjs✔ rejects an invalid user (BUG: missing await/return)ℹ Error: Test "rejects an invalid user (BUG: missing await/return)" at missing-await.test.mjs:4:1 generated asynchronous activity after the test ended. This activity created the error "AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
1 !== 2" and would have caused the test to fail, but instead triggered an unhandledRejection event.✖ missing-await.test.mjsℹ tests 2ℹ suites 0ℹ pass 1ℹ fail 1ℹ cancelled 0ℹ skipped 0ℹ todo 0The individual test still shows a ✔ — node:test already returned control before that .then() ran. But Node 24’s runner tracks async activity per test and notices work happening after the test ended; it reports the whole file as failed instead of silently swallowing it, which is safer than older Node releases. Do not rely on this safety net, though — always return/await the promise a test body creates, so the failure is attributed to the right test and does not depend on this detection:
// fixedimport { test } from 'node:test';import assert from 'node:assert/strict';
test('rejects an invalid user', async () => { await assert.rejects(async () => { throw new Error('invalid'); });});mock.method on the wrong reference
Section titled “mock.method on the wrong reference”Covered above in detail — mocking db.save never affects a save that was destructured out before the mock ran. The test can still pass (it may assert the real db.save was never called, expecting the mock) while actually proving the mock had zero effect on the code under test.
test.only, —test-only, and t.skip/t.todo
Section titled “test.only, —test-only, and t.skip/t.todo”import { test } from 'node:test';import assert from 'node:assert/strict';
test('this one is skipped without --test-only handling', () => { assert.equal(1, 1);});
test.only('only this one runs when --test-only is set', () => { assert.equal(2, 2);});# without --test-only: .only has NO effect, both tests runnode --test only.test.mjs
# with --test-only: only the marked test runs, the rest are skippednode --test --test-only only.test.mjs# without --test-only✔ this one is skipped without --test-only handling✔ only this one runs when --test-only is setℹ 'only' and 'runOnly' require the --test-only command-line option.ℹ tests 2ℹ pass 2
# with --test-only✔ only this one runs when --test-only is setℹ tests 1ℹ pass 1test.only is inert unless the runner is told to honor it. That is exactly why it is dangerous: a CI script that always passes --test-only will silently run fewer tests forever if someone forgets to remove a debugging .only, and the run still reports success.
t.skip (or { skip: true }) and t.todo (or { todo: true }) are the intentional, visible versions of “don’t run this normally”:
import { test } from 'node:test';import assert from 'node:assert/strict';
test('skipped test', { skip: true }, () => { assert.fail('never runs');});
test('todo test', { todo: true }, () => { assert.fail('reported as todo, does not fail the run');});
test('a normal passing test', () => { assert.ok(true);});node --test skip-todo.test.mjs﹣ skipped test # SKIP✖ todo test # TODO✔ a normal passing testℹ tests 3ℹ pass 1ℹ skipped 1ℹ todo 1A todo test that fails is still reported (with a ✖ and # TODO) but does not fail the overall run — the exit code stays 0. Unlike a silently-passing bug, skip/todo show up explicitly in the summary counts, so a reviewer can see how many tests are not actually being enforced.
Reporters and coverage
Section titled “Reporters and coverage”--test-reporter controls the output format. spec (the default, shown throughout this page) is human-readable; tap is the raw TAP13 protocol other tools can parse; dot is a compact one-character-per-test summary; junit produces CI-friendly XML. Combine with --test-reporter-destination to write to a file:
node --test --test-reporter=dot small.test.mjs.node --test --test-reporter=tap small.test.mjsTAP version 13# Subtest: addsok 1 - adds --- type: 'test' ...1..1# tests 1# suites 0Coverage is still --experimental-test-coverage on Node 24 and 26 — it works, but the flag name is the honest signal that its output format can still change:
node --test --experimental-test-coverage --test-coverage-functions=100 partial.test.mjs✔ only uses one functionℹ tests 1ℹ pass 1ℹ Error: 50.00% function coverage does not meet threshold of 100%.ℹ start of coverage report------------------------------------------------------------file | line % | branch % | funcs % | uncovered lines------------------------------------------------------------partial.mjs | 100.00 | 100.00 | 50.00 |------------------------------------------------------------all files | 100.00 | 100.00 | 50.00 |------------------------------------------------------------end of coverage reportThat run exits non-zero: --test-coverage-functions=100 (there are matching --test-coverage-lines and --test-coverage-branches thresholds) fails the whole process when actual coverage falls short — useful as a CI gate once you decide coverage numbers are worth enforcing.
Snapshot testing
Section titled “Snapshot testing”t.assert.snapshot(value) compares a value against a stored .snapshot file next to the test, serialized with util.inspect. The first run (or any run with --test-update-snapshots) writes the file; every later run compares against it:
// @skip-verify needs a pre-recorded .snapshot file (or --test-update-snapshots first) to passimport { test } from 'node:test';
test('snapshot demo', (t) => { t.assert.snapshot({ a: 1, b: [1, 2, 3] });});# first time, or after an intentional change: record the snapshotnode --test --test-update-snapshots snapshot.test.mjsexports[`snapshot demo 1`] = `{ "a": 1, "b": [ 1, 2, 3 ]}`;# every later run: compare against the recorded snapshot, fail on driftnode --test snapshot.test.mjsCommit the generated *.snapshot file to version control and review diffs to it exactly like you would review any other code change — a snapshot update is only safe once a human has confirmed the new value is correct, not merely different.
Test lifecycle, end to end
Section titled “Test lifecycle, end to end”flowchart TB
Discover["node --test discovers *.test.js, *-test.*, *_test.*, test-*.*, and everything under test/"] --> Before["before() -- once per file/describe"]
Before --> Loop{"For each test"}
Loop --> BE["beforeEach()"]
BE --> Run["test body runs (assert.* / t.assert.*)"]
Run --> AE["afterEach()"]
AE --> Loop
Loop -->|"all tests done"| After["after() -- once per file/describe"]
After --> Report["reporter (spec/tap/dot/junit) + coverage + exit code"] Mental Model
Section titled “Mental Model”node:test is not a mini-framework bolted onto Node — it is a thin, honest layer: test()/describe()/it() register work, hooks wrap it, mock/assert are plain objects you can use with or without a test() wrapper, and the reporter is the very last thing that runs. Nothing here is magic: a test “passes” purely because its function returned (or resolved) without throwing and without an unhandled rejection — which is exactly why an unawaited assertion or a missing assert.* call slips through silently.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
node:test over Jest/Vitest | Zero dependency, ships with every Node install, no config file | No built-in coverage UI, no snapshot review tool, no DOM/browser environment simulation |
--test-timeout set explicitly | A hung test (missing await null, unresolved promise) fails fast in CI instead of hanging the pipeline | One more flag every CI script has to remember to pass |
mock.module() | Can replace even built-in modules like node:os for a test | Experimental — behind a flag, prints a warning, and its behavior is not guaranteed stable release to release |
| Snapshot testing | Fast to write for complex output shapes | A rubber-stamped --test-update-snapshots after a change is a snapshot test that stops catching anything |
Common Misconceptions
Section titled “Common Misconceptions”- “A test that shows a green check mark actually tested something” — fact: a test with no
assert.*call, or one whose assertion runs after the test function already returned, reports as passed regardless. - “mock.method(obj, ‘name’, fn) affects every reference to that function anywhere in the codebase” — fact: it only patches the property slot on
obj. A destructured or otherwise pre-captured reference to the original function is untouched. - “Node’s built-in mocking, coverage, and snapshot support are all equally stable” — fact:
mock.fn/mock.method/mock.timersand snapshots are stable;mock.module()and--experimental-test-coverageare both still explicitly experimental on Node 24 and 26. - “test.only is a safe way to focus on one test while debugging” — fact: it is safe locally, but only because
--test-onlyis (usually) off there. In a CI script that always passes--test-only, a forgotten.onlysilently drops every other test from the run.
Reading AI-generated code
Section titled “Reading AI-generated code”- Does a test body end without a single
assert.*/t.assert.*call? It will pass no matter what the code under test actually does. - Is an async test’s inner work started with
.then()/a barePromisecall instead ofawaited orreturned? The test can finish and report a pass before that work — or its assertion — has run. - Does a
mock.methodtarget an object property, while the code under test imported or destructured that same function directly? The mock and the real call site may not be the same binding. - Is there a
test.onlyleft in a test file with no comment explaining why, right before a commit? It is either harmless (no--test-onlyin CI) or is silently disabling every sibling test (if there is). - Does a
mock.timerstest drivetick()immediately after starting async work, with noawait null(or equivalent yield) in between? The timer may not be registered yet, and the test can hang forever with no default timeout.
🐛 Spot the bug
import { test, mock } from 'node:test';import assert from 'node:assert/strict';
const emailer = {send(to, body) { /* real network call */ },};
import { send } from './emailer.js'; // destructured at module load time
async function notifyUser(user) {return send(user.email, 'Welcome!');}
test('notifyUser sends an email', async () => {mock.method(emailer, 'send', () => Promise.resolve('sent'));
assert.equal(emailer.send.mock.callCount(), 1);});Show the answer
notifyUser calls the send that was imported (destructured) directly from ./emailer.js — a separate binding from emailer.send. Mocking emailer.send replaces the property on the emailer object, but notifyUser’s send(...) call never looks at that object at all, so the mock is never hit. Depending on what the real send does, this test either throws (a real network call runs during “testing”) or, worse, passes for the wrong reason if send happens to return something assert.equal(emailer.send.mock.callCount(), 1) still fails on — in which case the bug is at least caught, just confusingly. The fix: either mock the actual imported binding (mock.method(emailerModule, 'send', ...) against the module namespace, or restructure notifyUser to call emailer.send(...)) so there is exactly one place to mock.
Beyond the basics
Section titled “Beyond the basics”# Only run tests matching a patternnode --test --test-name-pattern "Array"
# Watch mode — re-runs on file change (stable, no warning)node --test --watch
# Code coverage — the flag is still --experimental-test-coverage in Node 24 / 26node --test --experimental-test-coverage
# Parallel test files (the default); cap the workersnode --test --test-concurrency=4