C.W.K.
Stream
Lesson 01 of 05 · published

node --test — The Built-In Test Runner

~12 min · modern-node, testing, node-test

Level 0Node Curious
0 XP0/40 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"For a decade, every Node project shipped Jest or Mocha. In 2026 the runtime ships its own. Most projects don't notice — and that's the cost of not noticing."

What You Get For Free

Node 20+ ships node:test — a full test runner built into the binary. node --test scans your project for test files and runs them. No package install. No config file. No transpilation step.

// test/add.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { add } from '../src/math.mjs';

test('add returns the sum of two numbers', () => {
  assert.equal(add(1, 2), 3);
});

test('add handles negatives', () => {
  assert.equal(add(-1, 1), 0);
});

Run with node --test. Output is TAP format by default; pass --test-reporter=spec for jest-style output.

The Full API

It's not just test():

  • describe(name, fn) — grouping
  • before / after / beforeEach / afterEach — hooks
  • test.skip — skip a test
  • test.only — run only this (when invoked with --test-only)
  • test.todo — mark as planned
  • Subtests via t.test(...) inside a test
  • Async tests via async functions or callbacks taking t

If you've used Mocha, the shape is familiar. Coverage via node --test --experimental-test-coverage (or stable equivalents in newer Node).

Mocking — node:test/mock

Built-in mocking lives in node:test's t.mock API:
import { test } from 'node:test';
import assert from 'node:assert/strict';

import * as db from '../src/db.mjs';

test('fetches user from db', (t) => {
  const stub = t.mock.method(db, 'findUser', () => ({
    id: 1, name: 'Pippa'
  }));

  const result = getUser(1);
  assert.equal(result.name, 'Pippa');
  assert.equal(stub.mock.calls.length, 1);
});
You can stub methods, time-travel via t.mock.timers.enable(), mock fetch globally. The API is smaller than Jest's but covers the 80% case. For larger ecosystems (snapshot testing, complex fixtures) Jest still wins; for most Node services, node:test is enough.

Watch Mode

node --test --watch reruns tests on file change. Same primitive as node --watch for servers — built into the runtime, no nodemon-for-tests needed.

What You Lose Going From Jest

Jest has features node:test doesn't:

  • Snapshot testing (you can roll your own, but the dedicated API is missing).
  • Powerful matchers (expect(x).toEqual({...}) with deep partial matching).
  • Automatic module mocking (jest.mock('./module') rewrites imports).
  • Watch-mode UI with focused test selection.

If your project depends on these, stay with Jest. If your tests are mostly assertions + a few mocks, switching to node:test drops a 200MB dependency from your devDependencies tree.

Pippa's Confession

cwkPippa's frontend used to ship vitest in devDependencies (200MB worth of node_modules). The backend used pytest — different language, no overlap. When I helped Dad audit npm install times, the test runners were a major contributor. I tried converting frontend tests to node:test + the built-in mock API. Same coverage, dependency tree halved, install time visibly faster. Dad's note: "Use vitest because you need its features, not because it's the default." Now I default to node:test and reach for vitest only when the team needs Jest-flavored APIs.

Code

Real-feeling integration test with node:test·javascript
// A test file with subtests, hooks, and an async test
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { Server } from '../src/server.mjs';

describe('Server', () => {
  let server;
  before(async () => { server = await Server.start({ port: 0 }); });
  after(async () => { await server.stop(); });

  test('GET / returns hi', async () => {
    const res = await fetch(`http://localhost:${server.port}/`);
    assert.equal(res.status, 200);
    assert.equal(await res.text(), 'hi');
  });

  test('POST /echo round-trips', async (t) => {
    await t.test('with JSON', async () => {
      const res = await fetch(`http://localhost:${server.port}/echo`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: '{"a":1}',
      });
      assert.deepEqual(await res.json(), { a: 1 });
    });
  });
});
The flags you actually use·bash
# Run all tests under ./test
node --test

# Run only one file (or matching pattern)
node --test test/add.test.mjs
node --test test/**/*.test.mjs

# Watch mode — reruns on file change
node --test --watch

# Spec reporter (jest-style output)
node --test --test-reporter=spec

# Just the tests marked test.only
node --test --test-only

# With coverage
node --test --experimental-test-coverage

# Run with strip-types so .ts test files work natively (Node 22+)
node --test --experimental-strip-types test/**/*.test.ts

External links

Exercise

Convert a small project that uses Jest (or vitest, or mocha) to node:test. Pick a project with under 100 tests so the conversion is bounded. Track: (a) install time and node_modules size before/after, (b) any features you couldn't reproduce, (c) any tests that needed to be rewritten because the API differed. The exercise's point is the third axis — what does your test code actually depend on that you didn't realize?
Hint
Most surface-level conversions are mechanical: expect(a).toBe(b)assert.equal(a, b), jest.fn()t.mock.fn(). Snapshot tests and module mocking are the two patterns that often don't translate cleanly. If you find yourself wanting jest.mock('./db'), that's the moment to evaluate whether you needed module-level mocking or whether dependency injection would be cleaner.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue
💛 by Ttoriwarm

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.