12k
All articles

Dropping Jest for Node's Built-In Test Runner

Node test runner vs Jest: stable features, watch mode, snapshots, fake timers, coverage, TypeScript support, and what you lose on migration.

OpenReplay Team
OpenReplay Team
Dropping Jest for Node's Built-In Test Runner

Node’s built-in test runner can replace Jest for most server-side suites. The runner itself has been stable since Node 20.0.0, and watch mode, snapshot testing, and fake timers all exist today, even though older migration guides still list them as missing.

The frustration that drives this migration is familiar. Your service is plain ESM, yet your test command drags in a transform pipeline, a config file, and a dependency tree that breaks on major upgrades, all to run functions and assert on results. The open question is not whether node:test exists but which parts of it are stable enough to wire into CI. What follows walks the feature surface in order, gives the stability level of each piece from the Node test runner documentation, and sets out what you lose against Jest and Vitest.

Key Takeaways

  • Node’s test runner has been stable since v20.0.0, but coverage still requires the experimental --experimental-test-coverage flag and watch mode is also marked experimental.
  • Snapshot testing landed in v22.3.0 and became stable in v23.4.0; fake timers via mock.timers have been stable since v23.1.0 and can mock Date.
  • ES module exports are frozen, so mock.method cannot replace a named export; export an object instead, or use the experimental mock.module() behind --experimental-test-module-mocks.
  • .ts test files run without a loader because type stripping is on by default, stable since v24.12.0.
  • What you give up leaving Jest is not features but ergonomics: the matcher vocabulary, the jsdom environment, and one-line stub helpers like mockResolvedValue.

What Do You Get with No Dependencies?

The zero-dependency baseline is node:test for structure and node:assert for assertions, executed with node --test. You get describe/it (aliases for suite/test), before/after/beforeEach/afterEach hooks, subtests, skip and todo, and a nonzero exit code on failure.

// math.test.js
import { describe, it } from 'node:test';
import assert from 'node:assert';

describe('add', () => {
  it('sums two numbers', () => {
    assert.strictEqual(1 + 2, 3);
  });
});
node --test

Stability is per feature, not per module, and that distinction is what matters before you commit a pipeline. Here is where each piece stands:

FeatureFlag / APIStatusVersion
Runner corenode --testStableStable since v20.0.0
Watch mode--watchExperimentalAdded v19.2.0
Snapshotst.assert.snapshot()StableAdded v22.3.0, stable v23.4.0
Fake timersmock.timersStableStable since v23.1.0
Coverage--experimental-test-coverageExperimental-
Module mockingmock.module()Early developmentAdded v22.3.0 / v20.18.0
Test tags--experimental-test-tag-filterEarly developmentAdded v26.2.0, backported to v24.19.0
TypeScript type strippingon by defaultStableStable since v24.12.0

Running and Filtering With the Node Test Runner

With no arguments, node --test discovers files matching **/*.test.{cjs,mjs,js}, **/*-test.{cjs,mjs,js}, **/*_test.{cjs,mjs,js}, **/test-*.{cjs,mjs,js}, **/test.{cjs,mjs,js}, and **/test/**/*.{cjs,mjs,js}, plus the same six patterns with {cts,mts,ts} unless you turn type stripping off with --no-strip-types. You can also pass explicit globs as arguments.

Filtering maps directly onto Jest habits:

node --test --test-name-pattern="parses headers"   # like jest -t
node --test --test-skip-pattern="integration"      # inverse filter
node --test --test-only                             # honor { only: true }

--test-only is the piece Jest users miss first: marking a test { only: true } does nothing unless the flag is passed. Test tags arrived with --experimental-test-tag-filter in v26.2.0 and were backported to the LTS line in v24.19.0, both at early-development stability. The filter syntax is not identical on the two lines: v26 accepts boolean expressions and wildcards, while 24.x matches literal tag names. Either way, early development is too green to gate a pipeline on.

Watch Mode

Watch mode exists and is invoked with node --test --watch. It keeps an eye on your test files and the modules they pull in, then reruns whatever a change affects. The docs still mark watch mode Stability 1, Experimental, added in v19.2.0. In practice that means it is fine as a local development loop and should stay out of CI scripts, which do not need it anyway.

{
  "scripts": {
    "test": "node --test",
    "test:watch": "node --test --watch"
  }
}

Coverage Is Still Behind a Flag

Code coverage still requires --experimental-test-coverage, so a coverage-gated pipeline is opting into an unstable surface. Scope what gets measured with --test-coverage-include and --test-coverage-exclude globs, and emit machine-readable output for CI with the lcov reporter:

node --test --experimental-test-coverage \
  --test-coverage-include='src/**' \
  --test-reporter=lcov --test-reporter-destination=lcov.info

Thresholds are enforceable too, via --test-coverage-lines, --test-coverage-branches, and --test-coverage-functions, or through the equivalent lineCoverage, branchCoverage, and functionCoverage options of the programmatic run() API. The other built-in reporters are spec (the default), tap, dot, and junit.

Mocking: Spies, Timers, and the Frozen-Exports Wall

The mock object from node:test covers spies (mock.fn), method stubs (mock.method), and fake timers (mock.timers). There is no mockResolvedValue; you stub async results with an async mockImplementation. Assertions read from mock.callCount() and mock.calls[n].arguments instead of matchers:

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

test('spy records calls', (t) => {
  const fn = t.mock.fn();
  fn('a');
  assert.strictEqual(fn.mock.callCount(), 1);
  assert.deepStrictEqual(fn.mock.calls[0].arguments, ['a']);
});

Fake timers have been stable since v23.1.0 and mock setTimeout, setInterval, setImmediate, and Date, advanced with tick() or runAll(). There is one gap worth knowing about: pull a timer out of a module by destructuring, as in import { setTimeout } from 'node:timers', and the mock will not apply to it.

test('advances mocked time and Date together', (t) => {
  t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: 100 });
  const fn = t.mock.fn();
  setTimeout(fn, 200);
  t.mock.timers.tick(200);
  assert.strictEqual(fn.mock.callCount(), 1);
  assert.strictEqual(Date.now(), 300);
});

The real constraint is module mocking. ES module exports are frozen, so mock.method cannot replace a named export; the durable workaround is exporting an object and mocking the method on it:

// before: cannot be stubbed
export function fetchUser(id) { /* ... */ }

// after: stubbable with mock.method(api, 'fetchUser')
export const api = {
  fetchUser(id) { /* ... */ },
};

Node does ship an official alternative, mock.module(), which mocks ESM, CJS, JSON, and builtin modules, but it sits behind --experimental-test-module-mocks at early-development stability. Use it to experiment, not to anchor a CI suite.

TypeScript Without a Loader

Node runs .ts, .mts, and .cts test files directly through type stripping, which is enabled by default (since v23.6.0 and v22.18.0) and stable since v24.12.0, meaning stable on the 24.x LTS line. The test runner matches TypeScript file patterns automatically unless you pass --no-strip-types. The older recipe of wiring in a loader such as tsx, described in Mehul Kar’s Node-20-era migration post, is now history for test execution, though stripping only erases types, so enum and other runtime TS syntax still needs a transform.

What Do You Give Up Against Jest and Vitest?

The honest trade is ergonomics, not capability. Three losses are real. First, the matcher ecosystem: Jest’s expect gives you toHaveBeenNthCalledWith and hundreds of community matchers, while node:assert leaves you composing assertions from deepStrictEqual and mock.calls. The extension point is assert.register(), added in v23.7.0 and v22.14.0, which lets you define custom assertions on the test context. Second, browser-like environments: there is no jsdom or happy-dom equivalent, so component tests that touch the DOM should stay on Vitest or Jest. Third, stub convenience: no mockResolvedValue, no test.each (a for...of loop does the job), and per-call stubbing goes through mockImplementationOnce rather than chained helpers. Erick Wendel’s migration guide maps these translations pair by pair, though its fake-timers section predates the landed mock.timers API and reads as a draft proposal.

Where Does That Leave a Migrating Suite?

For a Node service, CLI, or library that never touches the DOM, the built-in runner covers the stable core of what Jest was doing, with zero dependencies and no transform layer; the remaining experimental edges are coverage, watch mode, module mocking, and tags. A low-risk path is to convert one package, keep coverage gating on your existing tooling until the flag drops, and rewrite matcher-heavy assertions as you touch them. Run node --test against a single converted file and see how much of your config directory you can delete.

FAQs

Does node --test run test files in parallel?

Yes. Process isolation is the default, so every test file gets its own child process, and --test-concurrency sets how many of those may run at the same time. Within a single file, tests still run one after another unless you set a concurrency option on test or describe. If your suites share a database, a port, or global state, --test-concurrency=1 keeps it to one file at a time.

Can I run Jest and node:test side by side during a migration?

Yes. The runners are independent, so you can keep separate npm scripts and migrate file by file. The catch is overlapping discovery: both match files like *.test.js by default, so scope each runner with explicit globs, separate directories, or Jest's testMatch setting to prevent converted files from running twice or unconverted files from failing under node --test.

Does node:test work with CommonJS projects?

Yes. The runner is module-system agnostic: require('node:test') and require('node:assert') work in CommonJS files, and the default discovery patterns explicitly include .cjs alongside .mjs and .js. The one requirement is the node: scheme, so require('test') or import test from 'test' fails. A mixed codebase can run ESM and CJS test files in the same node --test invocation.

Which Node version should I target to adopt node:test in CI?

Node 24 LTS covers the stable core: the runner (stable since v20.0.0), snapshot testing, mock.timers fake timers, and default TypeScript type stripping. Coverage and watch mode remain experimental on every release line. Two newer test runner features reached 24.x by backport rather than staying on the current line, test tags in v24.19.0 and run-order randomization in v24.16.0, but both sit at early development, so do not build CI gates on them yet.

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.