Acumatica · Nodejs

Node.js 22 Test Runner — A Field Guide

Node's built-in test runner (node:test) has matured through Node 22 — test/describe/it, mocking, coverage, and watch mode — and where it still trades blows with Jest and Vitest.

John Kihiu12 min read

I put off adopting node:test for longer than I should have, on the assumption a built-in test runner would be a stripped-down toy next to Jest. It isn't, not anymore. By the time you're on Node 22, node:test has parallel test files, built-in mocking, snapshot support, coverage reporting, and a watch mode — enough that for a decent chunk of projects I no longer reach for an external test framework at all.

The basics: test, describe, it

The core API is deliberately close to what Jest and Mocha users already know: test() for a standalone case, describe()/it() if you prefer that grouping style, both importable from node:test with zero install. Assertions come from node:assert/strict, which has been solid for years and covers the same ground as most assertion libraries.

JAVASCRIPT · node:test basics
import { test, describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { formatCurrency } from '../src/format.js';

describe('formatCurrency', () => {
  it('rounds to two decimal places', () => {
    assert.equal(formatCurrency(19.999), '$20.00');
  });

  it('throws on negative input', () => {
    assert.throws(() => formatCurrency(-5), /negative/);
  });
});

test('handles zero without formatting error', () => {
  assert.equal(formatCurrency(0), '$0.00');
});

Run it with node --test, which discovers files matching the usual conventions (*.test.js, files under a test/ directory) with no config file needed for the default case.

Mocking with node:test's built-in mock

The mock export on the test context replaces what used to require sinon or jest.mock for a lot of common cases: mocking a method on an object, stubbing timers, tracking call counts and arguments.

JAVASCRIPT · MOCKING
import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as emailer from '../src/emailer.js';

test('sends a welcome email on signup', (t) => {
  const sendMock = t.mock.method(emailer, 'send', () => Promise.resolve(true));

  return signupUser({ email: 'a@b.com' }).then(() => {
    assert.equal(sendMock.mock.callCount(), 1);
    assert.equal(sendMock.mock.calls[0].arguments[0], 'a@b.com');
  });
});
Mocks are scoped to the test context automatically

t.mock.method(...) restores the original implementation after the test finishes — you don't need an explicit afterEach cleanup step the way some other mocking libraries require, which removes a category of test-pollution bugs where one test's mock leaks into the next.

Coverage and watch mode

Run node --test --experimental-test-coverage and you get a coverage report with no nyc or c8 install — it's still flagged experimental as of Node 22, but it's been reliable in the projects I've used it on. Pair it with node --test --watch and you have re-run-on-save plus coverage with zero dependencies beyond Node itself.

Where Jest and Vitest still win

The built-in runner doesn't do everything. Vitest's watch-mode UI, in-source testing, and tight Vite integration are still ahead of what node:test offers out of the box. Jest's snapshot testing ecosystem, its jsdom integration for component testing, and its sheer breadth of community plugins are still unmatched if you're testing React components rather than backend logic. For a Node backend or CLI tool with no DOM in the picture, node:test is now genuinely competitive; for frontend component testing, I still reach for Vitest.

Check your CI runner's Node version before betting on this

--experimental-test-coverage and some of the newer mocking APIs require a reasonably current Node 22.x patch release, not just "Node 22" loosely. Pin your CI image and verify the exact minor version before assuming a feature from the latest docs is actually available.

Wrapping up

node:test crossed the line from "interesting experiment" to "reasonable default" somewhere in the Node 20-to-22 window, at least for backend and tooling projects that don't need a DOM. It won't replace Vitest for frontend work, but for a Node service, it's now a legitimate reason to carry one fewer dependency.

John Kihiu
Acumatica ERP Developer · Laravel Engineer

Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.