Testing with the built-in test runner
node:test and assert, suites and subtests, mocks and fake timers, watch mode, coverage and global setup.
The first test
Node ships a test runner. No framework, no configuration file: a file named *.test.js with node:test imports is discovered and executed in its own process.
// sum.js
export const sum = (a, b) => a + b;
// sum.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { sum } from "./sum.js";
test("adds two numbers", () => {
assert.equal(sum(2, 3), 5);
});
test("rejects non-numbers", () => {
assert.throws(() => sum("2", 3), TypeError);
});node --test # discover and run every matching file
node --test --watch # rerun on change
node --test test/unit/*.test.js
node --test --test-only # run just the tests marked with { only: true }node:assert/strictuses strict equality, which catches"1" == 1style mistakes that the loose module accepts.- An assertion failure fails the test; an unhandled rejection or a thrown error does the same.
- Async tests are just functions returning a promise — no callback, no
doneargument. node --testruns each file in a separate process, so module state never leaks between files.
Suites, subtests and mocks
import { describe, it, before, after, mock } from "node:test";
import assert from "node:assert/strict";
describe("checkout", () => {
before(() => { /* start a test database */ });
after(() => { /* stop it */ });
it("charges the customer once", async (t) => {
const charge = mock.fn(async () => ({ ok: true }));
const result = await checkout({ charge }, { id: 1 });
assert.equal(result.ok, true);
assert.equal(charge.mock.callCount(), 1);
await t.test("and records an audit entry", () => {
assert.ok(listener.entries.length > 0);
});
});
});
mock.timers.enable({ apis: ["setTimeout"], now: 0 });| API | Purpose |
|---|---|
test / it | A single case |
describe | Group related cases into a suite |
t.test | A subtest that reports separately |
before / after | Run once around a suite |
beforeEach / afterEach | Run around every case |
mock.fn | Replace a function and count calls |
t.mock.method | Replace one method for the life of one test |
mock.timers | Control setTimeout and Date |
t.skip / todo | Mark intent without failing the run |
- Assert on returned values and thrown errors before reaching for a mock; mocks verify interactions, which couples the test to the implementation.
- Injected dependencies are far easier to fake than imported ones, which is the strongest practical argument for passing collaborators as arguments.
- With fake timers enabled,
Date.now()only moves when you advance it, so time-dependent tests become deterministic. - Every test must be independent: no shared ports, no shared files, no reliance on execution order.
Watch, coverage and setup
node --test --test-concurrency=4
node --test --experimental-test-coverage --test-coverage-lines=80
node --test --test-name-pattern="checkout"
node --test --test-reporter=spec
node --test --test-reporter=junit --test-reporter-destination=report.xml
node --test --import ./test/setup.js # global hooks, tracing, env defaults- Coverage thresholds fail the run, which is what makes them useful in CI; set them per directory so an untested area does not block everything.
- The JUnit reporter is what most CI dashboards understand without a plugin.
--test-name-patternis for local iteration only; CI should always run the full suite.- A setup file loaded with
--importcan register global hooks, set environment defaults and install a tracer. - Node's runner does not provide a DOM. Browser-shaped tests still need jsdom or a real browser driver.
💡
Because each test file runs in its own process, module-level singletons are not shared. That removes a whole class of flaky tests, but it also means you cannot cache an expensive resource across files — start it in setup instead.
FAQ
Do I still need Jest or Vitest?
Not for a Node service. The built-in runner covers suites, mocks, fake timers, coverage and reporters with zero dependencies. Reach for a framework when you need browser-like environments, snapshot testing or a rich plugin ecosystem.
How do I test code that talks to a database?
Run the same engine in a disposable container, apply migrations in
before, and give each test its own transaction or schema. In-memory substitutes usually diverge from production behaviour and produce false confidence.Related
Errors, logging and debugging Databases, HTTP clients and external services
Last refreshed 2026-09-18.