
Node.js's built-in test runner: is it worth ditching Jest?
A practical guide to node:test, Node.js's built-in test runner: zero-dependency setup, basic syntax, mocking, coverage, and what's still missing compared to Jest.
Node.js has had a built-in test runner since version 18 (stable since 20), yet the vast majority of projects still install Jest or Vitest by default, often without asking whether they actually need to. The node:test module has matured enough to be a real alternative for a good chunk of projects — not for all of them.
Setup: zero dependencies
Nothing to install. node:test ships with the runtime:
// sum.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { sum } from "./sum.js";
test("adds two positive numbers", () => {
assert.strictEqual(sum(2, 3), 5);
});
test("adds with a negative number", () => {
assert.strictEqual(sum(5, -2), 3);
});
node --test
No config file, no transform to set up for ESM — node --test automatically looks for files matching common patterns (*.test.js, *.spec.js, test/ folders) and runs everything in parallel by default.
describe/it, hooks, and async tests
The syntax is deliberately close to Jest/Mocha’s, to make migration less painful:
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
describe("UserRepository", () => {
let repo;
before(() => {
repo = new UserRepository();
});
after(async () => {
await repo.close();
});
it("finds a user by id", async () => {
const user = await repo.findById(1);
assert.equal(user.name, "Diego");
});
it("throws when the id does not exist", async () => {
await assert.rejects(() => repo.findById(999), /not found/);
});
});
assert.rejects/assert.doesNotReject cover the async cases that in Jest require expect(...).rejects.toThrow() — the API is more verbose in a few places, but covers the same scenarios.
Built-in mocking, no external libraries
From version 20 onward, node:test ships a native mocking system (t.mock), covering a good part of what jest.fn() or sinon used to be needed for:
import { test } from "node:test";
import assert from "node:assert/strict";
test("calls the email service with the right arguments", (t) => {
const send = t.mock.fn(() => true);
registerUser({ email: "[email protected]" }, { sendEmail: send });
assert.strictEqual(send.mock.callCount(), 1);
assert.deepEqual(send.mock.calls[0].arguments, ["[email protected]"]);
});
t.mock.method() also lets you temporarily replace a method on an existing object (the equivalent of jest.spyOn), and it’s restored automatically at the end of the test.
💡 Consiglio
node --test --watch reruns only the tests for changed files, in the same spirit as jest --watch. Useful during development, though the feedback on which tests got re-triggered is more
bare-bones than Jest’s interface.
Coverage without extra packages
node --test --experimental-test-coverage
Coverage is generated internally (it uses V8 instrumentation, not istanbul/nyc), printed to the console by default. For a browsable HTML report you still need to wire up an external reporter — here Jest, with --coverage ready out of the box and mature editor integrations, remains more convenient.
What’s still missing compared to Jest
- Snapshot testing:
node:testhas no native equivalent oftoMatchSnapshot(). For projects that lean heavily on snapshots (typically UI components), that’s an absence that stings. - Built-in DOM/jsdom environment: Jest sets up
jsdomwith a flag; withnode:testyou have to wire it up manually. For testing React/Vue components, that’s extra work Jest (or Vitest) saves you. - Expressive matchers:
assert.strictEqual/assert.deepEqualcover the common cases, butexpect’s matcher ecosystem (toBeCloseTo,toContainEqual, custom matchers) is richer and more readable for complex assertions. - Plugin ecosystem and editor integration: Jest (and Vitest) have years of plugins, VS Code extensions, and mature CI integrations.
node:testis newer and the tooling surface around it is still smaller.
When migrating actually makes sense
For libraries and Node.js packages with no DOM dependencies — utilities, HTTP clients, pure business logic, CLI scripts — node:test is often enough, and it removes an entire dependency from the project, with faster startup (no transform to load) and zero configuration to maintain. For frontend projects with components to render and snapshots to compare, or large, stable test suites already on Jest, migrating rarely pays for the time it costs: node:test’s main benefit is starting without dependencies, not converting something that already works.
For the rest of the Node ecosystem on the security and maintenance side, it’s also worth keeping an eye on the updates covered in the article on Node.js security releases.

Co-Founder & CTO at PAPION. Senior full-stack engineer specializing in React, TypeScript, Node.js, and application security.