Mocking, Snapshots & Coverage
|
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 example below was run on all three lines unless the text says otherwise. A test
double replaces a real collaborator (a network client, a mailer, the clock) so a test can run fast, deterministically and in isolation: a
spy records how it was called, a stub returns canned values, a mock is a double that the test also asserts on. The built-in node:test
module covers all of it without a third-party library: the MockTracker (the top-level mock export and the per-test t.mock), mock timers,
module mocks, snapshot assertions and a V8-based coverage report. Stability differs a lot between these features, so each section starts with its
level: mock.fn(), mock.method(), mock.getter(), mock.setter() and the mock timers are stable; mock.property() is stable on the current
documentation; mock.module() is Stability 1.0 (Early development) and needs a flag; snapshot testing is stable; code coverage and its flags are
Stability 1 (Experimental). The test runner itself, its discovery rules, hooks and assertions are in
The Test Runner; the clock-related primitives that mock timers replace are in Timers.
The running scenario is the Bookshelf application from Introduction & Architecture.
The stability and version map
| API | Stability (Node.js 26 docs) | Version notes |
|---|---|---|
|
2 Stable (the test runner is stable since v20.0.0) |
Added in v19.1.0 and v18.13.0 ( |
|
Listed without a separate stability note in the Node.js 26 docs |
Added in v24.3.0 and v22.20.0, so absent from older 22.x and 20.x releases. |
|
Stable since v23.1.0 |
Added in v20.4.0 and v18.19.0; the options-object form of |
|
1.0 Early development |
Added in v22.3.0 and v20.18.0; JSON modules supported from v24.0.0 and v22.17.0; needs |
Snapshot testing: |
Stable |
Added in v22.3.0; no longer experimental in v23.4.0 and v22.13.0. |
|
Stable |
Added in v23.7.0 and v22.14.0. |
|
1 Experimental |
Added in v19.7.0 and v18.15.0; usable together with |
|
1 Experimental |
Added in v22.8.0 (a threshold miss makes the process exit with code 1). |
|
1 Experimental |
Added in v22.5.0. |
|
1 Experimental |
Added in v26.7.0; on Node.js 24.21.0 and 22.23.3 the option is unknown and |
|
Stable |
Reporters are exposed at |
Spies and stubs with mock.fn()
mock.fn([original[, implementation]][, options]) returns a function that records every call. The returned function carries a mock property, a
MockFunctionContext, with calls (each entry has arguments, result, error, target and this), callCount(), mockImplementation(),
mockImplementationOnce(implementation[, onCall]), resetCalls() and restore(). The times option makes the substitute behaviour apply to a
limited number of calls and then fall back to the original. The first test below injects a fake fetch into a small catalogue client; the second one
scripts a failure followed by a success and uses times.
// src/catalog.js
export async function fetchBook(id, { fetchImpl = fetch } = {}) {
const res = await fetchImpl(`https://catalog.example.test/books/${id}`);
if (!res.ok) throw new Error(`catalog returned ${res.status}`);
return res.json();
}
Source: Node.js API: fetch
// test/fn.test.js
import { test, mock } from 'node:test';
import assert from 'node:assert/strict';
import { fetchBook } from '../src/catalog.js';
test('mock.fn records calls and returns canned data', async () => {
const fetchImpl = mock.fn(async () => ({ ok: true, json: async () => ({ id: 7, title: 'Dune' }) }));
const book = await fetchBook(7, { fetchImpl });
assert.deepEqual(book, { id: 7, title: 'Dune' });
assert.equal(fetchImpl.mock.callCount(), 1);
assert.deepEqual(fetchImpl.mock.calls[0].arguments, ['https://catalog.example.test/books/7']);
assert.equal(fetchImpl.mock.calls[0].error, undefined);
});
test('one-off behaviours with mockImplementationOnce and times', async () => {
const ok = { ok: true, json: async () => ({ id: 1 }) };
const fetchImpl = mock.fn(async () => ok);
fetchImpl.mock.mockImplementationOnce(async () => ({ ok: false, status: 503 }));
await assert.rejects(fetchBook(1, { fetchImpl }), /catalog returned 503/);
assert.deepEqual(await fetchBook(1, { fetchImpl }), { id: 1 });
assert.equal(fetchImpl.mock.callCount(), 2);
fetchImpl.mock.resetCalls();
assert.equal(fetchImpl.mock.callCount(), 0);
const flaky = mock.fn(() => 'real', () => 'fake', { times: 2 });
assert.deepEqual([flaky(), flaky(), flaky()], ['fake', 'fake', 'real']);
});
Both tests passed on Node.js 26, 24 and 22. Passing the dependency as a parameter (a default argument that points at the real fetch) is the least
magical way to make code testable; the rest of this page is for the cases where you cannot change the call site.
Replacing methods, getters and properties
mock.method(object, methodName[, implementation][, options]) replaces a method on an existing object with a recording proxy. With no
implementation it is a pure spy that still calls the original; with one it is a stub. mock.getter() and mock.setter() are shorthand for the
getter and setter options, and mock.property(object, propertyName[, value]) swaps a plain data property and exposes accesses and
accessCount() on its context. The distinction that matters is when the mock is undone. A mock made with t.mock belongs to that test and is
restored automatically when the test ends; a mock made with the top-level mock export lives until you call mock.restoreAll() (put the originals
back, keep the history) or mock.reset() (restore everything and clear recorded calls). The second and fourth tests below prove the automatic restore.
t.mock// test/method.test.js
import { test, mock } from 'node:test';
import assert from 'node:assert/strict';
const shelf = {
books: ['Dune', 'Emma'],
count() { return this.books.length; },
get label() { return 'Shelf A'; },
limit: 10,
};
test('spy on a method, then override it', (t) => {
const spy = t.mock.method(shelf, 'count');
assert.equal(shelf.count(), 2);
assert.equal(spy.mock.callCount(), 1);
assert.equal(spy.mock.calls[0].this, shelf);
spy.mock.mockImplementation(() => 99);
assert.equal(shelf.count(), 99);
});
test('the previous test restored count() automatically', () => {
assert.equal(shelf.count(), 2);
});
test('getter and property mocks', (t) => {
t.mock.getter(shelf, 'label', () => 'Mock shelf');
assert.equal(shelf.label, 'Mock shelf');
const limit = t.mock.property(shelf, 'limit', 3);
assert.equal(shelf.limit, 3);
limit.mock.mockImplementation(5);
assert.equal(shelf.limit, 5);
assert.equal(limit.mock.accessCount(), 3); // get, set (by mockImplementation), get
});
test('restored after the test too', () => {
assert.equal(shelf.label, 'Shelf A');
assert.equal(shelf.limit, 10);
});
test('mock.restoreAll() restores top-level mocks by hand', () => {
mock.method(shelf, 'count', () => 0);
assert.equal(shelf.count(), 0);
mock.restoreAll();
assert.equal(shelf.count(), 2);
});
All five tests passed on the three lines. Two observations from the run: changing a property mock with mockImplementation(5) is itself recorded
as a set access (so accessCount() is 3, not 2), and mock.property() is the newest member of the family (v24.3.0, v22.20.0), which matters for
projects that still support an earlier 22.x. The same technique stubs built-in modules for a single test (t.mock.method(fs, 'readFileSync', …) on
the default import of node:fs); that also worked on all three lines when checked. Prefer replacing your own seams over patching core modules, because
other code in the same process that calls the patched function sees the stub for as long as the test runs.
Mock timers
mock.timers is a MockTimers instance (stable since v23.1.0). enable({ apis, now }) takes over any of 'setInterval', 'setTimeout',
'setImmediate' and 'Date' (the default is all of them); tick(ms) advances the fake clock and fires what is due, runAll() fires every pending
timer at once (moving the mocked Date to the last timer’s time), setTime(ms) sets the reference timestamp, and reset() disables the mock (which is
also what timers[Symbol.dispose]() does, so using works). Use t.mock.timers so the clock is restored when the test ends. A debounce, which
would otherwise need a real 300 ms wait, is tested instantly.
// src/catalog.js (excerpt)
export function debounce(fn, ms) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), ms);
};
}
// test/timers.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { debounce } from '../src/catalog.js';
test('debounce with mocked timers', (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const fn = t.mock.fn();
const run = debounce(fn, 300);
run('a'); run('b');
t.mock.timers.tick(299);
assert.equal(fn.mock.callCount(), 0);
t.mock.timers.tick(1);
assert.deepEqual(fn.mock.calls[0].arguments, ['b']);
});
test('Date and runAll', (t) => {
t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: 1_000 });
const fn = t.mock.fn();
setTimeout(fn, 1000); setTimeout(fn, 2000);
t.mock.timers.runAll();
assert.equal(fn.mock.callCount(), 2);
assert.equal(Date.now(), 3000);
t.mock.timers.setTime(10_000);
assert.equal(Date.now(), 10_000);
});
Both tests passed on Node.js 26, 24 and 22. The mock replaces only the four listed APIs, so a test that awaits something else that waits (a promise-based delay from node:timers/promises, say) was not part of these runs; check the MockTimers documentation, or keep such waits behind a seam of your own.
Mocking modules (experimental)
mock.module(specifier[, options]) replaces the exports of an ES module, a CommonJS module, a JSON module or a built-in module. It is Stability 1.0
(Early development) and only works when Node.js starts with --experimental-test-module-mocks; with the flag Node
prints an ExperimentalWarning (observed on 26, 24 and 22). Three rules govern it. First, references obtained before the mock was installed are not
affected, so the module under test must be imported after the call, with a dynamic import(). Second, the specifier is resolved relative to the test
file, like an import there. Third, options.cache (default false) decides whether each import() or require() gets a fresh mock module. The
returned MockModuleContext has restore(), and t.mock.module() restores automatically at the end of the test. With the permission model enabled the
flag needs --allow-worker too.
// src/mailer.js
export function send(to, subject) {
throw new Error('real SMTP is not available in tests');
}
// src/orders.js
import { send } from './mailer.js';
export function confirmOrder(email, bookId) {
send(email, `Order for ${bookId} confirmed`);
return { email, bookId, status: 'confirmed' };
}
// test/module.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
test('mock a local ES module', async (t) => {
const send = t.mock.fn();
t.mock.module('../src/mailer.js', { exports: { send } });
const { confirmOrder } = await import('../src/orders.js'); // import AFTER mocking
assert.equal(confirmOrder('a@example.test', 7).status, 'confirmed');
assert.deepEqual(send.mock.calls[0].arguments, ['a@example.test', 'Order for 7 confirmed']);
});
Source: Node.js API: mock.module()
node --experimental-test-module-mocks --test test/module.test.js
The test passed on Node.js 26 and 24. On Node.js 22.23.3 the same code failed with does not provide an export named 'send': the 22.x documentation
describes the older namedExports and defaultExport options, and the exports option is not accepted there (observed). Writing namedExports: { send }
passed on 22, and on 24 and 26 it printed a DeprecationWarning pointing to options.exports. If one test suite must run on all three lines, wrap the call
in a tiny helper that picks the option by process.version, or avoid module mocks by injecting dependencies. Because the API is Early development, keep the
number of module mocks small and never let production code depend on it.
Snapshot testing
A snapshot test serializes a value to text and compares it with a stored, reviewed copy. In node:test the assertion is t.assert.snapshot(value[, options])
(use the test context’s assert, not the imported node:assert, so the runner can name the snapshot). The first run fails because the snapshot file does not
exist; rerun with --test-update-snapshots to write it, review the file like source code, commit it, and later runs compare against it. By default the file is
named like the test file plus .snapshot and holds one export per assertion, keyed by the full test name and a counter; snapshot.setResolveSnapshotPath(fn)
changes the location and snapshot.setDefaultSnapshotSerializers(serializers) changes how values are turned into text. t.assert.fileSnapshot(value, path[, options])
(v23.7.0, v22.14.0) stores a single value in a file you choose, useful for a rendered HTML fragment or a JSON document.
// test/snap.test.js
import { suite, test } from 'node:test';
suite('bookshelf snapshots', () => {
test('inline value', (t) => {
t.assert.snapshot({ id: 7, title: 'Dune', tags: ['sf'] });
t.assert.snapshot('plain text');
});
test('file snapshot', (t) => {
t.assert.fileSnapshot('<li>Dune</li>\n', new URL('./fixtures/list.html', import.meta.url).pathname);
});
});
node --test test/snap.test.js # fails: no snapshot file yet
node --test --test-update-snapshots test/snap.test.js # writes test/snap.test.js.snapshot and test/fixtures/list.html
node --test test/snap.test.js # passes: values match the stored copies
exports[`bookshelf snapshots > inline value 1`] = `
{
"id": 7,
"title": "Dune",
"tags": [
"sf"
]
}
`;
exports[`bookshelf snapshots > inline value 2`] = `
"plain text"
`;
In the run, the first command failed with a message saying the snapshot file could not be read and suggesting --test-update-snapshots, the second passed and
created both files, and the third passed on Node.js 26, 24 and 22. Changing Dune to Emma made both tests fail with a diff of the serialized strings. In the
observed output the fileSnapshot file contained the value with JSON quotes ("<li>Dune</li>\n"), which is the default serializer at work. Snapshots are a
good fit for large, stable structures such as an API response shape or a CLI help text, and a poor fit for values that change on every run (timestamps, random
ids, ordering that is not guaranteed): normalize or mock those first, with the tools from the previous sections, or the suite becomes noise that people
re-approve without reading. Never use --test-update-snapshots in CI, because it turns every mismatch into a pass.
Code coverage
Coverage uses V8’s built-in collection, so no instrumentation step or extra package is needed. node --test --experimental-test-coverage collects coverage
in every test process, merges it, and prints a table with line, branch and function percentages and the uncovered line ranges once the run is done (the spec
and tap reporters print it; if no tests run there is no report). Node.js core modules and anything under node_modules/ are excluded by default, and so are
the test files themselves. If NODE_V8_COVERAGE points to a directory the raw V8 files are written there. The whole feature is Stability 1 (Experimental) in
the Node.js 26 docs, so flag names and report format may change; the flags below were checked against the 26, 24 and 22 CLI pages and run.
| Flag | Effect | Availability |
|---|---|---|
|
Turns collection on; the test runner reports it through the |
22, 24, 26 |
|
Minimum percentage; if a total is below it the report prints an error and the process exits with code 1. |
22.8.0 and later |
|
Repeatable globs, matched against absolute and relative paths. If both are given, a file must satisfy both. Specifying |
22.5.0 and later |
|
Adds source files that no test loaded, with zero coverage, honouring include and exclude. Without it a never-imported file is invisible to the report. |
26.7.0 and later only |
A first run on the scratch project shows the default report, and also that a file nobody imported is simply missing:
start of coverage report
file | line % | branch % | funcs % | uncovered lines
src | | | |
catalog.js | 78.95 | 100.00 | 80.00 | 8-11
orders.js | 100.00 | 100.00 | 100.00 |
all files | 83.33 | 100.00 | 83.33 |
end of coverage report
Adding --test-coverage-include='src/*' --test-coverage-include-all on Node.js 26 listed mailer.js (replaced by the module mock, so never loaded) and the
untested unused.js at 0 percent, and the totals dropped to 74.19 percent of lines. This is the honest number: a file that no test loads is exactly what a
coverage gate should expose. Comment directives control what counts: / node:coverage disable / and / node:coverage enable / bracket a region, and
/ node:coverage ignore next / or / node:coverage ignore next 3 */ skip a number of lines.
// src/catalog.js (excerpt)
/* node:coverage ignore next 3 */
export function legacyFormat(price) {
return `$${price}`;
}
node --test --experimental-test-module-mocks \
--experimental-test-coverage \
--test-coverage-include='src/catalog.js' --test-coverage-include='src/orders.js' \
--test-coverage-lines=90 --test-coverage-branches=90 --test-coverage-functions=90
node --test --experimental-test-module-mocks --experimental-test-coverage \
--test-coverage-exclude='src/mailer.js' --test-coverage-exclude='test/**' \
--test-reporter=lcov --test-reporter-destination=lcov.info \
--test-reporter=spec --test-reporter-destination=stdout
With a discount() test added, the first command printed 100 percent for both files and exited with 0 on Node.js 26 and 24, while on 22.23.3 it exited with 1
only because the module-mock test of the previous section failed there (see the exports note above). The second command wrote lcov.info (records start with
SF:src/catalog.js and FN: lines) next to the normal spec output. Earlier runs, with a line threshold of 95 and the untested discount() still in place, printed
Error: 74.19% line coverage does not meet threshold of 95%. and exited with code 1 on Node.js 26, and the equivalent message on 24 and 22 with their own
percentages. Percentages differ between lines (they depend on which files are loaded and on the V8 version), so set thresholds from your own baseline, not
from a number in an article. In the Node.js 26 run the lcov file still listed the ignored function with zero hits (FNDA:0,legacyFormat), which is
observed behaviour rather than documented behaviour: lcov consumers may count it.
CI gates
A test suite protects the main branch only if a failure stops the merge. Everything above is expressed as process exit codes, which is what any CI system reads:
node --test exits non-zero when a test fails, and a missed coverage threshold exits with code 1 even if every test passed. A practical gate has five parts:
pin the Node.js line (and run the matrix of lines you support, see Release Lines & LTS), run tests with coverage and
thresholds, write a machine-readable report (junit for the CI’s test tab, lcov for a coverage service), keep the human-readable spec output on stdout, and
never run with --test-update-snapshots or --test-only. Signals and exit codes of the test process are covered in
Process, Signals & Exit Codes.
{
"scripts": {
"test": "node --test",
"test:snap": "node --test --test-update-snapshots",
"test:ci": "node --test --experimental-test-module-mocks --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-exclude='test/**' --test-coverage-lines=90 --test-coverage-branches=85 --test-coverage-functions=90 --test-reporter=spec --test-reporter-destination=stdout --test-reporter=junit --test-reporter-destination=reports/junit.xml --test-reporter=lcov --test-reporter-destination=reports/lcov.info"
}
}
name: test
on: [pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node: [22, 24, 26]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: npm ci
- run: npm run test:ci
The package scripts were not run as one command; each flag in test:ci was run in the commands above. Two cautions for the matrix: the module-mock option name
differs between Node.js 22 and later lines (previous section), and --test-coverage-include-all is not accepted before Node.js 26.7.0, so keep it out of a
shared script or put it behind a condition. A reasonable policy is a floor that never decreases (set it a little below today’s number and raise it with the
code), plus a review of the uncovered lines column in the pull request; coverage measures which lines ran, not whether the assertions were good.
What changed recently
| Version | Change |
|---|---|
v20.4.0, v18.19.0 |
Mock timers added; the options-object form of |
v22.3.0, v20.18.0 |
Snapshot testing and |
v22.5.0, v22.8.0 |
Coverage include and exclude globs (22.5.0); line, branch and function thresholds (22.8.0). |
v22.13.0, v23.4.0 |
Snapshot testing is no longer experimental; mock timers were declared stable in v23.1.0. |
v22.14.0, v23.7.0 |
|
v24.0.0, v22.17.0 |
|
v24.3.0, v22.20.0 |
|
v26.7.0 |
|
Node.js 26 did not change the stable parts of this page, and in the documentation the coverage flags and module mocks remain at Stability 1 and 1.0, so plan for them
to move. Related test-runner additions of the 26 line (test tags, randomized order, t.diagnostic) are Early development and belong to
The Test Runner.
Where the books are outdated
Efficient Node.js by Samer Buna (O’Reilly, 2025) has a testing chapter that teaches the same test doubles (mock.fn, mock.method, mock.timers) and
recommends gating CI on coverage in general terms, but it does not cover snapshot testing, module mocks or any of the coverage flags above, and it predates
the stability changes of v23.1.0 and v23.4.0 and the coverage-related additions of v26.7.0. Treat its coverage advice as a pointer and use this page for flag
names and thresholds. Node.js Projects by Jonathan Wexler (O’Reilly, 2025) points to external frameworks (Vitest, Jest, Supertest) for testing; for projects
without a need for their extras, the built-in runner described here, together with
The Test Runner, is now sufficient. Older articles that call mock timers or snapshots experimental, or install c8
or nyc only for threshold enforcement, describe versions before v22.8 and v23.4.
Pitfalls
| Mistake | Remedy |
|---|---|
Importing the module under test statically and then calling |
Use |
Using the top-level |
Prefer |
Mocked clock left running |
Use |
Updating snapshots blindly |
Review the diff of the snapshot file in the pull request; never run the update flag in CI. |
Gating on a coverage number copied from another project |
Measure your baseline, enforce a floor, and include never-loaded files ( |
Writing mock-heavy tests that mirror the implementation |
Prefer injecting small dependencies and asserting on observable results; keep mocks for real boundaries (network, clock, mail). |