The Node.js built-in test runner has been stable since Node 20, and in 2026 it is good enough that I start new backend projects without Jest or Vitest. It lives in the node:test module, runs with node --test, and needs zero dependencies. This guide walks through the parts you actually use day to day: writing tests, mocking, fake timers, snapshots, coverage and CI output.

Why use the Node.js built-in test runner

Three practical reasons: no install or config, no transform step that can disagree with your runtime, and faster startup because there is nothing to bootstrap. For a plain Express or Fastify API, that is often all you need. If you are on a recent release, check what changed in Node.js 26 LTS before pinning a version in CI. The official test runner documentation is the reference for anything below.

The stability picture matters: the runner itself, mocking and snapshots are stable, while watch mode and code coverage are still marked experimental. Plan accordingly.

Your first test with node --test

Create sum.test.js next to your code:

import { test } from 'node:test';
import assert from 'node:assert/strict';

function sum(a, b) {
  return a + b;
}

test('sum adds two numbers', () => {
  assert.equal(sum(2, 3), 5);
});

test('async work resolves', async () => {
  const value = await Promise.resolve(42);
  assert.strictEqual(value, 42);
});

Run it with node --test. Node discovers files such as *.test.js, *-test.js and anything inside a test directory. Use node:assert/strict so equal behaves like strictEqual and deepEqual is strict too.

Useful flags while developing:

  • --test-name-pattern="login" runs only tests whose name matches a regex; --test-skip-pattern excludes them.
  • --test-only honors { only: true } on a test.
  • --watch re-runs on file changes.

Organizing tests with describe, it and hooks

describe and it are aliases for suite and test, so the Jest-style layout works unchanged. Hooks are before, after, beforeEach and afterEach.

import { describe, it, before, after, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { createApp } from './app.js';

describe('GET /health', () => {
  let server;
  let baseUrl;

  before(async () => {
    server = createApp().listen(0);
    baseUrl = `http://localhost:${server.address().port}`;
  });

  after(() => server.close());

  it('returns ok', async () => {
    const res = await fetch(`${baseUrl}/health`);
    assert.equal(res.status, 200);
    assert.deepEqual(await res.json(), { status: 'ok' });
  });
});

Listening on port 0 lets the OS pick a free port, which avoids collisions because each test file runs in its own process.

Mocking functions and methods with node:test

The mock tracker offers mock.fn for standalone spies and mock.method for replacing a method on an object. When you use the one on the test context (t.mock), everything is restored automatically when the test ends.

import { test } from 'node:test';
import assert from 'node:assert/strict';

const mailer = {
  async send(to, body) { /* real SMTP call */ },
};

async function registerUser(email) {
  await mailer.send(email, 'Welcome!');
  return { email };
}

test('sends a welcome email', async (t) => {
  const send = t.mock.method(mailer, 'send', async () => undefined);

  await registerUser('a@example.com');

  assert.equal(send.mock.callCount(), 1);
  assert.deepEqual(send.mock.calls[0].arguments, ['a@example.com', 'Welcome!']);
});

For dependencies imported as modules there is mock.module(), but it is gated behind an experimental flag. I prefer passing dependencies in as arguments or exporting an object, which keeps tests independent of that flag. The same pattern works well with a Redis client; see the rate limiting with Redis post for code worth testing this way.

Faking timers and Date

mock.timers replaces setTimeout, setInterval and Date, so tests of expiry logic (OTP windows, token TTLs, debounce) run instantly.

test('runs callback after delay', (t) => {
  const fn = t.mock.fn();
  t.mock.timers.enable({ apis: ['setTimeout'] });

  setTimeout(fn, 5000);
  t.mock.timers.tick(5000);

  assert.equal(fn.mock.callCount(), 1);
});

test('freezes Date', (t) => {
  t.mock.timers.enable({ apis: ['Date'], now: 1_700_000_000_000 });
  assert.equal(Date.now(), 1_700_000_000_000);
  t.mock.timers.tick(1000);
  assert.equal(Date.now(), 1_700_001_000_000 - 0);
});

This is a clean way to verify expiry behavior such as in a Redis-backed OTP flow without sleeping in tests.

Snapshot testing

Snapshots are available through the test context assertions:

test('serializes user', (t) => {
  t.assert.snapshot({ id: 1, role: 'admin' });
});

Generate or refresh the .snapshot file with node --test --test-update-snapshots, and commit it. Keep snapshots small; large ones get approved without being read.

Running TypeScript tests

On current Node versions, type stripping runs .ts files directly, so node --test can execute user.test.ts with no build step. Rules to remember: use explicit .ts extensions in relative imports, avoid enums and namespaces (they need transformation, not just stripping), and run tsc --noEmit separately because Node does not type-check. See the Node.js TypeScript docs for the exact version behavior.

{
  "scripts": {
    "test": "node --test",
    "typecheck": "tsc --noEmit"
  }
}

Code coverage and thresholds

Coverage is built in but still experimental:

node --test --experimental-test-coverage

It prints line, branch and function coverage after the run. For programmatic runs, run() accepts lineCoverage, branchCoverage and functionCoverage thresholds, and the process exits with code 1 if any is missed. Check node --help on your version for equivalent CLI threshold flags. To skip generated code, wrap it in /* node:coverage disable */ and /* node:coverage enable */ comments.

Treat coverage as a map of untested code, not a score to maximize.

Reporters and CI

The default reporter is spec. Others are tap, dot, junit and lcov. You can pair several reporters with destinations:

node --test \
  --experimental-test-coverage \
  --test-reporter=spec --test-reporter-destination=stdout \
  --test-reporter=lcov --test-reporter-destination=lcov.info

Reporters and destinations are matched in order. Most CI systems ingest junit or lcov directly. Concurrency across files is controlled with --test-concurrency.

node:test vs Jest: when to switch

Neednode:testJest / Vitest
Zero dependenciesYesNo
describe/it, hooks, snapshotsYesYes
Fake timers and DateYesYes
Module mockingExperimental flagMature
Rich matchers (expect)Use assertYes
Stable coverageExperimentalYes

If your tests lean heavily on module mocking, jsdom or React component testing, stay with Vitest or Jest. For backend services, scripts and libraries, the built-in runner is usually enough. New to the runtime itself? Start with what Node.js is and how it works.

Conclusion

The fastest way to evaluate it is to migrate one small package: rename imports to node:test and node:assert/strict, replace jest.fn with mock.fn, and run node --test. If the suite passes with no extra tooling, you have removed a dependency tree from your project for free.