The built-in Node.js test runner: a complete guide
You no longer need Jest, Mocha, or Vitest to test a Node.js project. Node.js 26 ships a complete test runner in node:test — with suites, hooks, mocking, filtering, watch mode, and code coverage — that you invoke with node --test. No dependencies, no config file. Every command and its output below is from a stock node:26-slim Docker image.
Writing a test
Import test (or describe/it) from node:test and assert with the built-in node:assert/strict. Test files are just modules — name them *.test.mjs (or *.test.js, *.test.ts) and the runner finds them.
// cart.test.mjs
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { subtotal, withTax } from './cart.mjs';
describe('cart', () => {
let items;
beforeEach(() => {
items = [{ price: 10, qty: 2 }, { price: 5, qty: 1 }];
});
it('sums line items', () => {
assert.equal(subtotal(items), 25);
});
it('applies 20% tax by default', () => {
assert.equal(withTax(25), 30);
});
});Running the tests
node --test auto-discovers test files — anything matching *.test.* or *-test.*, plus files under a test/ directory — and runs each in its own process for isolation.
$ node --test
▶ cart
✔ sums line items (0.51625ms)
✔ applies 20% tax by default (0.739792ms)
✔ accepts a custom rate (0.126ms)
✔ cart (2.007333ms)
✔ fetchPrice calls the API and returns the price (0.956959ms)
ℹ tests 4
ℹ suites 1
ℹ pass 4
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 48.276209Setup and teardown hooks
Four hooks cover fixtures: before/after run once per suite, and beforeEach/afterEach run around every test (that's the beforeEach rebuilding the cart above).
import { before, beforeEach, afterEach, after } from 'node:test';
before(() => { /* once, before all tests in scope */ });
beforeEach(() => { /* before every test */ });
afterEach(() => { /* after every test */ });
after(() => { /* once, after all tests */ });Mocking — no extra library
The mock helper creates spies and stubs. mock.fn() wraps a function and records every call; inspect .mock.callCount() and .mock.calls. There's also mock.method() to replace a method on an object, and mock.timers for fake timers.
// mock.test.mjs
import { test, mock } from 'node:test';
import assert from 'node:assert/strict';
import { fetchPrice } from './cart.mjs';
test('fetchPrice calls the API and returns the price', async () => {
const fakeFetch = mock.fn(async () => ({ json: async () => ({ price: 99 }) }));
const price = await fetchPrice(fakeFetch, 42);
assert.equal(price, 99);
assert.equal(fakeFetch.mock.callCount(), 1);
assert.deepEqual(fakeFetch.mock.calls[0].arguments, ['/prices/42']);
});Filtering which tests run
Use --test-name-pattern to run only tests whose name matches a regex. In code you can also mark tests with { only: true } (with --test-only), test.skip(), and test.todo().
$ node --test --test-name-pattern="tax"
▶ cart
✔ applies 20% tax by default (0.900583ms)
✔ cart (1.856459ms)
ℹ tests 2
ℹ pass 2
ℹ fail 0Watch mode
Add --watch to rerun tests on every file change — a built-in replacement for jest --watch.
node --test --watchCode coverage
Pass --experimental-test-coverage for a built-in report (test files are excluded automatically). Enforce minimums with --test-coverage-lines, --test-coverage-branches, and --test-coverage-functions.
$ node --test --experimental-test-coverage
...
ℹ start of coverage report
ℹ ----------------------------------------------------------
ℹ file | line % | branch % | funcs % | uncovered lines
ℹ ----------------------------------------------------------
ℹ cart.mjs | 100.00 | 100.00 | 100.00 |
ℹ ----------------------------------------------------------
ℹ all files | 100.00 | 100.00 | 100.00 |
ℹ ----------------------------------------------------------
ℹ end of coverage reportReading a failure
When an assertion fails you get the failing test, a diff, and a stack trace pointing at the exact line.
✖ failing tests:
test at fail.test.mjs:5:1
✖ shows a nice diff on failure (0.915958ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
30 !== 31
at TestContext.<anonymous> (file:///w/fail.test.mjs:6:10)What about…?
- TypeScript: name files
*.test.tsand runnode --test— Node strips the types for you (see running TypeScript natively in Node.js). - Reporters:
--test-reporter=spec|tap|junit|dot, or plug in your own. - Snapshots: assertion snapshots are supported via
t.assert.snapshot(). - DOM testing: there's no built-in jsdom — for component or browser tests you'll still reach for a browser-based tool.
Bottom line
For unit and integration tests of Node code, node:test plus node:assert covers suites, hooks, mocking, filtering, watch, and coverage with zero dependencies and near-instant startup. For a new project, try it before adding a test framework — you may not need one.