The Built-in Node.js Test Runner
Modern Node.js ships a complete test runner in the core node:test module, so you can write and execute a real test suite without installing Jest, Mocha, or Vitest. It gives you a familiar test()/describe()/it() API, subtests, parallelism, watch mode, and pluggable reporters, all paired with the built-in node:assert library. For libraries and services that want fast, dependency-free CI, this is often all you need. This page covers writing tests, organizing them, running them with node --test, choosing a reporter, and re-running on change.
Availability
The test runner landed experimentally in Node 18 and became stable in Node 20, so on any current LTS (20 or 22) it is ready to use with no flags and no install. It works in both ES modules and CommonJS files. By convention, Node discovers test files matching patterns like *.test.js, *-test.js, or anything under a test/ directory.
import { test } from 'node:test';
import assert from 'node:assert/strict';
Importing
node:assert/strictgives you the strict-mode assertions by default, soassert.equal()behaves likeassert.strictEqual()(uses===). It is the recommended entry point for new code.
Writing your first test
A test is just a call to test() with a name and an async (or sync) function. Inside, you make assertions; if any assertion throws, the test fails. There is no global expect — you use the node:assert API directly.
// math.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
function add(a, b) {
return a + b;
}
test('add() sums two numbers', () => {
assert.equal(add(2, 3), 5);
});
test('add() handles negatives', () => {
assert.equal(add(-4, 1), -3);
});
Run it directly:
node --test math.test.js
Output:
✔ add() sums two numbers (1.2ms)
✔ add() handles negatives (0.4ms)
ℹ tests 2
ℹ pass 2
ℹ fail 0
Grouping with describe and it
For larger suites, describe() groups related tests and it() defines each case — the same BDD vocabulary you know from other frameworks. You can nest describe blocks, and lifecycle hooks (before, after, beforeEach, afterEach) run setup and teardown at the appropriate scope.
import { describe, it, before, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
describe('ShoppingCart', () => {
let cart;
beforeEach(() => {
cart = { items: [] };
});
it('starts empty', () => {
assert.equal(cart.items.length, 0);
});
it('adds items', () => {
cart.items.push('book');
assert.deepEqual(cart.items, ['book']);
});
});
Subtests and async tests
The test() callback receives a context object t, and calling t.test() creates a subtest. Subtests are awaited as part of their parent, so the parent only finishes once its children do. This is handy for table-driven tests or grouping assertions without a separate describe block. Because tests are promise-aware, you simply await inside and return — no done callback required.
import { test } from 'node:test';
import assert from 'node:assert/strict';
test('user API', async (t) => {
await t.test('fetches a user', async () => {
const res = await fetch('https://jsonplaceholder.typicode.com/users/1');
const user = await res.json();
assert.equal(user.id, 1);
});
await t.test('rejects unknown users', async () => {
const res = await fetch('https://jsonplaceholder.typicode.com/users/99999');
assert.equal(res.ok, false);
});
});
Assertions with node:assert
node:assert/strict covers the common cases without any extra matcher library. The most-used methods:
| Method | Checks |
|---|---|
assert.equal(a, b) | a === b (strict mode) |
assert.deepEqual(a, b) | Deep structural equality |
assert.ok(value) | Value is truthy |
assert.throws(fn, /regex/) | fn throws a matching error |
assert.rejects(promise) | A promise rejects |
assert.match(str, /regex/) | String matches a pattern |
import { test } from 'node:test';
import assert from 'node:assert/strict';
function parsePort(value) {
const n = Number(value);
if (!Number.isInteger(n) || n < 1) throw new RangeError('invalid port');
return n;
}
test('parsePort validates input', () => {
assert.equal(parsePort('8080'), 8080);
assert.throws(() => parsePort('abc'), /invalid port/);
});
Skipping, todo, and focusing tests
You can mark tests to skip, flag work-in-progress with todo, or run a subset with only. Skipped and todo tests still appear in the report so they are not silently lost.
import { test } from 'node:test';
test('flaky network call', { skip: 'needs live server' }, () => {});
test('rewrite parser', { todo: true }, () => {});
test('focus this one', { only: true }, () => {});
To honor only, run with node --test --test-only.
Running tests
Point the runner at your project and it discovers test files automatically. Common invocations:
# Run every test file Node can find under the project
node --test
# Run a single file
node --test math.test.js
# Filter by test name
node --test --test-name-pattern="user API"
# Re-run automatically when files change
node --test --watch
Watch mode keeps the process alive and re-executes affected tests on save, giving you a tight feedback loop without nodemon.
Reporters
Node bundles several reporters and lets you pick one with --test-reporter. The default is spec for a TTY and tap when output is piped or running in CI. You can even emit multiple formats at once by pairing each reporter with a destination.
| Reporter | Output |
|---|---|
spec | Human-readable, indented tree (default in a terminal) |
tap | Test Anything Protocol — machine-parseable |
dot | One character per test, compact |
junit | JUnit XML for CI dashboards |
# Pretty console output plus a JUnit file for CI
node --test \
--test-reporter=spec --test-reporter-destination=stdout \
--test-reporter=junit --test-reporter-destination=results.xml
Add
--experimental-test-coverageto print a line/branch coverage summary after the run — nonycorc8required. It is still experimental but works well on Node 22.
Best Practices
- Import
node:assert/strictso equality checks use===by default and never silently coerce types. - Name test files with the
.test.jssuffix (or place them intest/) sonode --testdiscovers them automatically. - Prefer
await t.test(...)subtests ordescribe/itfor structure; both keep async tests deterministic. - Use
beforeEach/afterEachto isolate state so tests never depend on execution order. - Add an
"test": "node --test"script topackage.jsonand a--watchvariant for local development. - Let the runner default to
tap/junitreporters in CI andspeclocally — drive it with--test-reporterrather than hand-formatting. - Reach for
node:testfirst on libraries with no other dependencies; adopt Jest or Vitest only when you need their richer mocking or snapshot ecosystems.