Node’s built-in test runner has been stable since Node 20. node --test discovers your test files, runs each one in its own child process, and gives you describe/it, hooks, spies, fake timers and snapshot assertions with nothing in devDependencies. Coverage, watch mode and module mocking work as well, though the docs still mark all three experimental. That last detail decides most migrations.
What you get with nothing installed
With no arguments, Node runs every file 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}. The same six patterns apply to .ts, .cts and .mts unless you pass --no-strip-types, so a TypeScript suite runs with no loader at all. Narrow the set by passing globs as the final arguments: node --test "src/**/*.test.ts".
The API is the shape you know from Jest, minus the globals. test, it, suite, describe, before, after, beforeEach and afterEach, each with .skip, .todo and .only variants. Nothing is injected into global scope, so every file imports what it uses, and assertions come from node:assert rather than expect.
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { scheduleReminder } from '../src/reminder.js';
describe('scheduleReminder', () => {
it('fires once the delay has elapsed', (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const onFire = t.mock.fn();
scheduleReminder(globalThis, onFire, 30_000);
assert.equal(onFire.mock.callCount(), 0);
t.mock.timers.tick(30_000);
assert.equal(onFire.mock.callCount(), 1);
});
it('records the arguments it was called with', (t) => {
const repo = { save(row) { return row.id; } };
t.mock.method(repo, 'save');
repo.save({ id: 'r_1' });
assert.equal(repo.save.mock.callCount(), 1);
assert.deepEqual(repo.save.mock.calls[0].arguments, [{ id: 'r_1' }]);
assert.equal(repo.save.mock.calls[0].result, 'r_1');
});
});
t.mock is the test’s own tracker and its mocks are undone when that test finishes. The mock exported from node:test is shared for the whole file and stays in place until something calls mock.restoreAll(), which is the sort of thing that bites you three tests later.
Fake timers cover setTimeout, clearTimeout, setInterval, clearInterval, setImmediate, clearImmediate and Date, enabled per API rather than all at once. Snapshots stopped being experimental in v23.4.0: t.assert.snapshot(value) writes a .snapshot file beside the test, and node --test --test-update-snapshots regenerates it.
Is the Node built-in test runner stable?
node:test itself is Stability 2, stable, and has been since v20.0.0. Several things bolted to it are not, and the documentation is specific about which.
Watch mode (--watch) is Stability 1, experimental. So is coverage collection. Module mocking is Stability 1.0, early development, and stays behind --experimental-test-module-mocks. Global setup and teardown (--test-global-setup, added in v24.0.0) is 1.0, as are random execution order (--test-randomize, v26.1.0) and test tags (--experimental-test-tag-filter, v26.2.0).
Experimental here means the surface can change on a minor release, not that it falls over. Coverage and watch mode have been steady for years and I would ship both. Module mocking is the one worth thinking about.
How do you mock a module without jest.mock?
Nothing gets hoisted. t.mock.module() replaces a specifier’s exports for the rest of the test, and you await import() the module under test afterwards so it picks up the replacement.
import { it } from 'node:test';
import assert from 'node:assert/strict';
it('prices in GBP without calling the rates API', async (t) => {
t.mock.module('../src/rates.js', {
exports: { getRate: () => 1.25 },
});
const { totalInGbp } = await import('../src/pricing.js');
assert.equal(totalInGbp(100), 125);
});
Run that with node --test --experimental-test-module-mocks, and accept an ExperimentalWarning on stderr every time.
Two traps here. The exports option is recent: it landed in Node 25.9.0 on 1 April 2026 and was backported to 24.15.0 two weeks later, superseding defaultExport and namedExports, which the docs now mark deprecated. On an earlier 24.x the option is ignored and the import fails with SyntaxError: The requested module './rates.js' does not provide an export named 'getRate', which reads like a bug in your source and is not one. Second, module customisation hooks registered through the asynchronous API are ignored, because the test runner’s loader is synchronous.
Jest is no happier. Its own documentation still calls ESM support experimental, it wants node --experimental-vm-modules, and jest.mock hoisting does not apply to ES modules at all, so you end up on jest.unstable_mockModule. Vitest is the one with a settled answer, because Vite owns the module graph.
Coverage thresholds and CI
node --test \
--experimental-test-coverage \
--test-coverage-include='src/**' \
--test-coverage-lines=90 \
--test-coverage-branches=85 \
--test-reporter=spec --test-reporter-destination=stdout \
--test-reporter=lcov --test-reporter-destination=lcov.info
Falling under a threshold exits with code 1, so the build fails on the coverage number without any plugin. Collection is V8-based, node_modules/ and the matched test files are excluded by default, and awkward branches can be skipped inline with /* node:coverage ignore next 3 */.
The built-in reporters are spec, tap, dot, junit and lcov, and every flag in this section is documented in the Node CLI reference. Pass --test-reporter more than once, each with its own --test-reporter-destination, and you get human output and a machine artefact from one run. For larger suites, --test-shard=1/4 splits files across CI machines, --test-concurrency sets how many file processes run at once (default os.availableParallelism() - 1), and --test-rerun-failures <state-file>, added in v24.7.0, re-runs only what failed last time.
--test-isolation=none drops the child process per file and imports everything into one process. Faster, and global state now leaks between files. That is the trade.
What it will not do
There is no expect, no toMatchInlineSnapshot, and no test.each, so table-driven cases become a loop over an array calling it() with an interpolated name.
There is no DOM. No jsdom option, no browser mode, so React component tests stay on Vitest 4 or Jest 30.
There is no transform pipeline, and this is the real dividing line. Node ignores tsconfig.json completely, so paths aliases never resolve, and .tsx files are unsupported outright. Type stripping erases types and does nothing else, with edges we went through in running TypeScript in Node without a loader. If your tests import JSX, CSS or aliased paths, you want Vite in front of them, which means Vitest.
Migrating a Jest suite
Most of it is mechanical. expect(x).toBe(y) becomes assert.equal, toEqual becomes assert.deepEqual, rejects.toThrow becomes assert.rejects. jest.fn() is mock.fn(), jest.spyOn(obj, 'm') is t.mock.method(obj, 'm'), jest.useFakeTimers() is t.mock.timers.enable({ apis: ['setTimeout'] }), and toMatchSnapshot() is t.assert.snapshot(). Then add the imports Jest was giving you for free.
{
"scripts": {
"test": "node --test",
"test:watch": "node --test --watch",
"test:ci": "node --test --experimental-test-coverage --test-coverage-lines=90 --test-reporter=junit --test-reporter-destination=junit.xml"
}
}
For a Node service, a CLI or a library, that is where I would start now. The dependency you delete is not the point; the version skew you delete is. A test runner that ships with the runtime cannot drift from it, which is the same argument we made for the Rust linters replacing ESLint, and it is why most of the Node work we build now runs on it.
I would not move a large Jest suite that leans on jest.mock in every file, and I would not move component tests anywhere except Vitest. Everything else is a morning’s work.