Open a new Node.js project in 2026. First command, without thinking: npm install --save-dev jest. Muscle memory. We’ve all done it this week.
Here’s the thing: the Node.js test runner — node:test — went stable in Node 22. We’re on Node 26 now. The experimental flags from every tutorial you read between 2023 and 2025 are gone, and almost nobody updated their reflexes. For server code — API handlers, service functions, database queries — three patterns built into Node cover about 90% of what you’re paying Jest to do.
Three patterns. No framework. No transformer. No jest.config.js. Let me show you which ones, and the one section where I’ll tell you to ignore everything I just said.
Why node:test is finally worth using in 2026
The honest history: node:test shipped experimental in Node 18, went stable in 22, and most of the ecosystem missed it. Every tutorial Google surfaces is from 2024 and opens with “this is still experimental but…” It isn’t. Hasn’t been for two LTS cycles.
What you actually get in the box, no flags required: describe and it (with test as an alias), before/after/beforeEach/afterEach hooks, mock.fn() and mock.method() for spies and stubs, mock.module() for replacing whole ESM modules, multiple reporters (spec, tap, junit, lcov), watch mode, and snapshot testing since 22.3. Coverage lives behind one flag — --experimental-test-coverage — that’s been stable in practice since 22.
Zero config. Zero dependencies. node --test 'src/**/*.test.js' just works. For .test.ts, either node --experimental-strip-types --test (22.6+) or tsx --test if you want full TypeScript fidelity. No ts-jest, no babel-jest, no transform block. The boilerplate that used to take a package.json, a config file, and a coffee is now a single CLI invocation.
Here’s the reframe that matters: this is not “minimum viable Jest.” For server code, it’s the right tool. The toy add(1, 2) === 3 examples in every other tutorial actively hide that. Real services have dependencies, async work, and integration boundaries — and node:test handles all three. Three patterns prove it.
Pattern 1: Unit tests for services with describe/it
Forget add(1, 2). Here’s a real service function — order validation, the kind of thing you actually write at 4pm on a Tuesday:
// src/services/order.ts
export function validateOrder(order: Order): ValidationResult {
if (!order.customerId) return { ok: false, error: 'missing customerId' };
if (!order.items?.length) return { ok: false, error: 'empty cart' };
const total = order.items.reduce((sum, i) => sum + i.price * i.qty, 0);
if (total <= 0) return { ok: false, error: 'invalid total' };
return { ok: true, total };
}
And the test:
// src/services/order.test.ts
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { validateOrder } from './order.js';
describe('validateOrder', () => {
let order: Order;
beforeEach(() => {
order = { customerId: 'c1', items: [{ price: 10, qty: 2 }] };
});
describe('rejects invalid input', () => {
it('flags missing customerId', () => {
delete order.customerId;
assert.deepEqual(validateOrder(order), { ok: false, error: 'missing customerId' });
});
it('flags empty cart', () => {
order.items = [];
assert.equal(validateOrder(order).ok, false);
});
});
it('returns total for valid orders', () => {
assert.deepEqual(validateOrder(order), { ok: true, total: 20 });
});
});
Run it: node --experimental-strip-types --test 'src/**/*.test.ts'. (If you’re setting up TypeScript from scratch, the TypeScript setup guide walks through the tsconfig side.) The spec reporter is on by default — green ticks, nested groups, durations. If you’ve used Jest, you recognize every keyword on the page.
Two things worth flagging. First, that node:assert/strict import. Use it. Loose assert does type coercion, which is the footgun every junior hits at least once. Strict mode does what you mean. Second, describe blocks nest. Group by scenario, not by function — your test output reads like a spec, which is the whole point.
Tests pass. Green ticks all the way down. But this function is pure — no database, no network, no external service. Real services touch the world. That’s where most teams reach for Jest’s mocks. So what does node:test give you?
Pattern 2: Mocking database and HTTP calls with mock.fn() and mock.module()
This is the pattern that actually decides whether you can drop Jest. Mocking. Three tools cover most cases.
mock.fn() creates a standalone mock — a function you pass in as a dependency. mock.method() replaces a method on an existing object and lets you restore it. mock.module() swaps an entire imported module, including ESM modules with named exports. That last one is the thing every Jest user assumes Node doesn’t have. It does.
Here’s a UserService that hits a repository. The repo is the dependency we want to fake:
// src/services/user.test.ts
import { describe, it, beforeEach, afterEach, mock } from 'node:test';
import assert from 'node:assert/strict';
import { UserService } from './user.js';
import { db } from '../db/index.js';
describe('UserService.getById', () => {
let service: UserService;
beforeEach(() => {
service = new UserService(db);
mock.method(db.users, 'find', async (id: string) => ({
id, name: 'Test User', email: '[email protected]',
}));
});
afterEach(() => mock.restoreAll());
it('returns mapped user shape', async () => {
const user = await service.getById('u1');
assert.deepEqual(user, { id: 'u1', displayName: 'Test User' });
});
it('calls the repository exactly once with the right id', async () => {
await service.getById('u1');
const call = (db.users.find as any).mock.calls[0];
assert.equal(call.arguments[0], 'u1');
assert.equal((db.users.find as any).mock.callCount(), 1);
});
});
That afterEach(() => mock.restoreAll()) is the line everyone skips and then debugs for an hour. Mocks leak between tests if you don’t restore. Put it in every file that uses mocks. Make it a snippet.
For external HTTP, mock.module() is the workhorse. Swap undici or your fetch wrapper with a canned response, assert your service handles a 500 without throwing:
mock.module('../lib/http.js', {
namedExports: { get: async () => ({ status: 500, body: null }) },
});
Honest caveat — the part most “node:test replaces Jest” hype pieces leave out. Complex partial mocks (keep three exports real, fake the fourth) and Jest’s jest.mock(path, factory) auto-mocking are still rougher than Jest. If your test suite leans heavily on auto-mocked classes with generated method stubs, you’ll feel friction. For straightforward “fake this dependency, assert these calls,” you won’t.
Your tests pass, your dependencies are stubbed, and your assertions check call counts. So they’re exercising the code. Are they exercising enough of it?
Pattern 3: Built-in coverage with –experimental-test-coverage
One flag:
node --experimental-strip-types --test \
--experimental-test-coverage \
--test-coverage-include='src/**' \
--test-coverage-exclude='**/*.test.ts' \
'src/**/*.test.ts'
That’s the whole setup. No nyc, no c8, no .nycrc. The output is a table at the bottom of your test run — file, line %, branch %, function %, uncovered line numbers. The columns mean what you think: lines tells you which statements ran, branches tells you which if arms were taken, functions tells you which definitions were called at least once. Uncovered line numbers are where you go look next.
The include/exclude flags matter more than they look. Without them you’ll get reports on node_modules paths that slipped past your file glob, and your numbers become noise. Set both.
For CI, emit lcov alongside the spec reporter on stdout:
node --test \
--experimental-test-coverage \
--test-reporter=spec --test-reporter-destination=stdout \
--test-reporter=lcov --test-reporter-destination=coverage/lcov.info \
'src/**/*.test.ts'
Pipe that lcov.info into Codecov, Coveralls, or genhtml for a local HTML report. There’s no built-in HTML generator — that’s the one limit worth knowing.
The flag still says --experimental-test-coverage in 2026. The output is stable. The flag is a label that hasn’t been removed yet. Compared to c8: same V8 backbone, fewer features, zero config. For a server project, fine. For a frontend project with source maps fighting bundlers, c8 still has the edge.
You can write the tests, mock the dependencies, and measure coverage. But your team’s muscle memory is Jest. What does the port actually look like?
Jest-to-node:test migration map
Most files are a 15-minute job. Here’s the map you need:
| Jest | node:test |
|---|---|
describe, it, test |
Same names — import { describe, it, test } from 'node:test' |
beforeAll, afterAll, beforeEach, afterEach |
Same names — import { before, after, beforeEach, afterEach } from 'node:test' (note: before/after, not beforeAll/afterAll) |
expect(x).toBe(y) |
assert.equal(x, y) |
expect(x).toEqual(y) |
assert.deepEqual(x, y) |
expect(fn).toThrow() |
assert.throws(fn) |
expect(promise).rejects.toThrow() |
await assert.rejects(promise) |
jest.fn() |
mock.fn() |
jest.spyOn(obj, 'm') |
mock.method(obj, 'm') |
jest.mock('mod') |
mock.module('mod', { namedExports: { ... } }) |
jest --coverage |
node --test --experimental-test-coverage |
jest --watch |
node --test --watch |
The realistic gotcha: assertion error messages are terser. Jest’s expect produces a colorful diff and a clear “expected X, received Y.” node:assert/strict gives you a one-liner with a structural diff. It’s enough. It’s not luxurious. Adjust your mental model the first time a test fails and you’ll be fine.
In CI, swap jest for node --test in your test script plus the lcov flag. One line. The migration is finite. So when would you deliberately not do it?
When you should stay on Jest
Three situations. Be honest about them.
Large existing Jest suites. If you have 2,000 tests and a green pipeline, porting them is ideology, not engineering. Migrate when you touch the file. New projects only.
Heavy snapshot testing. node:test has basic snapshots since 22.3, but the ecosystem — image snapshots, custom serializers, inline snapshots, jest-image-snapshot — is much thinner. If your suite leans on this, stay.
Plugin-heavy projects. jest-extended, jest-axe, custom matchers, framework presets (ts-jest, babel-jest, next/jest). Replacing those is real work, not a one-line CI change.
One more, while I’m being honest: React or Vue component testing with jsdom and userEvent. node:test technically works. The DX is rough. Vitest is the better landing pad for frontend, not node:test.
Bottom line: for server code in 2026 — APIs, services, background jobs, CLIs — node:test is the default. For mature browser test suites with deep Jest history, stay put.
The bottom line
So back to that reflex from the opening — npm install --save-dev jest on every new project. For server code, in 2026, you can stop. describe/it for your services, mock.fn() and mock.method() for dependencies, --experimental-test-coverage for measurement. Three patterns. No framework. No config file.
One action for tomorrow morning: on the next service you spin up, skip the Jest install. Create src/something.test.ts. Run node --test. See it pass on green. That’s the whole onboarding.
If you want the next step — wiring this into a CI pipeline that doesn’t fight you — the Node.js 26 features post covers the runtime details, and the frontend testing strategy guide handles the other half of the testing question for projects that span both sides.
Delete jest from package.json. Move on.