I Deleted Jest: Testing with Node's Built-in Test Runner
How to use node:test in Node 24: describe/it, mocks, fake timers, module mocking, coverage thresholds, TypeScript and when Jest or Vitest is still the better pick.
On this page 15 sections
On a small backend service I maintain, Jest and its dependencies were the heaviest thing in node_modules. The runner that replaced them adds nothing to the install, because it ships with Node.
node --testThat one command finds your test files, runs them and prints a report. This post covers what you need to move a real suite over: the API, mocking, coverage, TypeScript, a Jest translation table and the places where the built-in runner still falls short.
It’s part three of a series on Node’s built-in toolchain. Part two replaced nodemon and dotenv with --watch and --env-file.

Quick answer
Yes, node:test is production ready. The module has been marked Stable since Node 20. I did the swap on Node 24, where a few rough edges (coverage thresholds, more reporters, TypeScript test files) were already smoothed out. For the rest of that release, see the complete Node.js 24 breakdown.
Two parts are still flagged in the docs. Code coverage is Experimental, and module mocking is Early development behind --experimental-test-module-mocks. Everything else in this post is stable.
node:test vs Jest vs Vitest at a glance
node:test |
Vitest | Jest | |
|---|---|---|---|
| Install | Nothing, ships with Node | npm package | npm package |
| ESM | Native | Native | Still needs --experimental-vm-modules |
| TypeScript | Type stripping, no type check | Through Vite/esbuild | ts-jest or Babel |
| DOM testing | None | jsdom or happy-dom | jsdom |
| Function mocks | mock.fn, mock.method |
vi.fn, vi.spyOn |
jest.fn, jest.spyOn |
| Module mocks | mock.module (experimental flag) |
vi.mock |
jest.mock |
| Fake timers | mock.timers |
vi.useFakeTimers |
jest.useFakeTimers |
| Snapshots | Yes, stable since v22.13 | Yes | Yes |
| Coverage | Built in (experimental) | Plugin package | Built in |
| Best fit | Backend services, libraries, CLIs | Vite apps, new TS projects | Large existing suites, React Native |
Writing your first test
Import from node:test and assert with node:assert/strict. If you’ve used any runner before, the shape will look familiar.
// math.test.js
import { test, describe, it } from 'node:test';
import assert from 'node:assert/strict';
describe('math', () => {
it('adds', () => {
assert.equal(1 + 1, 2);
});
it.skip('not ready yet', () => {});
});
test('a standalone test', () => {
assert.ok(true);
});Run node --test and you get this:
▶ math
✔ adds (0.5ms)
﹣ not ready yet (0.05ms) # SKIP
✔ math
ℹ tests 2
ℹ pass 1
ℹ skipped 1
ℹ fail 0describe and it are aliases for suite and test, and they nest as deep as you like. Mark single cases with .skip, .todo or .only. Pass --test-only to run just the only ones.
Use node:assert/strict, not plain node:assert. The plain version’s equal uses loose ==, so assert.equal(1, '1') passes.
Setup, teardown and async tests
The four hooks are before, after, beforeEach and afterEach. Jest’s beforeAll and afterAll become before and after.
Async tests need no special syntax. Mark the function async or return a promise, and Node waits.
import { describe, it, beforeEach, after } from 'node:test';
import assert from 'node:assert/strict';
describe('user repository', () => {
let db;
beforeEach(async () => { db = await openTestDb(); });
after(async () => { await db.close(); });
it('saves a user', async () => {
const user = await db.users.create({ name: 'Ada' });
assert.equal(user.name, 'Ada');
});
it('rejects a duplicate email', async () => {
await assert.rejects(db.users.create({ email: 'taken@example.com' }), /duplicate/);
});
});A test fails when it throws or when its promise rejects. You don’t wire up a separate assertion library.
Testing an Express API
People search for this one a lot, and it needs no supertest. Start the app on port 0 so the OS picks a free port, then hit it with the global fetch.
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { once } from 'node:events';
import { app } from '../src/app.js';
describe('GET /health', () => {
let server;
let baseUrl;
before(async () => {
server = app.listen(0);
await once(server, 'listening');
baseUrl = `http://localhost:${server.address().port}`;
});
after(() => server.close());
it('returns 200', async () => {
const res = await fetch(`${baseUrl}/health`);
assert.equal(res.status, 200);
assert.deepEqual(await res.json(), { status: 'ok' });
});
});Close the server in after. If you skip it, the open handle keeps the test file’s process alive and the run hangs. The same applies to database connections and intervals. The app structure here follows the Express REST API guide.
Mocking without a library
Most people assume this part needs Jest. It doesn’t. The mock object (or t.mock inside a test) covers functions, methods, timers and modules.
Function and method mocks
mock.fn() wraps a function and records every call. mock.method() replaces a method on an object. Mocks created through t.mock restore themselves when the test ends.
import { test } from 'node:test';
import assert from 'node:assert/strict';
test('records calls', (t) => {
const double = t.mock.fn((x) => x * 2);
double(3);
double(4);
assert.equal(double.mock.callCount(), 2);
assert.deepEqual(double.mock.calls[0].arguments, [3]);
assert.equal(double.mock.calls[0].result, 6);
});
test('stubs a method', async (t) => {
const mailer = { send: async () => { throw new Error('real network call'); } };
t.mock.method(mailer, 'send', async () => ({ id: 'fake' }));
const res = await mailer.send('hi');
assert.equal(res.id, 'fake');
});Each entry in mock.calls has arguments, result, error and this. That’s your toHaveBeenCalledWith: a deepEqual on calls[i].arguments.
If mock.method throws an error, check whether the property is non-configurable. ESM namespace exports are frozen, so you can’t stub a named export in place. Export an object and stub the method on it, or pass the dependency in. The ESM migration guide explains why imports behave this way.
Fake timers
mock.timers fakes setTimeout, setInterval, setImmediate and Date, so code that waits a minute runs instantly.
test('retries after one second', (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const retry = t.mock.fn();
setTimeout(retry, 1000);
t.mock.timers.tick(999);
assert.equal(retry.mock.callCount(), 0);
t.mock.timers.tick(1);
assert.equal(retry.mock.callCount(), 1);
});Fake timers only cover the APIs you list in apis, and process.nextTick isn’t one of them. If the code under test mixes timers with nextTick or promises, read how those queue up in the Node.js timers guide before you trust a green test. For Temporal.Now, stub it with t.mock.method, as shown in the Temporal migration guide.
Module mocks (experimental)
mock.module() is the closest thing to jest.mock(). On Node 24 it needs a flag and the docs list it as Early development.
// node --test --experimental-test-module-mocks
import { test } from 'node:test';
import assert from 'node:assert/strict';
test('signup sends a welcome email', async (t) => {
const send = t.mock.fn(async () => ({ id: 'fake' }));
t.mock.module('./mailer.js', { namedExports: { send } });
// Import after mocking, or you get the real module.
const { signup } = await import('./signup.js');
await signup('ada@example.com');
assert.equal(send.mock.callCount(), 1);
});The catch is ordering. A module that was imported before the mock exists keeps the real version, so the code under test has to come in through a dynamic import() after the mock. Jest hides this by hoisting jest.mock() calls. Node doesn’t.
My advice is to avoid building a whole suite on mock.module until it’s stable. Dependency injection works today and survives Node upgrades.
Parameterized tests (the test.each replacement)
Jest users look for test.each and don’t find it. There’s no .each, and you don’t need one. A plain loop registers one test per row, which is what test.each was hiding behind a DSL.
import { test } from 'node:test';
import assert from 'node:assert/strict';
const cases = [
{ input: 'abc', expected: 3 },
{ input: '', expected: 0 },
{ input: 'hello world', expected: 11 },
];
for (const { input, expected } of cases) {
test(`length of "${input}" is ${expected}`, () => {
assert.equal(input.length, expected);
});
}Each row shows up as its own named test, and one failing row doesn’t take the others down. This surprises people more than anything else during a migration, so get used to it before you move a suite over.
TypeScript test files
Node 24 strips types by default, so node --test runs .ts test files with no build step. The default globs pick up *.test.ts, *-test.ts, *_test.ts and anything under a test/ directory, plus the .mts and .cts versions.
Two limits matter. Type stripping doesn’t type-check, so add tsc --noEmit to CI. And syntax that needs code generation, such as enums and parameter properties, fails unless you pass --experimental-transform-types. The details are in native TypeScript support in Node.js 24, and the TypeScript best practices guide has a tsconfig that matches.
Running the suite
With no arguments, node --test searches the current directory for these patterns:
**/*.test.{cjs,mjs,js} **/*-test.{cjs,mjs,js} **/*_test.{cjs,mjs,js}
**/test-*.{cjs,mjs,js} **/test.{cjs,mjs,js} **/test/**/*.{cjs,mjs,js}Pass your own globs to replace them, and quote them so Node expands the pattern instead of your shell: node --test "src/**/*.spec.js".
Each test file runs in its own child process. --test-concurrency sets how many run at once (the default is your CPU count minus one). If spawning a process per file is too slow for a big suite, --test-isolation=none runs everything in one process, at the cost of shared state between files. The worker threads vs cluster vs child process post explains what that process-per-file cost looks like.
The flags I actually use:
| Flag | What it does |
|---|---|
--watch |
Reruns tests when a file changes, the same mechanism as node –watch |
--test-name-pattern, --test-skip-pattern |
Filters tests by name (regex) |
--test-only |
Runs only tests marked .only |
--test-timeout=5000 |
Fails any test that runs longer than 5 s (default is no limit) |
--test-shard=1/3 |
Splits the suite across CI machines |
--test-force-exit |
Exits when tests finish even if handles are open. A last resort, not a fix |
--env-file=.env.test |
Loads test environment variables, no dotenv needed |
A package.json that covers local work and CI:
{
"scripts": {
"test": "node --test",
"test:watch": "node --test --watch",
"test:ci": "node --test --experimental-test-coverage --test-coverage-lines=80 --test-reporter=spec --test-reporter-destination=stdout --test-reporter=junit --test-reporter-destination=junit.xml"
}
}Coverage, reporters and snapshots
Coverage needs no extra package. Add the flag and Node prints a per-file table.
node --test --experimental-test-coverageℹ start of coverage report
ℹ file | line % | branch % | funcs % | uncovered lines
...
ℹ end of coverage report--test-coverage-lines, --test-coverage-branches and --test-coverage-functions each take a minimum percentage and fail the run when coverage drops below it. --test-coverage-include and --test-coverage-exclude take globs, and test files are excluded by default. Coverage is still marked Experimental, so the output format can change between releases. Pin the Node version in CI.
Set the output format with --test-reporter. The built-in reporters are spec (the default), tap, dot, junit and lcov. Pair each reporter with a --test-reporter-destination, as in the test:ci script above, and you get a readable report in the log plus a JUnit file for your CI dashboard from one run. Use lcov if you upload coverage to Codecov or SonarQube.
Snapshot testing is built in too, and stable since v22.13. t.assert.snapshot() records a value on the first run and compares against it after that. --test-update-snapshots regenerates the files.
test('renders the invoice email', (t) => {
t.assert.snapshot(renderInvoiceEmail({ total: 4200 }));
});Snapshots used to be a reason to stay on Jest. They aren’t anymore.
Migrating from Jest: a cheat sheet
Moving a suite over is less a rewrite than an unbundling. describe and it mean the same thing, async tests work the same way and the hooks line up. Most of the diff is in assertions and mocks.
| Jest | node:test |
|---|---|
Globals describe, it, test |
import { describe, it, test } from 'node:test' |
beforeAll / afterAll |
before / after |
expect(a).toBe(b) |
assert.equal(a, b) (strict) |
expect(a).toEqual(b) |
assert.deepEqual(a, b) (strict) |
expect(fn).toThrow(/msg/) |
assert.throws(fn, /msg/) |
await expect(p).rejects.toThrow() |
await assert.rejects(p) |
jest.fn() |
mock.fn() |
jest.spyOn(obj, 'm') |
mock.method(obj, 'm') |
toHaveBeenCalledTimes(n) |
assert.equal(fn.mock.callCount(), n) |
toHaveBeenCalledWith(a, b) |
assert.deepEqual(fn.mock.calls[0].arguments, [a, b]) |
jest.restoreAllMocks() |
mock.restoreAll(), or use t.mock (auto-restores) |
jest.useFakeTimers() |
mock.timers.enable() |
jest.advanceTimersByTime(ms) |
mock.timers.tick(ms) |
jest.mock('./mod') |
t.mock.module('./mod', …) with flag, or dependency injection |
test.each(table) |
A for loop |
toMatchSnapshot() |
t.assert.snapshot(value) |
jest --coverage |
node --test --experimental-test-coverage |
jest --watch |
node --test --watch |
If rewriting every expect is too much churn, install the standalone expect package. It’s Jest’s matcher library without the runner. Its value matchers work fine, but the spy matchers like toHaveBeenCalledWith expect Jest mocks, not mock.fn(), so rewrite those against mock.calls anyway.
Migrate one service at a time, not the whole repo in one PR. Run both runners side by side until the new suite reports the same test count.
When to keep Jest or Vitest
node:test targets backend and library code. Three gaps decide the rest.
There’s no DOM. Jest with jsdom, or Vitest with happy-dom, renders components in a fake browser. Node has no equivalent, so React and Vue component suites stay where they are.
The matcher ecosystem belongs to them. node:assert covers most server-side checks. There’s no first-party counterpart to jest-dom or the long tail of community matchers.
Module tooling is thinner. Path aliases and hoisted module mocks work out of the box in Vitest through its Vite config. Node either lacks them or keeps them behind experimental flags.
The split I landed on: services and libraries run on node --test, and front-end component suites keep their existing runner. On the backend, dropping Jest took a whole dependency tree out of the install. The tests now run on the runtime itself.
Five mistakes to avoid
- Importing
node:assertinstead ofnode:assert/strict. Loose equality lets1 == '1'pass. - Leaving globs unquoted. Your shell expands
**differently from Node, and some test files silently drop out of the run. - Importing a module before mocking it.
mock.moduledoesn’t touch modules already loaded. Use a dynamicimport()after the mock. - Leaving handles open. An unclosed server, DB connection or interval keeps the test process alive. Close them in
afterbefore you reach for--test-force-exit. - Skipping the type check. Node runs
.tstests without checking types, so a test can pass against code thattscwould reject.
Frequently Asked Questions
Is the Node.js test runner stable?
Yes. node:test has been Stable since Node 20. Code coverage is Experimental and module mocking is Early development; the rest of the API is stable.
Which Node version do I need? Node 20 for the stable core. Node 22.8+ for coverage thresholds and Node 22.13+ (or 23.4+) for stable snapshots. Node 24 adds TypeScript test files with no flag, which makes it the easiest starting point.
Can node:test replace Jest? For backend services, libraries and CLIs, yes. For React or Vue component tests that need a DOM, no. Keep Jest or Vitest there.
How do I mock a module in node:test?
Use t.mock.module() with --experimental-test-module-mocks, and import the code under test after the mock. For stable code, inject dependencies or stub a method on an exported object with t.mock.method().
Does node:test support TypeScript?
Yes, through type stripping. node --test finds .ts test files by default on Node 24. It doesn’t type-check, so run tsc --noEmit separately.
Is node:test faster than Jest? It skips the transform and module-registry layers Jest adds, so startup is usually quicker. Published benchmarks disagree with each other, though. Time your own suite with both runners before you quote a number.
Related reading
- I Deleted nodemon and dotenv: node –watch and –env-file in Node 24. The previous part of this series.
- Native TypeScript Support in Node.js 24. What type stripping handles and what it doesn’t.
- Moment.js to Temporal: A Migration Guide. Mocking the clock with
node:test. - Node.js Error Handling Patterns for Production. The errors your
assert.rejectscalls should check for.
Thank you for reading and I hope you find it helpful. I sincerely request for your feedback in the comment’s section.