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.

bash
node --test

That 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.

nodejs 24 test runner

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.

js
// 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 0

describe 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.

js
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: '[email protected]' }), /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.

js
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.

js
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.

js
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.

js
// 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('[email protected]');

  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.

js
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:

json
{
  "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.

bash
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.

js
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

  1. Importing node:assert instead of node:assert/strict. Loose equality lets 1 == '1' pass.
  2. Leaving globs unquoted. Your shell expands ** differently from Node, and some test files silently drop out of the run.
  3. Importing a module before mocking it. mock.module doesn’t touch modules already loaded. Use a dynamic import() after the mock.
  4. Leaving handles open. An unclosed server, DB connection or interval keeps the test process alive. Close them in after before you reach for --test-force-exit.
  5. Skipping the type check. Node runs .ts tests without checking types, so a test can pass against code that tsc would 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.

Thank you for reading and I hope you find it helpful. I sincerely request for your feedback in the comment’s section.