Node.js Built-in Test Runner (node:test deep features)
Parent: JavaScript and Node.js · researched 2026-06-02T18:17:19.155Z· 6 sources · 7 concepts · skill nodejs-test-runner
This reference is about using node:test as a real test framework — the depth
Overview
- This reference is about using node:test as a real test framework - the depth [source]
- that lets it stand in for Jest or Mocha: nested subtests, lifecycle hooks, a full [source]
- mocking system (functions, methods, properties, timers, modules), built-in coverage [source]
- with failing thresholds, pluggable reporters, a CLI filtering/sharding/isolation [source]
- model, and snapshot testing. It assumes you already know how to write one [source]
- It is the "now build a suite" companion to three siblings that own neighbouring layers: [source]
- javascript-nodejs - owns the intro: your first test(), assert.strict [source]
- basics, ESM/CJS module syntax. This file does not re-teach the first test or the [source]
- primitives of assert. [source]
- software-engineering-patterns - owns the third-party runners Vitest and [source]
- Jest (Vitest's vi mocks, Jest matchers, Jest→Vitest migration). This file is [source]
- the built-in runner only; reach there if the project uses a framework. [source]
- nodejs-builtin-modules-modern - owns the generic --watch flag and the rest [source]
- of the batteries-included surface (node:util parseArgs, etc.). This file covers [source]
- --watch only as a test re-run mode. [source]
- The mental model: node:test is TAP-emitting and isolation-by-default. The [source]
- test() function and the TestContext/MockTracker objects are the API; the --test* [source]
- CLI flags are the runner that discovers files, isolates them in child processes, [source]
- filters by name, and aggregates reporters - both halves matter. Two import surfaces: [source]
- functions come from node:test (test, describe, it, before, mock, …); reporter [source]
- classes from node:test/reporters (spec, tap, lcov, …). The runner is **stable [source]
- since Node.js 20** (coverage thresholds and snapshots stabilized later - noted per feature). [source]
1. Test structure: test / describe / it, subtests, and the `TestContext`
- test(name, options?, fn) registers a test; fn receives a TestContext named t [source]
- (and may be async, or use the second done callback for callback-style). describe() [source]
- (alias suite()) groups tests and it() (alias of test) reads naturally inside it — [source]
- describe/it is the BDD style, bare test() the flat style. Both compose. [source]
- Subtests: call t.test(name, fn) to nest a test inside a parent test. The [source]
- parent must await its subtests (or await Promise.all([...])) - an un-awaited [source]
- subtest is cancelled when the parent finishes and reported as failing. This is the [source]
- single most common node:test mistake. [source]
- The TestContext t exposes: [source]
- t.diagnostic(message) - emit a TAP diagnostic line (not an assertion). [source]
- t.plan(count, options?) - assert that exactly count assertions/subtests run; [source]
- { wait } can wait for asynchronous assertions. The test fails if the count is off. [source]
- t.skip(message?) / t.todo(message?) - mark the running test skipped/todo at [source]
- runtime; t.runOnly(bool) enables only-mode for this context's subtests. [source]
- t.signal - an AbortSignal aborted on timeout/cancellation; pass it into [source]
- fetch/timers so work cancels with the test. t.name, t.fullName, t.filePath [source]
- t.waitFor(fn, options?) - poll fn until it stops throwing (for eventually-true [source]
- conditions), with interval/timeout. [source]
- t.assert - per-test assertion methods (see §7); t.mock - a per-test [source]
- MockTracker (see §2) that auto-restores after the test. [source]
- describe/suite callbacks get a SuiteContext (signal, name) rather than a [source]
2. Lifecycle hooks: before / after / beforeEach / afterEach
- Four hooks, importable as top-level functions or callable as t.before*: [source]
- before(fn, options?) / after(fn, options?) - run once per suite (file or [source]
- describe block), around all its tests. after runs even if tests fail. [source]
- beforeEach(fn, options?) / afterEach(fn, options?) - run around each test in [source]
- the current suite; afterEach runs even when the test fails (use it for teardown). [source]
- HookOptions: { signal, timeout } - abort/limit a slow hook independently of [source]
- the tests. Hooks receive the test/suite context as their argument. [source]
- Hooks nest: an outer describe's beforeEach runs before each test in nested [source]
- describes too. Scope hooks to a block by declaring them inside that describe, or [source]
- per-test via t.beforeEach(). [source]
3. Mocking functions, methods, getters/setters, and properties
- The MockTracker (import { mock } from 'node:test', or per-test t.mock) creates [source]
- spies/stubs and tracks calls. Top-level mock persists across tests - call [source]
- mock.reset()/mock.restoreAll() (or prefer t.mock, which auto-restores). [source]
- mock.fn(original?, implementation?, options?) - a mock function; without args [source]
- it's a no-op spy. options.times lets implementation apply for the first N calls [source]
- then fall back to original. [source]
- mock.method(object, methodName, implementation?, options?) - replace a method [source]
- in place while spying; restored on restore(). mock.getter() / mock.setter() [source]
- mock an accessor; mock.property(object, propertyName, value?) (Node 24+) mocks a [source]
- plain data property's reads/writes. [source]
- Introspection - the MockFunctionContext at someMock.mock: [source]
- mock.calls - array of call records ({ arguments, result, error, this, stack }). [source]
- mock.callCount() - number of invocations. [source]
- mock.mockImplementation(fn) / mock.mockImplementationOnce(fn, onCall?) - swap the [source]
- implementation, permanently or for one (specific) call. [source]
- mock.resetCalls() - clear recorded calls; mock.restore() - undo this one mock. [source]
4. Mocking time: `mock.timers`
- mock.timers fakes timers and Date so time-dependent code is deterministic and fast. [source]
- mock.timers.enable({ apis, now }) - turn on faking for the chosen apis: [source]
- setTimeout, setInterval, setImmediate, Date, and scheduler.wait. Faked APIs [source]
- cover the globals and node:timers + node:timers/promises. now seeds the [source]
- starting time (number or Date). [source]
- mock.timers.tick(ms) - advance fake time by ms, synchronously firing every [source]
- timer that would have elapsed (and moving the faked Date). runAll() fires all [source]
- pending timers and jumps to the last one's time. setTime(ms) sets the current [source]
- faked Date without running timers. [source]
- mock.timers.reset() - clear scheduled timers and restore real ones (also done by [source]
- mock.reset() / disable). Faking Date makes Date.now() / new Date() follow the [source]
5. Module mocking: `mock.module()` (experimental)
- mock.module(specifier, options?) replaces a module's exports for code imported [source]
- after the mock is installed - covering ESM, CommonJS, JSON, and builtin modules. [source]
- Requires the --experimental-test-module-mocks flag (and the API is experimental). [source]
- options: namedExports (object of named exports), defaultExport (the default), [source]
- and cache (default false - by default the module is freshly evaluated and the [source]
- real cache is untouched; references obtained before mocking are not affected). [source]
- Because mocks apply to subsequent loads, the module under test must be brought in via [source]
- dynamic import() after the mock.module() call (static top-level imports are [source]
- already resolved). The mock is reverted by restore() / restoreAll(). [source]
6. Coverage, reporters, and the filtering / execution model
- Coverage. Start with --experimental-test-coverage to collect and print line/ [source]
- branch/function coverage after the run. Thresholds are stable (Node 22.8+): [source]
- --test-coverage-lines=<pct>, --test-coverage-branches=<pct>, [source]
- --test-coverage-functions=<pct> make the process exit non-zero when coverage is [source]
- below target. --test-coverage-exclude=<glob> / --test-coverage-include=<glob> tune [source]
- scope; node_modules/, core modules, and the matched **test files themselves are [source]
- excluded by default. Emit a real report with the lcov** reporter (below) for CI. [source]
- Reporters (node:test/reporters): spec (human-readable, the CLI default on a [source]
- TTY), tap (TAP, the default when piped), dot (compact ./X), junit [source]
- (JUnit XML for CI), and lcov (an lcov.info file, only meaningful with [source]
- --experimental-test-coverage). Select with --test-reporter and route output with [source]
- --test-reporter-destination (stdout or a file). The two flags pair positionally, [source]
- so you can run several at once - e.g. spec to the terminal and junit to a file. [source]
- A custom reporter is any module default-exporting a function/stream that consumes the [source]
- TestsStream events (test:pass, test:fail, test:diagnostic, test:coverage, …). [source]
- Filtering & execution. node --test discovers files by default globs: .test., [source]
- -test., _test., files named test.*, files starting with test-, and **any [source]
- .js/.cjs/.mjs under a test/ directory** (recursively). You can instead pass quoted [source]
- glob args. Filter by name with --test-name-pattern (regex; tests whose name [source]
- matches run) and --test-skip-pattern (regex; matches are skipped) - supply both [source]
- and a test must satisfy both. only-mode: mark test('x', { only: true }, …) (or [source]
- t.runOnly(true)) and run with --test-only to execute just those. [source]
- --test-concurrency caps parallel files (default availableParallelism() - 1). [source]
- --test-isolation chooses process (default - each file in its own child process, [source]
- crash-isolated) or none (all files in one process; faster, shares state, forces [source]
- concurrency 1). --test-shard=<i>/<n> runs only the i-th of n shards for splitting [source]
- across machines (incompatible with watch). --watch with --test re-runs affected [source]
- tests on file change (generic --watch semantics live in [source]
- nodejs-builtin-modules-modern). [source]
7. Snapshots and assertions
- Snapshot testing (stable, Node 22.3+ / marked stable later): t.assert.snapshot(value, options?) [source]
- compares value to a stored snapshot. Generate/update snapshots by running with [source]
- --test-update-snapshots; the snapshot file defaults to <testfile>.snapshot. [source]
- Customize serializers via options.serializers (array of value => string [source]
- functions, applied in order) and relocate files with [source]
- snapshot.setResolveSnapshotPath() / set global serializers with [source]
- snapshot.setDefaultSnapshotSerializers(). [source]
- Assertions: node:test integrates node:assert/strict - use [source]
- assert.strictEqual, assert.deepStrictEqual, assert.throws, assert.rejects, [source]
- assert.match, etc. Inside a test, prefer **t.assert.*** (the same assert methods, [source]
- plus t.assert.snapshot): assertions made through t.assert are counted by t.plan() [source]
- and attributed to the test in the reporter output. [source]
Practical patterns
- Always await subtests (await t.test(...), or await Promise.all([...]) for [source]
- parallel siblings) so they're counted and not orphaned. [source]
- Prefer t.mock over the global mock - per-test mocks auto-restore at test end, [source]
- so you never leak a stub into the next test. [source]
- Pass t.signal into async work (fetch(url, { signal: t.signal }), timers) so a [source]
- timeout actually cancels the in-flight operation instead of leaking it. [source]
- Fake timers for time-logic: t.mock.timers.enable({ apis: ['setTimeout','Date'] }) [source]
- then tick() to drive debounce/retry/TTL code deterministically - no real waiting. [source]
- CI reporter combo: `node --test --experimental-test-coverage [source]
- --test-reporter=spec --test-reporter-destination=stdout [source]
- --test-reporter=junit --test-reporter-destination=junit.xml [source]
- --test-reporter=lcov --test-reporter-destination=lcov.info` - human output, a JUnit [source]
- artifact, and an lcov file in one run. [source]
- Gate merges on coverage: add `--test-coverage-lines=80 --test-coverage-branches=75 [source]
- --test-coverage-functions=80`; the non-zero exit fails the job. [source]
- Shard wide suites across CI machines: --test-shard=1/4 … 4/4 on four runners. [source]
Anti-patterns
- Un-awaited subtests - the parent finishes, the subtest is cancelled and reported [source]
- as failing. The #1 node:test footgun. [source]
- Reusing the global mock without restoring - stubs bleed across tests; use [source]
- t.mock or call mock.restoreAll() in afterEach. [source]
- Static-importing the module you intend to mock.module() - the import already [source]
- resolved before the mock installed; import it dynamically after mocking. [source]
- Forgetting --experimental-test-module-mocks - mock.module() silently does [source]
- nothing (or throws) without the flag. [source]
- Treating --experimental-test-coverage as a gate - collection alone never fails [source]
- the build; you need the --test-coverage- threshold* flags for that. [source]
- Real timers / real sleeps in tests - slow and flaky; fake them with mock.timers. [source]
- --test-isolation=none while expecting per-file isolation - all files share one [source]
- process and global state; a leak in one file corrupts others. [source]
- Reaching for Jest/Vitest mocking idioms (jest.fn, vi.mock) here - wrong API; [source]
- those frameworks are a software-engineering-patterns concern. [source]
Troubleshooting
- Subtest "was cancelled" / counts wrong → await the t.test() call (or the [source]
- Promise.all of them); check t.plan() matches the real assertion count. [source]
- mock.module() has no effect → confirm --experimental-test-module-mocks is set [source]
- and the target is loaded by dynamic import() after the mock. [source]
- Timer mocks don't fire → you didn't tick()/runAll(), or the API wasn't in the [source]
- enable({ apis }) list; remember faking covers node:timers/promises too. [source]
- Coverage report empty / not failing → --experimental-test-coverage only prints; [source]
- add --test-coverage-lines/-branches/-functions to fail, and check [source]
- --test-coverage-exclude didn't exclude your sources (test files are excluded by [source]
- No JUnit/lcov file written → each --test-reporter needs its own paired [source]
- --test-reporter-destination; lcov also requires --experimental-test-coverage. [source]
- Tests not discovered → the file doesn't match the default globs and isn't under a [source]
- test/ dir; rename to *.test.js or pass a quoted glob to --test. [source]
- --test-name-pattern skips everything → it's a regex over the full test name; [source]
- with --test-skip-pattern both must pass. Anchor/escape as needed. [source]
- Snapshot always fails → first run needs --test-update-snapshots to create the [source]
- baseline; non-deterministic values (dates, ids) need a custom serializers entry. [source]
References
- Node.js - Test runner (node:test: test/describe/it, hooks, TestContext, MockTracker, mock.timers, mock.module, snapshots, coverage, reporters): https://nodejs.org/api/test.html [source]
- Node.js - Command-line API (--test, --test-name-pattern, --test-skip-pattern, --test-only, --test-concurrency, --test-isolation, --test-shard, --experimental-test-coverage, --test-coverage-lines/-branches/-functions, --test-coverage-exclude/-include, --test-reporter, --test-reporter-destination, --experimental-test-module-mocks, --test-update-snapshots): https://nodejs.org/api/cli.html [source]
- Node.js Learn - Using Node.js's test runner (structure, file discovery, watch, only): https://nodejs.org/learn/test-runner/using-test-runner [source]
- Node.js Learn - Mocking in tests (mock.fn/method/getter/setter, mock.timers, mock.module): https://nodejs.org/learn/test-runner/mocking [source]
- Node.js Learn - Collecting code coverage (flags, thresholds, lcov): https://nodejs.org/learn/test-runner/collecting-code-coverage [source]
- Node.js - node:test/reporters (spec, tap, dot, junit, lcov, custom reporters): https://nodejs.org/api/test.html#test-reporters [source]
Children
- Test structure: test/describe/it, subtests, and the TestContext (frontier)
- Lifecycle hooks: before/after/beforeEach/afterEach (+ hook options) (frontier)
- Mocking functions, methods, getters/setters, and properties (mock.fn/method) (frontier)
- Mocking time: mock.timers (frontier)
- Module mocking: mock.module() (--experimental-test-module-mocks) (frontier)
- Coverage, reporters, and the filtering/execution model (--test-* flags, isolation, sharding) (frontier)
- Snapshots (t.assert.snapshot) and assertions (node:assert/strict + t.assert.*) (frontier)
Frontier under this node: Coverage, reporters, and the filtering/execution model (--test-* flags, isolation, sharding), Lifecycle hooks: before/after/beforeEach/afterEach (+ hook options), Mocking functions, methods, getters/setters, and properties (mock.fn/method), Mocking time: mock.timers, Module mocking: mock.module() (--experimental-test-module-mocks), Snapshots (t.assert.snapshot) and assertions (node:assert/strict + t.assert.*), Test structure: test/describe/it, subtests, and the TestContext