Chapter 16

Testing with `node:test`

node:test, assert, mocking, and end-to-end testing in TypeScript.

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

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.

FeatureStatusAdded
test(), describe(), it(), hooksStable18.0
node:assertStable (predates the runner)
Reporters: spec, tap, dot, junit, lcovStable19.9
mock.fn, mock.method, mock.getter, mock.setterStable19.1
mock.propertyStable22.3
Watch mode (--watch)Experimental19.2
Coverage (--experimental-test-coverage)Experimental20.1
mock.timersExperimental20.4
--test-name-patternStable20.1
Snapshot testing (t.assert.snapshot)Experimental 22.3, Stable 23.422.3
mock.moduleExperimental (needs --experimental-test-module-mocks)22.3
context.waitFor()Stable22.8
Test isolation (--experimental-test-isolation)Experimental22.8
context.plan()Stable22.11
Default reporter changed tap -> spec23.0
Global setup/teardown (--test-global-setup)Experimental24.0
it.expectFailure25.5
Test randomization (--test-randomize)Experimental26.1
Test tags (--experimental-test-tag-filter)Experimental26.2
--test-rerun-failuresExperimentalrecent

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.module is 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

SituationExit code
all pass0
a skip and a failing todo and nothing else0
one real assertion failure1

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';
AssertionChecks
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) / doesNotMatchregex 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

Jestnode:test
jest.fn()mock.fn()
jest.fn(impl)mock.fn(impl)
fn.mock.callsfn.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:

  1. 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) });
  2. Review the diff, never blind-update. --test-update-snapshots in a hurry is how a regression gets committed as the new expected value. Treat a snapshot diff in a PR as a change request.

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

ReporterOutputUse for
specindented, human-readable (default from Node 23)local development
tapTAP 13 (default on Node 22 and earlier)tooling that speaks TAP
dotone character per testlarge suites, quiet CI logs
junitJUnit XMLGitHub Actions, GitLab, Jenkins test summaries
lcovlcov coverageCodecov, 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 beats beforeEach assignment: 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() === 0 is 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 placedAt assertable 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:testJestVitest
Installnone~300 packages~50 packages
Cold startfastestslowestfast
Config filenot neededusuallyusually
Assertionsnode:assert (~12 methods)expect (100+ matchers)expect, Jest-compatible
Custom matcherswrite helper functionsexpect.extendexpect.extend
Mockingmock.*, module mocking experimentalmature, module mocking centralmature, vi.mock
Snapshotsstable since 23.4mature, inline snapshotsmature, inline snapshots
Coveragebuilt in, lcov outputbuilt in, HTML reportsbuilt in, v8 or istanbul
Watch--watchyesbest of the three
DOM testingnojsdomjsdom/happy-dom
TypeScriptstripping or a loaderts-jest / babelnative
ESMnativehistorically painfulnative
Browser modenonoyes

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