HomeThe built-in Node.js test runner: a complete guide

The built-in Node.js test runner: a complete guide

By · Node.js & JavaScript developer
Published July 5, 2026

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

Setup 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 0

Watch mode

Add --watch to rerun tests on every file change — a built-in replacement for jest --watch.

node --test --watch

Code 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 report

Reading 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.ts and run node --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.

Sources & further reading

About Code with Node.js

This is a personal blog and reference point of a Node.js developer.

I write and explain how different Node and JavaScript aspects work, as well as research popular and cool packages, and of course fail time to time.