Testing with node:test
Node ships a test runner, an assertion library, a mocking library, a snapshot engine and a coverage
reporter. No npm install, no config file, no transpiler. That is genuinely new territory for
JavaScript, and it is the reason this chapter exists: most engineers’ testing knowledge is Jest-shaped,
and the built-in runner is close enough to be familiar and different enough to trip you up.
Everything here was executed on Node 22.22.2 — 70 tests, 68 passing, 1 skipped, 1 todo, 0 failures. Where a feature needs a newer Node than this box has, the version table in section 1 says so, and I have marked which claims are measured here versus read from the docs.
Table of contents
- 1. What you get, and since when
- 2. Running tests
- 3. Test structure and hooks
- 4.
node:assertin depth - 5. The test context
- 6. Mocking with
mock.fn - 7. Mocking objects:
mock.methodand friends - 8. Mocking time
- 9. Mocking modules
- 10. Snapshot testing
- 11. Coverage
- 12. Reporters and CI
- 13. TypeScript
- 14. The programmatic API
- 15. Testing a service through its seams
- 16. Gotchas found by running this
- 17. node:test vs Jest vs Vitest
- 18. Interview questions
1. What you get, and since when
The runner shipped in Node 18, stabilized in Node 20, and has been gaining features every release since. Version numbers matter here more than in most topics, because the gap between “in the docs” and “in your runtime” is where the frustration lives.
| Feature | Status | Added |
|---|---|---|
test(), describe(), it(), hooks | Stable | 18.0 |
node:assert | Stable (predates the runner) | — |
Reporters: spec, tap, dot, junit, lcov | Stable | 19.9 |
mock.fn, mock.method, mock.getter, mock.setter | Stable | 19.1 |
mock.property | Stable | 22.3 |
Watch mode (--watch) | Experimental | 19.2 |
Coverage (--experimental-test-coverage) | Experimental | 20.1 |
mock.timers | Experimental | 20.4 |
--test-name-pattern | Stable | 20.1 |
Snapshot testing (t.assert.snapshot) | Experimental 22.3, Stable 23.4 | 22.3 |
mock.module | Experimental (needs --experimental-test-module-mocks) | 22.3 |
context.waitFor() | Stable | 22.8 |
Test isolation (--experimental-test-isolation) | Experimental | 22.8 |
context.plan() | Stable | 22.11 |
Default reporter changed tap -> spec | — | 23.0 |
Global setup/teardown (--test-global-setup) | Experimental | 24.0 |
it.expectFailure | — | 25.5 |
Test randomization (--test-randomize) | Experimental | 26.1 |
Test tags (--experimental-test-tag-filter) | Experimental | 26.2 |
--test-rerun-failures | Experimental | recent |
Two version facts worth internalizing:
- On Node 22 the default reporter is TAP; from 23.0 it is
spec. If your output looks like machine noise, you are on 22 and want--test-reporter=spec. mock.moduleis still experimental at Node 26 and needs a flag. Everything else in the mocking API is stable. That asymmetry drives a lot of design advice in section 9.
Node 24 is the current LTS line and Node 26 the current mainline release; if you are starting fresh,
24 LTS gives you stable snapshots, the spec default, and global setup/teardown.
2. Running tests
node --test # discover and run everything (see the glob rules below)
node --test test/ # only this directory
node --test money.test.ts basics.test.mjs # explicit files
node --test --watch # rerun on change
node --test --test-reporter=spec # human-readable (the default from Node 23)
node --test --test-name-pattern='places an order' # filter by name (a RegExp)
node --test --test-skip-pattern='integration' # inverse filter
node --test --test-only # run only tests marked `only`
node --test --test-concurrency=4 # parallelism across files
node --test --test-timeout=5000 # global per-test timeout
node --test --test-shard=1/3 # CI sharding: run a third of the files
node --test --experimental-test-coverage # coverage
node --test --test-force-exit # kill the process even if a handle is open
Discovery rules
With no file arguments, Node walks the current directory (skipping node_modules) and treats a file as
a test if it matches any of:
**/*.test.{js,mjs,cjs,ts,mts,cts}
**/*-test.{js,...} **/*_test.{js,...}
**/test.{js,...} **/test-*.{js,...}
**/test/** **/*.spec.{js,...} (via the generic patterns above)
--experimental-test-isolation=none runs everything in one process (faster, but shared global state);
the default is one process per file, which is what makes the --test-concurrency parallelism safe.
Exit codes, measured
| Situation | Exit code |
|---|---|
| all pass | 0 |
a skip and a failing todo and nothing else | 0 |
| one real assertion failure | 1 |
That second row is the one to know: a todo test whose body throws does not fail the run. The error
is still printed in the “failing tests” section, which looks alarming in CI logs, but the exit code is 0.
todo means “known-broken, do not block the build”.
3. Test structure and hooks
Two equivalent styles. Pick one per file; mixing them reads badly.
import { test, describe, it, before, after, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
// Flat style — a name and a function. My default for unit tests.
test('the flat form: a name and a function', () => {
assert.equal(1 + 1, 2);
});
test('async tests just return a promise', async () => {
assert.equal(await Promise.resolve(42), 42);
});
// Subtests: the test context can create children. They must be awaited.
test('subtests via the test context', async (t) => {
await t.test('nested one', () => assert.ok(true));
await t.test('nested two', () => assert.ok(true));
});
// BDD style — familiar from Jest/Mocha.
describe('describe/it (BDD form)', () => {
before(() => {}); // once, before the first test in this suite
after(() => {}); // once, after the last
beforeEach(() => {}); // before every test
afterEach(() => {}); // after every test
it('runs the first test', () => {});
it('runs the second test', () => {});
});
Hook order, asserted rather than described
graph TD
A["before<br/>(once, first)"] --> B["beforeEach<br/>(before test 1)"]
B --> C["test 1"]
C --> D["afterEach<br/>(after test 1)"]
D --> E["beforeEach<br/>(before test 2)"]
E --> F["test 2"]
F --> G["afterEach<br/>(after test 2)"]
G --> H["after<br/>(once, last)"]
I ran this suite and recorded the actual sequence:
const order = [];
before(() => order.push('before'));
after(() => order.push('after'));
beforeEach(() => order.push('beforeEach'));
afterEach(() => order.push('afterEach'));
it('runs the first test', () => order.push('test1'));
it('runs the second test', () => order.push('test2'));
it('saw hooks in the documented order', () => {
assert.deepEqual(order, ['before', 'beforeEach', 'test1', 'afterEach',
'beforeEach', 'test2', 'afterEach', 'beforeEach']);
});
The trailing 'beforeEach' is the interesting part: the third test’s own beforeEach has already run by
the time its body executes, so it sees itself in the list. after never appears, because the assertion
runs before it. Small thing, but it is the kind of detail that turns a hook question from recitation into
understanding.
Modifiers
test.skip('never executed', () => { throw new Error('unreachable'); });
test.todo('reported, failure tolerated', () => { throw new Error('still fine'); });
test.only('the only one that runs, with --test-only', () => {});
// Or as options, which composes better with computed conditions:
test('conditionally skipped', { skip: process.platform === 'win32' }, () => {});
test('with a reason', { skip: 'waiting on the vendor fix' }, () => {});
// Or from inside, when the decision needs runtime information:
test('runtime skip', (t) => {
if (!process.env.DATABASE_URL) return t.skip('no database configured');
// ...
});
test('slow one', { timeout: 30_000 }, async () => {});
test('parallel', { concurrency: true }, async () => {});
describe('a whole suite in parallel', { concurrency: 4 }, () => {});
only requires --test-only to have any effect, which is deliberate: a stray .only cannot silently
skip your whole suite in CI the way it can in Jest.
4. node:assert in depth
Always import node:assert/strict. The legacy module’s equal and deepEqual use ==, which will
eventually let a bug through.
import assert from 'node:assert/strict';
// equivalently: import { strict as assert } from 'node:assert';
| Assertion | Checks |
|---|---|
assert.ok(v) / assert(v) | truthiness |
assert.equal(a, b) | === (in strict mode) |
assert.notEqual(a, b) | !== |
assert.deepEqual(a, b) | recursive structural equality including prototypes |
assert.notDeepEqual(a, b) | the negation |
assert.throws(fn, expected?) | fn throws, optionally matching |
assert.doesNotThrow(fn) | fn does not throw |
assert.rejects(asyncFn, expected?) | returns a promise — must be awaited |
assert.doesNotReject(asyncFn) | same |
assert.match(str, re) / doesNotMatch | regex against a string |
assert.fail(msg?) | always fails |
assert.ifError(err) | fails if err is truthy — for callback APIs |
assert.partialDeepStrictEqual(a, b) | b’s properties are a subset of a’s (Node 22.13+) |
The four ways to match a thrown error
const boom = () => { throw new TypeError('bad shape: qty'); };
assert.throws(boom, TypeError); // by constructor
assert.throws(boom, /bad shape/); // by message regex
assert.throws(boom, { name: 'TypeError', message: 'bad shape: qty' }); // by shape
assert.throws(boom, (e) => e instanceof TypeError && e.message.includes('qty')); // by predicate
The shape form is the most useful for custom errors, because you can assert on your own fields:
await assert.rejects(() => svc.place({ sku: 'NOPE', qty: 1 }),
{ name: 'OutOfStockError', sku: 'NOPE' });
The two traps
assert.rejects returns a promise. Forgetting await means the assertion never runs and the test
passes vacuously — one of the most common silent-green bugs in async test suites.
assert.rejects(() => f()); // WRONG: passes even if f() resolves
await assert.rejects(() => f()); // right
deepEqual compares prototypes. Two objects with identical contents fail if one has a different
prototype. This bit me for real while writing section 15 — see
Gotcha 2.
class P { constructor() { this.a = 1; } }
assert.throws(() => assert.deepEqual(new P(), { a: 1 }), assert.AssertionError);
What node:assert does not have
No expect(x).toBe(y) chain, no toHaveBeenCalledWith, no custom matchers, no toMatchObject (until
partialDeepStrictEqual). If your team depends on rich matchers, that is the strongest argument for
Vitest — see section 17. What you can do is write plain helper
functions, which are more discoverable than a registry of matchers:
const assertCalledWith = (spy, args, msg) =>
assert.deepEqual(spy.mock.calls.map(c => c.arguments), args, msg);
5. The test context
The t argument is not decoration — it is how you get scoped mocks, subtests, and diagnostics.
test('the context surface', async (t) => {
t.name; // 'the context surface'
t.diagnostic('a comment that appears in the report');
t.skip('reason'); // skip from inside
t.todo('reason');
t.plan(3); // expect exactly 3 assertions (see the gotcha below)
await t.test('subtest', () => {});
t.mock; // a MockTracker scoped to THIS test, auto-restored
t.signal; // an AbortSignal aborted on timeout — pass it to fetch/streams
await t.waitFor(() => assert.ok(condition), { timeout: 1000 }); // poll until it passes
t.assert.snapshot(value); // snapshot, and the only assertions t.plan can count
});
t.signal is underused and genuinely good: pass it to anything cancellable and a timing-out test
actually tears its work down instead of leaking a pending request.
test('cancels the request when the test times out', { timeout: 500 }, async (t) => {
const res = await fetch(url, { signal: t.signal });
assert.equal(res.status, 200);
});
t.waitFor replaces the sleep-and-hope pattern for eventually-consistent assertions: it retries the
callback until it stops throwing or the timeout expires.
The t.plan gotcha, measured
t.plan(n) counts assertions made through the test context (t.assert.*) and subtests. Bare
node:assert calls are invisible to it. My first version of this test failed:
✖ t.plan pins the number of assertions
'plan expected 2 assertions but received 0'
…even though the body made two assert.ok(true) calls. The working version:
test('t.plan counts t.assert.* calls, not bare assert calls', (t) => {
t.plan(2);
t.assert.ok(true);
t.assert.equal(1, 1);
});
test('t.plan also counts subtests', async (t) => {
t.plan(1);
await t.test('the one planned subtest', () => assert.ok(true));
});
So t.plan is really “plan the number of context assertions or subtests”. Its actual value is guarding
async code paths — proving a callback ran, or that an error branch was taken exactly once.
6. Mocking with mock.fn
mock.fn creates a function that records every call and lets you swap its behaviour. It is a spy and a
stub in one object.
import { mock } from 'node:test';
const fn = mock.fn((a, b) => a + b);
fn(1, 2); fn(3, 4);
fn.mock.callCount(); // 2
fn.mock.calls[0].arguments; // [1, 2]
fn.mock.calls[0].result; // 3
fn.mock.calls[0].error; // undefined
fn.mock.calls[0].this; // the receiver
fn.mock.calls[0].target; // undefined for a plain call, the ctor for `new`
fn.mock.calls[0].stack; // a stack trace of the call site
Every call record has exactly these six keys — asserted:
assert.deepEqual(Object.keys(fn.mock.calls[0]).sort(),
['arguments', 'error', 'result', 'stack', 'target', 'this']);
Programming the behaviour
const fn = mock.fn(() => 'default');
fn.mock.mockImplementation(() => 'forever'); // replace permanently
fn.mock.mockImplementationOnce(() => 'once'); // replace for the next call only
fn.mock.mockImplementationOnce(() => 'third', 2); // replace for call index 2 specifically
fn.mock.resetCalls(); // clear history, keep the implementation
fn.mock.restore(); // put the original back (for method mocks)
Verified behaviour:
const fn = mock.fn(() => 'default');
fn.mock.mockImplementationOnce(() => 'once');
assert.deepEqual([fn(), fn(), fn()], ['once', 'default', 'default']);
const g = mock.fn(() => 'default');
g.mock.mockImplementationOnce(() => 'third', 2); // zero-indexed call number
assert.deepEqual([g(), g(), g(), g()], ['default', 'default', 'third', 'default']);
Errors are recorded, not swallowed:
const fn = mock.fn(() => { throw new Error('kaboom'); });
assert.throws(() => fn(), /kaboom/);
assert.equal(fn.mock.calls[0].error.message, 'kaboom');
assert.equal(fn.mock.calls[0].result, undefined);
And mock.fn() with no implementation is a pure spy that returns undefined — useful for callbacks and
for logger interfaces you only want to assert on.
Jest translation
| Jest | node:test |
|---|---|
jest.fn() | mock.fn() |
jest.fn(impl) | mock.fn(impl) |
fn.mock.calls | fn.mock.calls.map(c => c.arguments) |
expect(fn).toHaveBeenCalled() | assert.ok(fn.mock.callCount() > 0) |
expect(fn).toHaveBeenCalledTimes(2) | assert.equal(fn.mock.callCount(), 2) |
expect(fn).toHaveBeenCalledWith(a, b) | assert.deepEqual(fn.mock.calls[0].arguments, [a, b]) |
fn.mockReturnValue(v) | fn.mock.mockImplementation(() => v) |
fn.mockResolvedValue(v) | fn.mock.mockImplementation(async () => v) |
fn.mockRejectedValue(e) | fn.mock.mockImplementation(async () => { throw e; }) |
fn.mockReturnValueOnce(v) | fn.mock.mockImplementationOnce(() => v) |
jest.clearAllMocks() | mock.reset() (clears call history on tracked mocks) |
jest.restoreAllMocks() | mock.restoreAll() |
The main ergonomic difference: fn.mock.calls is an array of records, not an array of argument
arrays. calls[0].arguments where Jest has calls[0].
7. Mocking objects: mock.method and friends
class Db { query(sql) { return `REAL: ${sql}`; } }
const db = new Db();
const spy = mock.method(db, 'query', (sql) => `FAKE: ${sql}`);
db.query('select 1'); // 'FAKE: select 1'
spy.mock.callCount(); // 1
spy.mock.restore();
db.query('select 1'); // 'REAL: select 1'
With no implementation it spies while keeping the original behaviour — the “partial mock” that Jest
needs jest.spyOn(...).mockImplementation gymnastics for:
const spy = mock.method(db, 'query');
assert.equal(db.query('select 2'), 'REAL: select 2'); // original still runs
assert.equal(spy.mock.callCount(), 1);
Accessors and data properties:
mock.getter(cfg, 'env', () => 'test'); // replace a getter
mock.setter(cfg, 'level', function (v) { this._l = v * 2; }); // replace a setter
mock.property(plain, 'max', 99); // replace a data property (22.3+)
Scoped versus global trackers, and why t.mock wins
There are two MockTrackers: the module-level mock (global, lives for the process) and t.mock
(scoped to one test, automatically restored when the test ends).
// Global tracker: you own the cleanup.
describe('...', () => {
afterEach(() => mock.restoreAll());
it('...', () => { mock.method(svc, 'ping', () => 'stubbed'); });
});
// Context tracker: no cleanup needed. Verified:
it('t.mock is scoped to the test and auto-restored', (t) => {
t.mock.method(svc, 'ping', () => 'stubbed');
assert.equal(svc.ping(), 'stubbed');
});
it('the previous test left nothing behind', () => {
assert.equal(svc.ping(), 'real'); // no afterEach anywhere
});
Default to t.mock. A forgotten mock.restoreAll() produces the worst class of test bug: a suite
that passes alone and fails when run with its neighbours, or worse, passes in both cases while silently
testing a stub. t.mock makes that impossible by construction.
8. Mocking time
mock.timers replaces the timer APIs with a clock you advance by hand. It is experimental but has been
stable in practice since 20.4.
it('setTimeout without waiting', (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const fn = t.mock.fn();
setTimeout(fn, 60_000);
assert.equal(fn.mock.callCount(), 0);
t.mock.timers.tick(60_000); // advance the clock
assert.equal(fn.mock.callCount(), 1);
});
The API surface: enable({ apis, now }), tick(ms), setTime(ms), runAll(), reset().
apis accepts 'setTimeout', 'setInterval', 'setImmediate', and 'Date'.
it('setInterval fires once per interval', (t) => {
t.mock.timers.enable({ apis: ['setInterval'] });
const fn = t.mock.fn();
const id = setInterval(fn, 100);
t.mock.timers.tick(350);
assert.equal(fn.mock.callCount(), 3); // 100, 200, 300 — not 350
clearInterval(id);
});
it('Date can be frozen and moved', (t) => {
t.mock.timers.enable({ apis: ['Date'], now: 0 });
assert.equal(Date.now(), 0);
t.mock.timers.setTime(1_700_000_000_000);
assert.equal(new Date().toISOString(), '2023-11-14T22:13:20.000Z');
});
it('runAll flushes every pending timer at once', (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const order = [];
setTimeout(() => order.push('late'), 10_000);
setTimeout(() => order.push('early'), 1);
t.mock.timers.runAll();
assert.deepEqual(order, ['early', 'late']); // scheduled-time order, not insertion order
});
it('the promisified timers are mocked too', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const { setTimeout: sleep } = await import('node:timers/promises');
const p = sleep(5000, 'done');
t.mock.timers.tick(5000);
assert.equal(await p, 'done');
});
All four verified. The timers/promises one matters because that is what modern code actually uses, and
it is easy to assume the mock only covers the callback form.
But prefer injecting the clock
Mocking time globally is a big hammer: it changes the behaviour of every library in the process for the duration of the test, including your test framework’s own timeouts. When you control the code, passing the clock or the sleep function in is strictly better — smaller blast radius, and the sleep durations become assertable:
export async function retry(fn, { attempts = 3, baseMs = 100,
sleep = (ms) => new Promise(r => setTimeout(r, ms)) } = {}) { ... }
it('retry backs off without any real waiting', async () => {
const sleeps = [];
const sleep = mock.fn(async (ms) => { sleeps.push(ms); });
let calls = 0;
const result = await retry(async () => { if (++calls < 3) throw new Error('flaky'); return 'ok'; },
{ attempts: 5, baseMs: 100, sleep });
assert.equal(result, 'ok');
assert.deepEqual(sleeps, [100, 200]); // exponential backoff, and observable
});
it('gives up after the configured attempts', async () => {
const sleep = mock.fn(async () => {});
const fn = mock.fn(async () => { throw new Error('always down'); });
await assert.rejects(() => retry(fn, { attempts: 3, sleep }), /always down/);
assert.equal(fn.mock.callCount(), 3);
assert.equal(sleep.mock.callCount(), 2); // n attempts -> n-1 sleeps
});
The sleeps array is the point: with mock.timers you can prove the code waited, but with an injected
sleep you can prove it waited the right amount, which is the behaviour you actually care about. Both
tests run in microseconds. This is the same “inject the clock” argument as
Design patterns §6.10.
9. Mocking modules
mock.module intercepts the module registry. It needs --experimental-test-module-mocks and is still
experimental at Node 26.
test('mock.module replaces a module for subsequent imports', async (t) => {
t.mock.module('./src/clock.mjs', {
namedExports: { now: () => 1_700_000_000_000, VERSION: '9.9.9' },
});
const { header } = await import('./src/report.mjs'); // imported AFTER the mock
assert.equal(header(), 'report v9.9.9 @ 1700000000000');
});
test('it can also fake a core module', async (t) => {
t.mock.module('node:os', { namedExports: { platform: () => 'fakeOS' } });
assert.equal((await import('node:os')).platform(), 'fakeOS');
});
test('defaultExport for default-exporting modules', async (t) => {
t.mock.module('node:path', { defaultExport: { join: (...p) => p.join('|') } });
assert.equal((await import('node:path')).default.join('a', 'b'), 'a|b');
});
All verified with the flag on.
The rule that makes it work: import order
ESM caches a module’s resolved dependency graph. If the module under test was already imported at the top of your test file, mocking its dependency afterwards does nothing.
// WRONG — report.mjs already resolved clock.mjs before the mock existed
import { header } from './src/report.mjs';
test('...', (t) => { t.mock.module('./src/clock.mjs', { ... }); header(); });
// RIGHT — dynamic import after the mock is installed
test('...', async (t) => {
t.mock.module('./src/clock.mjs', { ... });
const { header } = await import('./src/report.mjs');
});
Demonstrated directly:
const before = await import('./src/clock.mjs');
t.mock.module('./src/clock.mjs', { namedExports: { VERSION: 'mocked' } });
const after = await import('./src/clock.mjs');
assert.notEqual(before.VERSION, after.VERSION); // the mock created a NEW module instance
assert.equal(after.VERSION, 'mocked');
The design argument for not needing it
Every mock.module call is a workaround for a hard-coded dependency. The alternative is a parameter:
// Needs module mocking to test:
import { now } from './clock.mjs';
export function header() { return `@ ${now()}`; }
// Needs nothing:
export function header({ now = Date.now } = {}) { return `@ ${now()}`; }
Module mocking is the right tool for third-party code you cannot change (node:fs, an SDK), for legacy
code you are adding tests to before refactoring, and for cutting off an expensive import graph. For your
own code it is a signal that a seam is missing. That is why the flag being experimental for three major
versions has bothered fewer people than you would expect — well-structured code rarely needs it.
10. Snapshot testing
it('captures a serialized value', (t) => {
const report = { orders: 2, total: 500, currency: 'USD', lines: [{ sku: 'ABC', qty: 2 }] };
t.assert.snapshot(report);
});
First run with --test-update-snapshots writes <testfile>.snapshot next to the test:
exports[`snapshot testing > captures a serialized value 1`] = `
{
"orders": 2,
"total": 500,
"currency": "USD",
"lines": [
{
"sku": "ABC",
"qty": 2
}
]
}
`;
Subsequent runs compare and fail on drift with an AssertionError showing expected versus actual — I
verified both the write and the mismatch path.
Configuration:
import { snapshot } from 'node:test';
snapshot.setResolveSnapshotPath((p) => `${p}.snap`); // where snapshots live
snapshot.setDefaultSnapshotSerializers([(v) => JSON.stringify(v, null, 2)]); // how they serialize
// t.assert.fileSnapshot(value, 'path/to/expected.txt') — one snapshot per file
The discipline snapshots need
Snapshots are the easiest way to write a test that asserts nothing useful. Three rules:
-
Normalize non-determinism before snapshotting. IDs, timestamps, durations, hostnames, absolute paths, iteration order. Verified example:
const raw = { id: 'rcpt_abc123', at: new Date(0).toISOString(), ms: 12.3456 }; t.assert.snapshot({ ...raw, id: '<id>', ms: Math.round(raw.ms) }); -
Review the diff, never blind-update.
--test-update-snapshotsin a hurry is how a regression gets committed as the new expected value. Treat a snapshot diff in a PR as a change request. -
Snapshot small, meaningful values. A 400-line snapshot of a whole DOM tree tells you something changed, not what broke. Prefer several small snapshots, or an explicit assertion on the three fields you actually care about.
Snapshots earn their keep for serialization formats, CLI output, generated code, and error-message formatting. They are a poor fit for anything a human has to reason about.
11. Coverage
node --test --experimental-test-coverage
node --test --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-exclude='**/*.test.*'
node --test --experimental-test-coverage --test-coverage-lines=90 --test-coverage-branches=80 --test-coverage-functions=90
node --test --experimental-test-coverage --test-reporter=lcov --test-reporter-destination=coverage/lcov.info
Real output from the mocking suite in this chapter, scoped to src/:
ℹ -------------------------------------------------------------------
ℹ file | line % | branch % | funcs % | uncovered lines
ℹ -------------------------------------------------------------------
ℹ src | | | |
ℹ order-service.mjs | 100.00 | 91.67 | 50.00 |
ℹ retry.mjs | 100.00 | 100.00 | 50.00 |
ℹ -------------------------------------------------------------------
ℹ all files | 100.00 | 94.74 | 50.00 |
ℹ -------------------------------------------------------------------
100% line coverage with 50% function coverage is a nice accident to be able to point at. The
uncovered functions are the default no-op logger.info / logger.error — real code paths, never
exercised, invisible in the line metric because they are one-liners the tests always override. That is
coverage’s central weakness in one table: it measures execution, not verification.
The honest position to take in an interview: coverage is a floor, not a goal. Useful as “which files
have no tests at all” and as a CI ratchet that must not go down. Useless as a quality target — a test
suite of assert.ok(true) after every function call reaches 100%. Branch coverage is more informative
than line coverage; mutation testing is what actually measures whether your assertions bite, and no
built-in tool does it.
The lcov output is standard, so Codecov, SonarQube and friends consume it directly. --test-coverage-lines=N
makes the run fail below the threshold, which is how you build the ratchet.
12. Reporters and CI
| Reporter | Output | Use for |
|---|---|---|
spec | indented, human-readable (default from Node 23) | local development |
tap | TAP 13 (default on Node 22 and earlier) | tooling that speaks TAP |
dot | one character per test | large suites, quiet CI logs |
junit | JUnit XML | GitHub Actions, GitLab, Jenkins test summaries |
lcov | lcov coverage | Codecov, SonarQube |
Multiple reporters at once — human output to the terminal, machine output to a file:
node --test \
--test-reporter=spec --test-reporter-destination=stdout \
--test-reporter=junit --test-reporter-destination=test-results.xml \
--experimental-test-coverage \
--test-reporter=lcov --test-reporter-destination=coverage/lcov.info
A package.json that needs no devDependencies at all:
{
"type": "module",
"scripts": {
"test": "node --test --test-reporter=spec",
"test:watch": "node --test --watch --test-reporter=spec",
"test:unit": "node --test --test-skip-pattern='integration'",
"test:cov": "node --test --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-lines=85",
"test:ci": "node --test --test-reporter=junit --test-reporter-destination=test-results.xml --test-reporter=spec --test-reporter-destination=stdout"
}
}
Custom reporters are just transform streams over the event objects, which is the same event stream the programmatic API exposes — see section 14.
For CI sharding, --test-shard=1/3 splits files (not tests) across three jobs. It requires more than
one file to be useful, and it does not balance by duration, so a single slow file still dominates.
13. TypeScript
Three options, in increasing order of build ceremony.
1. Native type stripping — no dependencies
Node 22.6+ can execute .ts by erasing the types. On Node 22.22 it is on by default: this works with
no flags at all.
node --test money.test.ts
import { test, describe, it, mock } from 'node:test';
import assert from 'node:assert/strict';
import { money, add, type Money } from './src/money.ts'; // note: the .ts extension
describe('typed unit tests run under node:test unchanged', () => {
it('adds same-currency money', () => {
assert.deepEqual(add(money(250), money(150)), { amount: 400, currency: 'USD' });
});
it('rejects a currency mismatch at runtime, not just at compile time', () => {
assert.throws(() => add(money(1, 'USD'), money(1, 'EUR')), /currency mismatch/);
});
it('validates the invariant the type system cannot express', () => {
assert.throws(() => money(1.5), RangeError);
});
it('typed mocks keep their signatures', () => {
const fmt = mock.fn((m: Money): string => `${m.amount} ${m.currency}`);
fmt(money(500));
assert.equal(fmt.mock.calls[0]!.result, '500 USD');
});
});
Two things to notice. Imports must carry the .ts extension, because stripping does not do module
resolution rewriting. And stripping is not compiling: syntax that has runtime semantics fails.
$ node --test enum.test.ts
SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript enum is not supported in strip-only mode
--experimental-transform-types handles those (verified: the same file passes with the flag). The
non-erasable constructs are enum, namespace with runtime content, parameter properties
(constructor(private x: number)), and legacy decorators. This is exactly what
erasableSyntaxOnly in TypeScript §13
is for — turn it on and your code stays runnable by Node with no build step.
2. A loader
npm i -D tsx
node --import tsx --test 'test/**/*.test.ts'
Handles the full language including enums and decorators. Note it must be installed locally —
my first attempt failed with ERR_MODULE_NOT_FOUND: Cannot find package 'tsx' because it was only
installed globally.
3. Compile first
tsc && node --test dist/**/*.test.js
Slowest loop, but it is the only option that runs exactly the code you ship, and the only one where a type error stops the tests.
The point worth making in an interview
Type checking and testing are separate jobs. None of the three options above type-check your tests
by default — stripping and loaders both discard types without validating them. You need tsc --noEmit
as its own CI step. Conversely, the type system cannot express “throws on a non-integer amount”, which is
why money(1.5) needs a runtime test. Types eliminate a class of tests (you do not test that a
Currency is one of two strings) and create a need for others (you test the invariants the types cannot
state).
Type-level assertions are their own technique — Assert<Equals<A, B>> as covered in
TypeScript §Intro — and they run at compile time, not under the test
runner.
14. The programmatic API
run() returns an event stream, which is how you build custom reporters, IDE integrations, or a
selective runner.
import { run } from 'node:test';
import assert from 'node:assert/strict';
const events = [];
const stream = run({ files: ['./money.test.ts'], concurrency: 2 });
stream.on('test:pass', (e) => events.push(['pass', e.name]));
stream.on('test:fail', (e) => events.push(['fail', e.name]));
for await (const _ of stream) { /* drain */ }
console.log(`${events.filter(([k]) => k === 'pass').length} passes`);
programmatic run: 5 passes, 0 failures
names: adds same-currency money | rejects a currency mismatch at runtime... | validates the invariant...
The event names: test:enqueue, test:dequeue, test:start, test:pass, test:fail,
test:plan, test:diagnostic, test:stderr, test:stdout, test:coverage, test:complete,
test:watch:drained. run() options include files, globPatterns, concurrency, timeout,
only, testNamePatterns, setup, shard, watch, forceExit, cwd, and isolation.
You will rarely need this. It matters for the interview answer “how would you build test tooling” and for the occasional monorepo script that needs to run tests per-package and aggregate the results.
15. Testing a service through its seams
The whole chapter comes together here. The system under test takes its dependencies as constructor parameters, so the test controls all of them without any module mocking.
export class OrderService {
constructor({ repo, payments, clock = () => Date.now(), logger = { info() {}, error() {} } }) {
this.repo = repo; this.payments = payments; this.clock = clock; this.logger = logger;
}
async place({ sku, qty, card }) {
if (!Number.isInteger(qty) || qty <= 0) throw new RangeError('qty must be a positive integer');
const item = await this.repo.findBySku(sku);
if (!item) throw new OutOfStockError(sku);
if (item.stock < qty) throw new OutOfStockError(sku);
const total = item.price * qty;
const receipt = await this.payments.charge({ card, amount: total });
await this.repo.decrement(sku, qty);
const order = { id: receipt.id, sku, qty, total, placedAt: this.clock() };
this.logger.info('order placed', order.id);
return order;
}
}
function makeService(overrides = {}) {
const repo = { findBySku: mock.fn(async () => ({ sku: 'ABC', price: 250, stock: 4 })),
decrement: mock.fn(async () => {}) };
const payments = { charge: mock.fn(async () => ({ id: 'rcpt_1' })) };
const logger = { info: mock.fn(), error: mock.fn() };
const deps = { repo, payments, clock: () => 1_700_000_000_000, logger, ...overrides };
return { svc: new OrderService(deps), ...deps };
}
it('places an order and charges the right amount', async () => {
const { svc, repo, payments } = makeService();
const order = await svc.place({ sku: 'ABC', qty: 2, card: 'tok_x' });
assert.deepEqual(order, { id: 'rcpt_1', sku: 'ABC', qty: 2, total: 500,
placedAt: 1_700_000_000_000 });
// Assert on the INTERACTION — the only thing a mock can tell you.
assert.deepEqual(payments.charge.mock.calls[0].arguments, [{ card: 'tok_x', amount: 500 }]);
assert.deepEqual(repo.decrement.mock.calls[0].arguments, ['ABC', 2]);
});
it('does not charge when the item is missing', async () => {
const { svc, payments } = makeService({
repo: { findBySku: mock.fn(async () => null), decrement: mock.fn() } });
await assert.rejects(() => svc.place({ sku: 'NOPE', qty: 1, card: 'tok' }), OutOfStockError);
assert.equal(payments.charge.mock.callCount(), 0); // the important NEGATIVE assertion
});
it('does not decrement stock when the payment fails', async () => {
const { svc, repo } = makeService({
payments: { charge: mock.fn(async () => { throw new Error('card declined'); }) } });
await assert.rejects(() => svc.place({ sku: 'ABC', qty: 1, card: 'bad' }), /card declined/);
assert.equal(repo.decrement.mock.callCount(), 0);
});
it('validates input before touching any dependency', async () => {
const { svc, repo } = makeService();
await assert.rejects(() => svc.place({ sku: 'ABC', qty: 0, card: 'tok' }), RangeError);
await assert.rejects(() => svc.place({ sku: 'ABC', qty: 1.5, card: 'tok' }), RangeError);
assert.equal(repo.findBySku.mock.callCount(), 0);
});
Four things this demonstrates that a “test the happy path” suite does not:
- A
makeService(overrides)factory beatsbeforeEachassignment: each test states exactly what is different about its world, and there is no shared mutable state between tests. - Negative assertions carry the most information. “Did not charge the card” and “did not decrement
stock” are the assertions that would catch a real refund bug.
callCount() === 0is a first-class test. - Ordering is a real requirement. “Validates before touching any dependency” is asserted by
findBySku.mock.callCount() === 0— a behaviour with no return value to check. - A deterministic clock makes
placedAtassertable instead of ignorable.
Integration tests with built-ins only
For the layer below, Node’s standard library is enough: a real HTTP server on an ephemeral port, a real temp directory, and a real SQL database.
describe('integration: a real HTTP server on an ephemeral port', () => {
let server, base;
before(async () => {
server = http.createServer(handler);
server.listen(0); // port 0 -> the OS picks a free port
await once(server, 'listening');
base = `http://127.0.0.1:${server.address().port}`;
});
after(async () => { server.close(); await once(server, 'close'); });
it('creates a resource and reports 201 with a Location header', async () => {
const res = await fetch(`${base}/orders`, { method: 'POST',
headers: { 'content-type': 'application/json' }, body: JSON.stringify({ qty: 3 }) });
assert.equal(res.status, 201);
assert.equal(res.headers.get('location'), '/orders/1');
assert.deepEqual(await res.json(), { id: 1, qty: 3 });
});
it('a timeout is a first-class test case', async () => {
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), 10);
await assert.rejects(() => fetch(`${base}/slow`, { signal: ac.signal }), { name: 'AbortError' });
clearTimeout(timer);
});
});
listen(0) is the technique — never a hard-coded port. It removes the entire class of “tests pass
alone, fail in parallel, fail on a colleague’s machine because something else owns 3000”.
describe('integration: node:sqlite as a real database (Node 22.5+)', () => {
before(async () => {
({ DatabaseSync } = await import('node:sqlite'));
db = new DatabaseSync(':memory:'); // fast, isolated, nothing to clean up
db.exec(`CREATE TABLE items (sku TEXT PRIMARY KEY, price INTEGER, stock INTEGER)`);
db.prepare('INSERT INTO items VALUES (?, ?, ?)').run('ABC', 250, 4);
});
after(() => db?.close());
it('enforces the real constraint a mock would have let through', () => {
assert.throws(() => db.prepare('INSERT INTO items VALUES (?, ?, ?)').run('ABC', 1, 1),
/UNIQUE constraint failed/);
});
it('a transaction rolls back on failure', () => {
db.exec('BEGIN');
db.prepare('UPDATE items SET stock = stock - 1 WHERE sku = ?').run('ABC');
db.exec('ROLLBACK');
assert.equal(db.prepare('SELECT stock FROM items WHERE sku = ?').get('ABC').stock, 4);
});
});
“Enforces the real constraint a mock would have let through” is the whole argument for integration tests in one test name. A mocked repository will happily accept a duplicate primary key; SQLite will not. And a temp directory for filesystem work:
before(async () => { dir = await fs.mkdtemp(path.join(os.tmpdir(), 'guide-')); });
after(async () => { await fs.rm(dir, { recursive: true, force: true }); });
it('surfaces the real error code for a missing file', async () => {
await assert.rejects(() => fs.readFile(path.join(dir, 'nope')), { code: 'ENOENT' });
});
node:sqlite (22.5+) is the quiet game-changer for integration testing in Node: an in-memory SQL
database with no dependency, no container, no cleanup, and real constraint enforcement. Strategy for
choosing between these levels is chapter 18.
16. Gotchas found by running this
These are not from a list of common mistakes; they are the five things that actually broke while writing this chapter.
1. t.plan does not count assert calls. It counts t.assert.* and subtests. My first version
failed with plan expected 2 assertions but received 0 after two assert.ok(true) calls. See
section 5.
2. deepStrictEqual compares prototypes, and node:sqlite returns null-prototype rows. The
failure diff is maddening because both sides print identically:
AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal:
+ [Object: null prototype] {
- {
price: 250,
sku: 'ABC',
stock: 4
}
Spread it ({ ...row }) or compare fields. The same trap applies to Object.create(null) dictionaries,
URLSearchParams entries, and objects across vm contexts or worker boundaries.
3. mock.calls[i].target is not your mock function. It is undefined for a plain call and a
function for a new call — not reference-equal to the mock. Assert typeof target === 'function', not
identity.
4. A failing todo prints a full stack trace in the “failing tests” section but exits 0. Verified.
Do not read a red-looking CI log as a failure without checking the exit code and the tally line.
5. Type stripping is not compiling. enum Status { Draft, Paid } in a .ts test file dies with
ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX. Either add --experimental-transform-types, use a loader, or set
erasableSyntaxOnly in tsconfig.json and never write non-erasable syntax.
And one more that did not break but is worth flagging: forgetting await on assert.rejects makes
the test pass unconditionally. Nothing in the tooling catches it. If you take one lint rule from this
chapter, make it require-await / no-floating-promises.
17. node:test vs Jest vs Vitest
node:test | Jest | Vitest | |
|---|---|---|---|
| Install | none | ~300 packages | ~50 packages |
| Cold start | fastest | slowest | fast |
| Config file | not needed | usually | usually |
| Assertions | node:assert (~12 methods) | expect (100+ matchers) | expect, Jest-compatible |
| Custom matchers | write helper functions | expect.extend | expect.extend |
| Mocking | mock.*, module mocking experimental | mature, module mocking central | mature, vi.mock |
| Snapshots | stable since 23.4 | mature, inline snapshots | mature, inline snapshots |
| Coverage | built in, lcov output | built in, HTML reports | built in, v8 or istanbul |
| Watch | --watch | yes | best of the three |
| DOM testing | no | jsdom | jsdom/happy-dom |
| TypeScript | stripping or a loader | ts-jest / babel | native |
| ESM | native | historically painful | native |
| Browser mode | no | no | yes |
Where node:test is the right answer: libraries and CLIs where a zero-dependency test suite is a
feature, backend services, anything where you want the test runner to be the runtime, and any project
where supply-chain surface matters.
Where it is not: frontend code (no DOM), teams that depend on rich matchers or inline snapshots, and codebases with a large existing Jest suite where migration cost exceeds the benefit. Vitest is the reasonable default for new frontend and Vite-based projects; Jest remains the official React Native runner.
A caution about secondary sources. A 2026 comparison I read while writing this claimed node:test has
“no snapshot testing” and “no watch mode until Node 23+”. Both are wrong against the official docs:
snapshots landed in 22.3 and stabilized in 23.4, and --watch has existed since 19.2. The feature set is
moving fast enough that blog posts go stale within a release or two — check
the Node docs with your version selector set to your Node, and
feature-detect when it matters. That is a good habit to demonstrate in an interview, not just here.
18. Interview questions
Q: Why would you use the built-in test runner over Jest?
A: No dependencies (supply-chain surface, install time, CI cache), fastest cold start, native ESM, no config file, and the runner is the runtime so there is no transform layer to explain when something behaves differently under test. The trade-off is the assertion surface and no DOM.
Q: What is the difference between mock and t.mock?
A: mock is a process-lifetime tracker you must clean up with mock.restoreAll(); t.mock is
scoped to one test and restores automatically when it ends. Prefer t.mock — a forgotten restore
produces order-dependent tests, which is the worst failure mode a suite can have.
Q: What does mock.method(obj, 'm') do with no implementation?
A: Spies while keeping the original behaviour. That is the “partial mock” case, and it is one line
here versus jest.spyOn(...).mockImplementation(original) gymnastics elsewhere.
Q: How do you test exponential backoff without waiting?
A: Two ways. t.mock.timers.enable({ apis: ['setTimeout'] }) plus tick() if the code calls
setTimeout directly. Better: inject the sleep function, so you can assert the durations
([100, 200]) rather than just that it waited. The second is a smaller blast radius and a stronger
assertion.
Q: Why is assert.rejects dangerous?
A: It returns a promise. Without await the assertion never runs and the test passes even when the
function resolves. There is no runtime warning; a no-floating-promises lint rule is the only defence.
Q: Why did deepStrictEqual fail on two objects that print identically?
A: It compares prototypes. A null-prototype object (from node:sqlite, Object.create(null), or a
cross-realm value) is not deep-strict-equal to a plain object literal. Spread it or compare fields.
Q: How do you avoid port conflicts in integration tests?
A: server.listen(0) and read server.address().port. Never a hard-coded port — that is what makes
parallel test runs and shared CI machines fail intermittently.
Q: What is the argument against module mocking?
A: Every mock.module call marks a dependency that could have been a parameter. Module mocking
couples the test to the module graph, breaks when imports are reordered, and is still experimental in
Node. It is right for third-party code and legacy code; for your own code it is a missing seam.
Q: Why is import order significant with mock.module?
A: ESM caches resolved module graphs. If the module under test was already statically imported, its
dependencies are bound before your mock exists. You must await import() the subject after installing
the mock.
Q: Is 100% coverage a good goal?
A: No. Coverage measures execution, not verification — the table in
section 11 shows 100% line coverage with 50% function coverage on the same files, and a
suite of assert.ok(true) would reach 100% of everything. Use it as a floor and a ratchet (“no file with
zero tests”, “coverage must not drop”), prefer branch over line coverage, and use mutation testing if you
actually want to know whether your assertions bite.
Q: What do you do about a test that is red 1% of the time?
A: Treat it as a bug in the test until proven otherwise, and quarantine it rather than retry it — an auto-retry hides the signal. The common causes are real time (fix: inject the clock), real ports (fix: port 0), shared state between tests (fix: scoped mocks and per-test fixtures), and unawaited promises (fix: lint). See chapter 18.
Q: How does --test-shard work and what is its limitation?
A: --test-shard=1/3 runs a third of the files in this job. It splits by file, not by test, and
does not balance by duration, so one slow file still gates the pipeline. It also needs more files than
shards to help at all.
Q: How do you run TypeScript tests with no build step?
A: node --test file.test.ts — type stripping is on by default from Node 22.6. Imports need explicit
.ts extensions, and non-erasable syntax (enum, namespace, parameter properties, legacy decorators)
needs --experimental-transform-types or a loader. Set erasableSyntaxOnly in tsconfig.json to keep
yourself honest.
Q: Do these runners type-check your tests?
A: No. Stripping and loaders discard types without validating them. tsc --noEmit is a separate CI
step, and forgetting it is why “it compiles” and “the tests pass” can both be true while the types are
wrong.
Q: When would you use t.signal?
A: Pass it to any cancellable operation (fetch, streams, an AbortController-aware client) so that
a timing-out test actually cancels its work instead of leaking a pending request into the next test.
Q: What does t.waitFor replace?
A: await sleep(500) before asserting on something eventually-consistent. It polls the callback
until it stops throwing or the timeout expires, so the test is as fast as the system allows instead of as
slow as your guess.
Q: How would you structure the test suite for a service?
A: Constructor-injected dependencies so every collaborator is a seam; a makeService(overrides)
factory instead of shared beforeEach state; fast unit tests with mock.fn collaborators covering the
branch matrix including the negative assertions; a thinner layer of integration tests against real
infrastructure (listen(0), node:sqlite, mkdtemp) for the things mocks cannot check — constraints,
serialization, transactions, status codes.
Next: Testing with unittest for the Python side, or
Test strategy and integration testing for the cross-language design chapter.
Verify it yourself
test-js/src/clock.mjs
export function now() { return Date.now(); }
export const VERSION = '1.0.0';
test-js/src/money.ts
export type Currency = 'USD' | 'EUR';
export interface Money { readonly amount: number; readonly currency: Currency }
export const money = (amount: number, currency: Currency = 'USD'): Money => {
if (!Number.isInteger(amount)) throw new RangeError('amount must be an integer number of cents');
return { amount, currency };
};
export function add(a: Money, b: Money): Money {
if (a.currency !== b.currency) throw new TypeError(`currency mismatch: ${a.currency} vs ${b.currency}`);
return money(a.amount + b.amount, a.currency);
}
test-js/src/order-service.mjs
export class OutOfStockError extends Error {
constructor(sku) { super(`out of stock: ${sku}`); this.name = 'OutOfStockError'; this.sku = sku; }
}
export class OrderService {
// Dependencies are injected -> every one of them is a seam a test can take over.
constructor({ repo, payments, clock = () => Date.now(), logger = { info() {}, error() {} } }) {
this.repo = repo; this.payments = payments; this.clock = clock; this.logger = logger;
}
async place({ sku, qty, card }) {
if (!Number.isInteger(qty) || qty <= 0) throw new RangeError('qty must be a positive integer');
const item = await this.repo.findBySku(sku);
if (!item) throw new OutOfStockError(sku);
if (item.stock < qty) throw new OutOfStockError(sku);
const total = item.price * qty;
const receipt = await this.payments.charge({ card, amount: total });
await this.repo.decrement(sku, qty);
const order = { id: receipt.id, sku, qty, total, placedAt: this.clock() };
this.logger.info('order placed', order.id);
return order;
}
}
test-js/src/report.mjs
import { now, VERSION } from './clock.mjs';
export function header() { return `report v${VERSION} @ ${now()}`; }
test-js/src/retry.mjs
export async function retry(fn, { attempts = 3, baseMs = 100, sleep = (ms) => new Promise(r => setTimeout(r, ms)) } = {}) {
let last;
for (let i = 0; i < attempts; i++) {
try { return await fn(i); }
catch (e) { last = e; if (i === attempts - 1) break; await sleep(baseMs * 2 ** i); }
}
throw last;
}
test-js/basics.test.mjs
import { test, describe, it, before, after, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
test('the flat form: a name and a function', () => {
assert.equal(1 + 1, 2);
});
test('async tests just return a promise', async () => {
const v = await Promise.resolve(42);
assert.equal(v, 42);
});
test('subtests via the test context', async (t) => {
await t.test('nested one', () => assert.ok(true));
await t.test('nested two', () => assert.ok(true));
});
describe('describe/it (BDD form)', () => {
const order = [];
before(() => order.push('before'));
after(() => {
order.push('after');
// hook order is asserted in the last test below
});
beforeEach(() => order.push('beforeEach'));
afterEach(() => order.push('afterEach'));
it('runs the first test', () => order.push('test1'));
it('runs the second test', () => order.push('test2'));
it('saw hooks in the documented order', () => {
assert.deepEqual(order, ['before', 'beforeEach', 'test1', 'afterEach',
'beforeEach', 'test2', 'afterEach', 'beforeEach']);
});
});
describe('assertion surface (node:assert/strict)', () => {
it('equal uses ===, not ==', () => {
assert.equal(1, 1);
assert.throws(() => assert.equal(1, '1'), assert.AssertionError); // strict mode: no coercion
});
it('deepEqual compares structure AND prototypes', () => {
assert.deepEqual({ a: [1, { b: 2 }] }, { a: [1, { b: 2 }] });
class P { constructor() { this.a = 1; } }
assert.throws(() => assert.deepEqual(new P(), { a: 1 }), assert.AssertionError);
});
it('throws/rejects can match by class, regex, predicate or shape', () => {
const boom = () => { throw new TypeError('bad shape: qty'); };
assert.throws(boom, TypeError);
assert.throws(boom, /bad shape/);
assert.throws(boom, { name: 'TypeError', message: 'bad shape: qty' });
assert.throws(boom, (e) => e instanceof TypeError && e.message.includes('qty'));
});
it('rejects is the async twin — and you must await it', async () => {
await assert.rejects(async () => { throw new RangeError('nope'); }, RangeError);
await assert.doesNotReject(async () => 1);
});
it('match/doesNotMatch for strings', () => {
assert.match('order 42 placed', /^order \d+ placed$/);
assert.doesNotMatch('order', /\d/);
});
it('ok is truthiness; fail always fails', () => {
assert.ok([].length === 0);
assert.throws(() => assert.fail('explicit'), /explicit/);
});
});
// GOTCHA: t.plan counts assertions made through the TEST CONTEXT (t.assert.*) and subtests.
// Bare `assert` calls from node:assert are invisible to it.
test('t.plan counts t.assert.* calls, not bare assert calls (Node 22.11+)', (t) => {
t.plan(2);
t.assert.ok(true);
t.assert.equal(1, 1);
});
test('t.plan also counts subtests', async (t) => {
t.plan(1);
await t.test('the one planned subtest', () => assert.ok(true));
});
test('t.plan with a bare assert would FAIL: "expected 1 assertions but received 0"', (t) => {
// Demonstrated, not run — see the note above.
t.assert.ok(true);
});
test('t.diagnostic writes a comment into the report', (t) => {
t.diagnostic('this line shows up in the TAP/spec output');
assert.ok(true);
});
test.skip('skipped: never executed', () => { throw new Error('unreachable'); });
test('conditionally skipped from inside', (t) => {
if (process.platform === 'nonexistent') return t.skip('not applicable here');
assert.ok(true);
});
test.todo('todo: reported, failure ignored', () => { throw new Error('still fine'); });
describe('concurrency', () => {
it('sequential by default', () => assert.ok(true));
it('opt in per test', { concurrency: true }, async () => {
await new Promise(r => setTimeout(r, 5));
assert.ok(true);
});
});
test('timeouts are per test', { timeout: 5000 }, async () => {
await new Promise(r => setTimeout(r, 1));
assert.ok(true);
});
test-js/enum.test.ts
import { test } from 'node:test';
import assert from 'node:assert/strict';
enum Status { Draft, Paid }
test('enum needs transform, not strip', () => assert.equal(Status.Paid, 1));
test-js/integration.test.mjs
import { test, describe, it, before, after, mock } from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import { once } from 'node:events';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
describe('integration: a real HTTP server on an ephemeral port', () => {
let server, base, hits;
before(async () => {
hits = [];
server = http.createServer((req, res) => {
hits.push(`${req.method} ${req.url}`);
if (req.url === '/orders' && req.method === 'POST') {
let body = '';
req.on('data', c => { body += c; });
req.on('end', () => {
const { qty } = JSON.parse(body || '{}');
if (!qty) { res.writeHead(400, { 'content-type': 'application/json' });
return res.end(JSON.stringify({ error: 'qty required' })); }
res.writeHead(201, { 'content-type': 'application/json', location: '/orders/1' });
res.end(JSON.stringify({ id: 1, qty }));
});
return;
}
if (req.url === '/slow') { setTimeout(() => { res.writeHead(200); res.end('late'); }, 50); return; }
res.writeHead(404); res.end();
});
server.listen(0); // port 0 -> the OS picks a free port: no conflicts, no fixed port
await once(server, 'listening');
base = `http://127.0.0.1:${server.address().port}`;
});
after(async () => { server.close(); await once(server, 'close'); });
it('creates a resource and reports 201 with a Location header', async () => {
const res = await fetch(`${base}/orders`, {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ qty: 3 }),
});
assert.equal(res.status, 201);
assert.equal(res.headers.get('location'), '/orders/1');
assert.deepEqual(await res.json(), { id: 1, qty: 3 });
});
it('rejects a bad body with 400 and a machine-readable error', async () => {
const res = await fetch(`${base}/orders`, { method: 'POST', body: '{}' });
assert.equal(res.status, 400);
assert.deepEqual(await res.json(), { error: 'qty required' });
});
it('a timeout is a first-class test case', async () => {
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), 10);
await assert.rejects(() => fetch(`${base}/slow`, { signal: ac.signal }), { name: 'AbortError' });
clearTimeout(timer);
});
it('the server saw exactly the requests we think it did', () => {
assert.deepEqual(hits, ['POST /orders', 'POST /orders', 'GET /slow']);
});
});
describe('integration: the real filesystem, in a temp directory', () => {
let dir;
before(async () => { dir = await fs.mkdtemp(path.join(os.tmpdir(), 'guide-')); });
after(async () => { await fs.rm(dir, { recursive: true, force: true }); });
it('round-trips a file', async () => {
const p = path.join(dir, 'a.json');
await fs.writeFile(p, JSON.stringify({ ok: true }));
assert.deepEqual(JSON.parse(await fs.readFile(p, 'utf8')), { ok: true });
});
it('surfaces the real error code for a missing file', async () => {
await assert.rejects(() => fs.readFile(path.join(dir, 'nope')), { code: 'ENOENT' });
});
it('is isolated: each test suite gets its own directory', async () => {
assert.deepEqual((await fs.readdir(dir)).sort(), ['a.json']);
});
});
describe('integration: node:sqlite as a real database (Node 22.5+, experimental)', () => {
let db, DatabaseSync;
before(async () => {
({ DatabaseSync } = await import('node:sqlite'));
db = new DatabaseSync(':memory:'); // in-memory: fast, isolated, no cleanup
db.exec(`CREATE TABLE items (sku TEXT PRIMARY KEY, price INTEGER, stock INTEGER)`);
db.prepare('INSERT INTO items VALUES (?, ?, ?)').run('ABC', 250, 4);
});
after(() => db?.close());
it('reads through the real driver, real SQL, real types', () => {
const row = db.prepare('SELECT * FROM items WHERE sku = ?').get('ABC');
// GOTCHA: node:sqlite returns null-prototype objects, and deepStrictEqual compares
// prototypes. The values match but the assertion fails with a diff that looks identical:
// + [Object: null prototype] { price: 250, ... }
// - { price: 250, ... }
// Spread it (or compare fields) to give it an ordinary prototype.
assert.deepEqual({ ...row }, { sku: 'ABC', price: 250, stock: 4 });
assert.throws(() => assert.deepEqual(row, { sku: 'ABC', price: 250, stock: 4 }),
assert.AssertionError);
});
it('enforces the real constraint a mock would have let through', () => {
assert.throws(() => db.prepare('INSERT INTO items VALUES (?, ?, ?)').run('ABC', 1, 1),
/UNIQUE constraint failed/);
});
it('a transaction rolls back on failure', () => {
db.exec('BEGIN');
db.prepare('UPDATE items SET stock = stock - 1 WHERE sku = ?').run('ABC');
db.exec('ROLLBACK');
assert.equal(db.prepare('SELECT stock FROM items WHERE sku = ?').get('ABC').stock, 4);
});
});
describe('snapshot testing (t.assert.snapshot — Node 22.3+, stable 23.4+)', () => {
it('captures a serialized value', (t) => {
const report = { orders: 2, total: 500, currency: 'USD', lines: [{ sku: 'ABC', qty: 2 }] };
t.assert.snapshot(report);
});
it('is only as good as the value being stable', (t) => {
// Anything non-deterministic must be normalized BEFORE snapshotting.
const raw = { id: 'rcpt_' + 'abc123', at: new Date(0).toISOString(), ms: 12.3456 };
t.assert.snapshot({ ...raw, id: '<id>', ms: Math.round(raw.ms) });
});
});
test-js/integration.test.mjs.snapshot
exports[`snapshot testing (t.assert.snapshot — Node 22.3+, stable 23.4+) > captures a serialized value 1`] = `
{
"orders": 2,
"total": 500,
"currency": "USD",
"lines": [
{
"sku": "ABC",
"qty": 2
}
]
}
`;
exports[`snapshot testing (t.assert.snapshot — Node 22.3+, stable 23.4+) > is only as good as the value being stable 1`] = `
{
"id": "<id>",
"at": "1970-01-01T00:00:00.000Z",
"ms": 12
}
`;
test-js/mocking.test.mjs
import { test, describe, it, mock, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { OrderService, OutOfStockError } from './src/order-service.mjs';
import { retry } from './src/retry.mjs';
describe('mock.fn: a spy with a programmable implementation', () => {
it('records every call', () => {
const fn = mock.fn((a, b) => a + b);
fn(1, 2); fn(3, 4);
assert.equal(fn.mock.callCount(), 2);
assert.deepEqual(fn.mock.calls[0].arguments, [1, 2]);
assert.equal(fn.mock.calls[0].result, 3);
assert.equal(fn.mock.calls[0].error, undefined);
assert.deepEqual(fn.mock.calls.at(-1).arguments, [3, 4]);
});
it('records thrown errors too', () => {
const fn = mock.fn(() => { throw new Error('kaboom'); });
assert.throws(() => fn(), /kaboom/);
assert.equal(fn.mock.calls[0].error.message, 'kaboom');
assert.equal(fn.mock.calls[0].result, undefined);
});
it('records `this`, and `target` distinguishes a construct call from a normal one', () => {
const fn = mock.fn(function () { this.tagged = true; });
const obj = {};
fn.call(obj);
const instance = new fn();
// Every call record has: arguments, error, result, stack, target, this
assert.deepEqual(Object.keys(fn.mock.calls[0]).sort(),
['arguments', 'error', 'result', 'stack', 'target', 'this']);
assert.equal(fn.mock.calls[0].this, obj);
assert.equal(fn.mock.calls[0].target, undefined); // plain call -> no target
assert.equal(typeof fn.mock.calls[1].target, 'function'); // `new` call -> target is the ctor
assert.equal(instance.tagged, true);
});
it('mockImplementation swaps the behaviour permanently', () => {
const fn = mock.fn(() => 'first');
assert.equal(fn(), 'first');
fn.mock.mockImplementation(() => 'second');
assert.equal(fn(), 'second');
assert.equal(fn(), 'second');
});
it('mockImplementationOnce swaps it for exactly one call', () => {
const fn = mock.fn(() => 'default');
fn.mock.mockImplementationOnce(() => 'once');
assert.deepEqual([fn(), fn(), fn()], ['once', 'default', 'default']);
});
it('mockImplementationOnce can target a specific call index', () => {
const fn = mock.fn(() => 'default');
fn.mock.mockImplementationOnce(() => 'third', 2);
assert.deepEqual([fn(), fn(), fn(), fn()], ['default', 'default', 'third', 'default']);
});
it('resetCalls clears history but keeps the implementation', () => {
const fn = mock.fn(() => 1);
fn(); fn();
fn.mock.resetCalls();
assert.equal(fn.mock.callCount(), 0);
assert.equal(fn(), 1);
});
it('a bare mock.fn() with no implementation returns undefined and still records', () => {
const fn = mock.fn();
fn('x');
assert.equal(fn.mock.callCount(), 1);
assert.equal(fn.mock.calls[0].result, undefined);
});
});
describe('mock.method: replace one method on a real object', () => {
class Db { query(sql) { return `REAL: ${sql}`; } }
afterEach(() => mock.restoreAll()); // undo everything registered on the global tracker
it('intercepts and restores', () => {
const db = new Db();
const spy = mock.method(db, 'query', (sql) => `FAKE: ${sql}`);
assert.equal(db.query('select 1'), 'FAKE: select 1');
assert.equal(spy.mock.callCount(), 1);
assert.deepEqual(spy.mock.calls[0].arguments, ['select 1']);
spy.mock.restore();
assert.equal(db.query('select 1'), 'REAL: select 1');
});
it('with no implementation it spies while KEEPING the original behaviour', () => {
const db = new Db();
const spy = mock.method(db, 'query');
assert.equal(db.query('select 2'), 'REAL: select 2'); // original still runs
assert.equal(spy.mock.callCount(), 1);
});
it('mock.getter / mock.setter / mock.property for accessors and fields', () => {
const cfg = { get env() { return 'prod'; }, set level(v) { this._l = v; } };
const g = mock.getter(cfg, 'env', () => 'test');
assert.equal(cfg.env, 'test');
assert.equal(g.mock.callCount(), 1);
const s = mock.setter(cfg, 'level', function (v) { this._l = v * 2; });
cfg.level = 5;
assert.equal(cfg._l, 10);
assert.equal(s.mock.callCount(), 1);
const plain = { max: 10 };
mock.property(plain, 'max', 99); // replace a data property (Node 22.3+)
assert.equal(plain.max, 99);
});
});
describe('the test-context tracker cleans up automatically', () => {
const svc = { ping() { return 'real'; } };
it('t.mock is scoped to the test and auto-restored', (t) => {
t.mock.method(svc, 'ping', () => 'stubbed');
assert.equal(svc.ping(), 'stubbed');
});
it('the previous test left nothing behind', () => {
assert.equal(svc.ping(), 'real'); // no afterEach needed: t.mock restores on test end
});
});
describe('mock.timers: deterministic time', () => {
it('setTimeout without waiting', (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const fn = t.mock.fn();
setTimeout(fn, 60_000);
assert.equal(fn.mock.callCount(), 0);
t.mock.timers.tick(60_000);
assert.equal(fn.mock.callCount(), 1);
});
it('setInterval fires once per tick interval', (t) => {
t.mock.timers.enable({ apis: ['setInterval'] });
const fn = t.mock.fn();
const id = setInterval(fn, 100);
t.mock.timers.tick(350);
assert.equal(fn.mock.callCount(), 3);
clearInterval(id);
});
it('Date can be frozen and moved', (t) => {
t.mock.timers.enable({ apis: ['Date'], now: 0 });
assert.equal(Date.now(), 0);
t.mock.timers.setTime(1_700_000_000_000);
assert.equal(new Date().toISOString(), '2023-11-14T22:13:20.000Z');
});
it('runAll flushes every pending timer at once', (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const order = [];
setTimeout(() => order.push('late'), 10_000);
setTimeout(() => order.push('early'), 1);
t.mock.timers.runAll();
assert.deepEqual(order, ['early', 'late']); // fired in scheduled-time order, not insertion order
});
it('the promisified timers are mocked too', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const { setTimeout: sleep } = await import('node:timers/promises');
const p = sleep(5000, 'done');
t.mock.timers.tick(5000);
assert.equal(await p, 'done');
});
});
describe('the whole point: testing a service through its seams', () => {
const item = { sku: 'ABC', price: 250, stock: 4 };
function makeService(overrides = {}) {
const repo = { findBySku: mock.fn(async () => ({ ...item })), decrement: mock.fn(async () => {}) };
const payments = { charge: mock.fn(async () => ({ id: 'rcpt_1' })) };
const logger = { info: mock.fn(), error: mock.fn() };
const deps = { repo, payments, clock: () => 1_700_000_000_000, logger, ...overrides };
return { svc: new OrderService(deps), ...deps };
}
it('places an order and charges the right amount', async () => {
const { svc, repo, payments } = makeService();
const order = await svc.place({ sku: 'ABC', qty: 2, card: 'tok_x' });
assert.deepEqual(order, { id: 'rcpt_1', sku: 'ABC', qty: 2, total: 500, placedAt: 1_700_000_000_000 });
// Assert on the INTERACTION, which is the only thing a mock can tell you about.
assert.deepEqual(payments.charge.mock.calls[0].arguments, [{ card: 'tok_x', amount: 500 }]);
assert.deepEqual(repo.decrement.mock.calls[0].arguments, ['ABC', 2]);
});
it('does not charge when the item is missing', async () => {
const { svc, payments } = makeService({ repo: { findBySku: mock.fn(async () => null), decrement: mock.fn() } });
await assert.rejects(() => svc.place({ sku: 'NOPE', qty: 1, card: 'tok' }), OutOfStockError);
assert.equal(payments.charge.mock.callCount(), 0); // the important negative assertion
});
it('does not decrement stock when the payment fails', async () => {
const { svc, repo } = makeService({
payments: { charge: mock.fn(async () => { throw new Error('card declined'); }) },
});
await assert.rejects(() => svc.place({ sku: 'ABC', qty: 1, card: 'bad' }), /card declined/);
assert.equal(repo.decrement.mock.callCount(), 0);
});
it('validates input before touching any dependency', async () => {
const { svc, repo } = makeService();
await assert.rejects(() => svc.place({ sku: 'ABC', qty: 0, card: 'tok' }), RangeError);
await assert.rejects(() => svc.place({ sku: 'ABC', qty: 1.5, card: 'tok' }), RangeError);
assert.equal(repo.findBySku.mock.callCount(), 0);
});
});
describe('injecting the sleep function beats mocking timers', () => {
it('retry backs off without any real waiting', async () => {
const sleeps = [];
const sleep = mock.fn(async (ms) => { sleeps.push(ms); });
let calls = 0;
const result = await retry(async () => { if (++calls < 3) throw new Error('flaky'); return 'ok'; },
{ attempts: 5, baseMs: 100, sleep });
assert.equal(result, 'ok');
assert.equal(calls, 3);
assert.deepEqual(sleeps, [100, 200]); // exponential, and observable
});
it('gives up after the configured attempts', async () => {
const sleep = mock.fn(async () => {});
const fn = mock.fn(async () => { throw new Error('always down'); });
await assert.rejects(() => retry(fn, { attempts: 3, sleep }), /always down/);
assert.equal(fn.mock.callCount(), 3);
assert.equal(sleep.mock.callCount(), 2); // n attempts -> n-1 sleeps
});
});
test-js/modulemock.test.mjs
import { test, mock } from 'node:test';
import assert from 'node:assert/strict';
// Requires --experimental-test-module-mocks. Still experimental as of Node 26.
test('mock.module replaces a module for subsequent imports', async (t) => {
t.mock.module('./src/clock.mjs', {
namedExports: { now: () => 1_700_000_000_000, VERSION: '9.9.9' },
});
const { header } = await import('./src/report.mjs'); // must be imported AFTER the mock
assert.equal(header(), 'report v9.9.9 @ 1700000000000');
});
test('it can also fake a core module', async (t) => {
t.mock.module('node:os', { namedExports: { platform: () => 'fakeOS' } });
const os = await import('node:os');
assert.equal(os.platform(), 'fakeOS');
});
test('cache: the ESM registry means import order matters', async (t) => {
// If ./src/report.mjs had already been imported at the top of this file, the mock above
// would not have applied, because its dependency graph was already resolved and cached.
const before = await import('./src/clock.mjs');
t.mock.module('./src/clock.mjs', { namedExports: { VERSION: 'mocked' } });
const after = await import('./src/clock.mjs');
assert.notEqual(before.VERSION, after.VERSION);
assert.equal(after.VERSION, 'mocked');
});
test('defaultExport for default-exporting modules', async (t) => {
t.mock.module('node:path', { defaultExport: { join: (...p) => p.join('|') } });
const path = (await import('node:path')).default;
assert.equal(path.join('a', 'b'), 'a|b');
});
test-js/money.test.ts
import { test, describe, it, mock } from 'node:test';
import assert from 'node:assert/strict';
import { money, add, type Money } from './src/money.ts';
describe('typed unit tests run under node:test unchanged', () => {
it('adds same-currency money', () => {
assert.deepEqual(add(money(250), money(150)), { amount: 400, currency: 'USD' });
});
it('rejects a currency mismatch at runtime, not just at compile time', () => {
assert.throws(() => add(money(1, 'USD'), money(1, 'EUR')), /currency mismatch/);
});
it('validates the invariant the type system cannot express', () => {
assert.throws(() => money(1.5), RangeError);
});
it('typed mocks keep their signatures', () => {
const fmt = mock.fn((m: Money): string => `${m.amount} ${m.currency}`);
fmt(money(500));
assert.equal(fmt.mock.calls[0]!.result, '500 USD');
// @ts-expect-error the mock is typed: a string is not Money
// fmt('nope');
});
});
test-js/programmatic.mjs
import { run } from 'node:test';
import { tap } from 'node:test/reporters';
import assert from 'node:assert/strict';
// The programmatic API: build your own runner, filter, or CI integration.
const events = [];
const stream = run({ files: ['./money.test.ts'], concurrency: 2 });
stream.on('test:pass', (e) => events.push(['pass', e.name]));
stream.on('test:fail', (e) => events.push(['fail', e.name]));
stream.on('test:diagnostic', () => {});
for await (const _ of stream) { /* drain */ }
const passes = events.filter(([k]) => k === 'pass').length;
console.log(`programmatic run: ${passes} passes, ${events.filter(([k]) => k === 'fail').length} failures`);
assert.equal(events.filter(([k]) => k === 'fail').length, 0);
assert.ok(passes >= 4);
console.log('names:', events.filter(([k]) => k === 'pass').map(([, n]) => n).slice(0, 3).join(' | '));
test-js/cov.info
TN:
SF:money.test.ts
FN:5,anonymous_0
FN:6,anonymous_1
FN:10,anonymous_2
FN:11,anonymous_3
FN:14,anonymous_4
FN:15,anonymous_5
FN:18,anonymous_6
FN:19,anonymous_7
FNDA:1,anonymous_0
FNDA:1,anonymous_1
FNDA:1,anonymous_2
FNDA:1,anonymous_3
FNDA:1,anonymous_4
FNDA:1,anonymous_5
FNDA:1,anonymous_6
FNDA:1,anonymous_7
FNF:8
FNH:8
BRDA:1,0,0,1
BRDA:5,1,0,1
BRDA:6,2,0,1
BRDA:10,3,0,1
BRDA:11,4,0,1
BRDA:14,5,0,1
BRDA:15,6,0,1
BRDA:18,7,0,1
BRDA:19,8,0,1
BRF:9
BRH:9
DA:1,1
DA:2,1
DA:3,1
DA:4,1
DA:5,1
DA:6,1
DA:7,1
DA:8,1
DA:9,1
DA:10,1
DA:11,1
DA:12,1
DA:13,1
DA:14,1
DA:15,1
DA:16,1
DA:17,1
DA:18,1
DA:19,1
DA:20,1
DA:21,1
DA:22,1
DA:23,1
DA:24,1
DA:25,1
LH:25
LF:25
end_of_record
SF:src/money.ts
FN:4,money
FN:9,add
FNDA:7,money
FNDA:2,add
FNF:2
FNH:2
BRDA:1,0,0,1
BRDA:4,1,0,7
BRDA:5,2,0,1
BRDA:5,3,0,6
BRDA:9,4,0,2
BRDA:10,5,0,1
BRF:6
BRH:6
DA:1,1
DA:2,1
DA:3,1
DA:4,1
DA:5,7
DA:6,6
DA:7,1
DA:8,1
DA:9,1
DA:10,2
DA:11,1
DA:12,2
LH:12
LF:12
end_of_record