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

Table 1. APIs covered on this page
API Stability (Node.js 26 docs) Version notes

mock.fn(), mock.method(), mock.getter(), mock.setter(), MockFunctionContext

2 Stable (the test runner is stable since v20.0.0)

Added in v19.1.0 and v18.13.0 (getter and setter in v19.3.0 and v18.13.0).

mock.property()

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.

mock.timers (MockTimers)

Stable since v23.1.0

Added in v20.4.0 and v18.19.0; the options-object form of enable() in v21.2.0 and v20.11.0; setTime() in v21.2.0 and v20.11.0.

mock.module(), MockModuleContext

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 --experimental-test-module-mocks; the exports option replaces the deprecated defaultExport and namedExports options.

Snapshot testing: t.assert.snapshot(), --test-update-snapshots, snapshot.setResolveSnapshotPath()

Stable

Added in v22.3.0; no longer experimental in v23.4.0 and v22.13.0.

t.assert.fileSnapshot()

Stable

Added in v23.7.0 and v22.14.0.

--experimental-test-coverage

1 Experimental

Added in v19.7.0 and v18.15.0; usable together with --test since v20.1.0 and v18.17.0.

--test-coverage-lines, --test-coverage-branches, --test-coverage-functions

1 Experimental

Added in v22.8.0 (a threshold miss makes the process exit with code 1).

--test-coverage-include, --test-coverage-exclude

1 Experimental

Added in v22.5.0.

--test-coverage-include-all

1 Experimental

Added in v26.7.0; on Node.js 24.21.0 and 22.23.3 the option is unknown and node exits with code 9 (observed).

lcov and junit reporters

Stable

Reporters are exposed at node:test/reporters since v19.9.0 and v18.17.0; lcov only has content together with coverage.

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.

A catalogue client that takes its fetch implementation as a parameter
// 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();
}
Spying, stubbing and one-off behaviour
// 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.

Auto-restoring mocks with 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.

Testing a debounce and the clock without waiting
// 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.

Replacing a mailer module for one test
// 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']);
});
Running it
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.

Two snapshot assertions
// 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);
  });
});
Generating, checking and updating snapshots
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
The generated snapshot file
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.

Table 2. Flags and what they do
Flag Effect Availability

--experimental-test-coverage

Turns collection on; the test runner reports it through the test:coverage event to every reporter.

22, 24, 26

--test-coverage-lines=n, --test-coverage-branches=n, --test-coverage-functions=n

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

--test-coverage-include=glob, --test-coverage-exclude=glob

Repeatable globs, matched against absolute and relative paths. If both are given, a file must satisfy both. Specifying --test-coverage-exclude overrides the default exclusion of test files, so exclude test/** yourself.

22.5.0 and later

--test-coverage-include-all

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:

Default coverage report (Node.js 26)
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.

Ignoring lines and a passing run
// src/catalog.js (excerpt)
/* node:coverage ignore next 3 */
export function legacyFormat(price) {
  return `$${price}`;
}
Thresholds, include globs and an lcov file
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.

package.json scripts for local and CI use
{
  "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"
  }
}
A GitHub Actions job that gates a pull request (illustrative, not executed here)
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

Table 3. Timeline of the relevant changes
Version Change

v20.4.0, v18.19.0

Mock timers added; the options-object form of enable() and setTime() followed in v21.2.0 and v20.11.0.

v22.3.0, v20.18.0

Snapshot testing and mock.module() added.

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

t.assert.fileSnapshot() added.

v24.0.0, v22.17.0

mock.module() supports JSON modules.

v24.3.0, v22.20.0

mock.property() added.

v26.7.0

--test-coverage-include-all added.

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

Table 4. Common mistakes
Mistake Remedy

Importing the module under test statically and then calling mock.module()

Use await import() after the mock; references obtained earlier are not rewritten.

Using the top-level mock and forgetting to restore

Prefer t.mock, or call mock.restoreAll() or mock.reset() in a hook.

Mocked clock left running

Use t.mock.timers, or dispose the timers object (using) so Date and timers return.

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 (--test-coverage-include-all on 26.7+ or an explicit list).

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).