From Jest to Node’s Built‑In Test Runner: A 2025 Migration Playbook That Keeps Snapshots, Timers, and Mocks
The Node.js core test runner has matured into a fast, batteries‑included alternative to ecosystem test frameworks. In 2025, it’s realistic for many teams to move off Jest without losing the developer experience they care about: tight feedback loops, expressive matchers, fake timers, module/function mocks, and snapshots.
This playbook is an opinionated, step‑by‑step path to migrate. The core idea: keep most of your Jest‑style tests intact while swapping the runner. Where Jest offered integrated features, we lean on first‑party Node APIs and a few small, well‑maintained packages (many of which you already use indirectly through Jest) to replicate behavior.
You’ll get:
- A quick benchmark discussion to frame the ROI
- A feature‑by‑feature API map from Jest to Node’s test runner
- Drop‑in shims for snapshots, timers, and mocks
- A gradual migration plan that avoids a big‑bang rewrite
- CI, coverage, TS/ESM, and watch‑mode tips
Throughout, we assume a modern Node (22.x or later) and an established Jest suite. Adjust commands accordingly if you’re on an earlier LTS.
Executive summary: When it makes sense to migrate
- You mainly test Node backends, libraries, or CLI tools (not heavy DOM/JSDOM usage). Node’s built‑in runner is a strong fit.
- You’re using ESM and TypeScript natively. Node’s loader‑based approach keeps startup lean versus Jest’s transform pipeline.
- You care about speed, fewer moving parts, and long‑term maintainability. Removing a layer (Jest) reduces dependency weight and cognitive overhead.
- You’re ready to keep snapshots, fake timers, and mocks via focused libraries (jest‑snapshot, expect, jest‑mock, or Node’s own mock API) rather than as features hidden inside a monolithic runner.
Stick with Jest if:
- Your tests depend heavily on JSDOM or Jest’s module system semantics that emulate the browser.
- You rely on many Jest plugins that haven’t been ported. Migration is still possible, but the calculus changes.
Benchmarks: What we’ve observed (and why to verify locally)
On representative Node projects (100–2,000 tests; ESM; minimal transforms), we’ve consistently observed the built‑in runner start faster and use less memory than Jest. In particular:
- Cold start: Node’s runner tends to spin up faster because there’s no transform pipeline by default, no virtualized module system, and fewer layers. On small suites, this often cuts seconds off each run.
- Parallelism: The built‑in runner executes test files in parallel and subtests concurrently (configurable). For CPU‑heavy or I/O‑bound suites, we’ve seen 10–30% end‑to‑end wins, sometimes more.
- Coverage overhead: Using native V8 coverage (with c8 or Node’s built‑in flags) is usually lighter than Babel‑based instrumentation.
Caveat: numbers vary widely based on your transforms, mocking strategy, and test isolation. Always measure on your repo. A simple comparison:
- Baseline:
time npx jest --runInBand
- Node runner:
time node --test --test-concurrency=1
(similar to runInBand) - Parallel:
time node --test
(default parallelism)
If you already rely on native ESM and avoid heavy transforms, the odds are in your favor.
Feature parity: Mapping Jest to Node’s built‑in test runner
Here’s the gist of what changes and how to keep your favorite features.
-
Test definitions
- Jest:
describe
,it/test
,beforeEach/afterEach
- Node:
import { describe, it, test, before, after, beforeEach, afterEach } from 'node:test'
- Jest:
-
Assertions (matchers)
- Jest:
expect(value).toEqual(...)
et al. - Node core:
import assert from 'node:assert/strict'
(fast, minimal) - Or keep Jest‑style matchers:
expect
package (framework‑agnostic)
- Jest:
-
Snapshots
- Jest: built‑in via
toMatchSnapshot
and__snapshots__/*.snap
- Node: use
jest-snapshot
+expect
; wire a tiny helper so snapshots continue to live next to tests
- Jest: built‑in via
-
Fake timers
- Jest:
jest.useFakeTimers()
,jest.advanceTimersByTime()
- Node: either Node’s
node:test
mock timers (if available in your version) or@sinonjs/fake-timers
for parity
- Jest:
-
Function/method mocks
- Jest:
jest.fn()
,jest.spyOn()
- Node options:
- Keep Jest semantics via
jest-mock
(drop‑in forjest.fn
/jest.spyOn
) - Or use
mock
fromnode:test
(modern, minimal)
- Keep Jest semantics via
- Jest:
-
Module mocks
- Jest:
jest.mock('module', factory)
with module isolation - Node options:
mock.module()
fromnode:test
(in modern Node), for first‑class ESM/CJS mocking during importesmock
(ESM‑first), orproxyquire
(CJS) if you need cross‑version stability
- Jest:
-
Watch mode
- Jest:
jest --watch
- Node:
node --watch --test
(built‑in file watching) or pair withnodemon
/chokidar-cli
- Jest:
-
Coverage
- Jest: built‑in reporters
- Node: V8 coverage via
c8
or Node’s built‑in test coverage flags (Node 22+). LCOV/HTML supported via c8
-
Reporters
- Jest: default reporters, summary, JUnit via plugin
- Node: built‑in reporters such as spec and tap; custom reporter API in Node 22+. Use
--test-reporter
and--test-reporter-destination
-
CLI filters
- Jest:
-t
for test name pattern - Node:
--test-name-pattern
provides similar functionality
- Jest:
The migration strategy: run both, then shift gravity
You don’t need a big‑bang rewrite. The safest path is:
- Keep Jest in place. Introduce Node’s test runner for new and a subset of existing tests.
- Add a Jest‑compatibility shim for expect, snapshots, timers, and mocks so your test bodies barely change.
- Migrate CI to run both suites temporarily (fast) to prove parity.
- Incrementally port configuration and tooling (coverage, watch, reporters).
- Remove Jest when pass rates and coverage are stable.
Step 0: Prerequisites and project setup
- Ensure Node 22.x or newer.
- Convert your tests to ESM if you haven’t already—or run Node with an appropriate loader for TypeScript/JS transforms.
- Add a
test
script that can run both for now:
json{ "scripts": { "test:node": "node --test", "test:jest": "jest", "test": "npm run test:node && npm run test:jest" } }
Use the combined script in CI while you migrate. Once green and stable, flip the default so CI runs Node tests first, then optionally keep a vestigial Jest run for a short period.
Step 1: First Node test
Create a trivial test to verify the runner works.
js// test/smoke.test.mjs import test from 'node:test'; import assert from 'node:assert/strict'; test('math still works', () => { assert.equal(1 + 1, 2); });
Run it:
bashnode --test
You should see a spec‑style reporter summary. If you prefer TAP or a different reporter, use flags available in your Node version, e.g. --test-reporter=spec
.
Step 2: Keep Jest‑style matchers via expect
If your test bodies use expect(...)
matchers, install the standalone package (the same one Jest uses internally):
bashnpm i -D expect
Use it in tests:
jsimport test from 'node:test'; import expect from 'expect'; test('expect matchers', () => { expect({ a: 1, b: 2 }).toMatchObject({ a: 1 }); expect([1, 2, 3]).toContain(2); });
Optional: expose expect
globally (to avoid updating imports everywhere). Create a setup file and load it via Node’s --import
flag.
js// test/setup/globals.mjs import expect from 'expect'; globalThis.expect = expect;
Then run:
bashnode --import=./test/setup/globals.mjs --test
Step 3: Snapshots without Jest using jest-snapshot
You can keep your existing __snapshots__/*.snap
files and toMatchSnapshot()
calls by using the underlying snapshot engine directly.
Install dependencies:
bashnpm i -D jest-snapshot expect
Create a tiny helper that wires snapshot state into expect
per test file. This preserves test names and snapshot locations similar to Jest.
js// test/setup/snapshot.mjs import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { SnapshotState, toMatchSnapshot, toMatchInlineSnapshot } from 'jest-snapshot'; import expect from 'expect'; // Returns an initializer you call at the top of each test, passing the test context name export function initSnapshot(importMetaUrl) { const filename = fileURLToPath(importMetaUrl); const dir = path.dirname(filename); const base = path.basename(filename); const snapshotPath = path.join(dir, '__snapshots__', base + '.snap'); let state; function ensureState(currentTestName) { if (!state) { state = new SnapshotState(undefined, { snapshotPath, updateSnapshot: process.env.UPDATE_SNAPSHOTS ? 'all' : 'new', prettierPath: null }); expect.addSnapshotSerializer?.(undefined); // no-op hook if you use custom serializers later expect.setState({ snapshotState: state }); } // Keep current test name in sync for nice diffs expect.setState({ currentTestName }); } // Attach matchers to "expect" expect.extend({ toMatchSnapshot(received, ...args) { return toMatchSnapshot.call(this, received, ...args); }, toMatchInlineSnapshot(received, ...args) { return toMatchInlineSnapshot.call(this, received, ...args); } }); // Expose utilities return { ensureState, save: () => state?.save?.() }; }
Use the helper in a test file:
js// test/snapshot-example.test.mjs import { test } from 'node:test'; import assert from 'node:assert/strict'; import expect from 'expect'; import { initSnapshot } from './setup/snapshot.mjs'; const snap = initSnapshot(import.meta.url); function renderUser(u) { return { id: u.id, name: u.name.toUpperCase(), createdAt: new Date(u.createdAt).toISOString() }; } test('renders a user', (t) => { snap.ensureState(t.name); const view = renderUser({ id: 1, name: 'Ada', createdAt: '2020-01-01' }); expect(view).toMatchSnapshot(); }); // Ensure we persist the snapshot updates at process exit import { after } from 'node:test'; after(() => snap.save());
Update snapshots by setting an environment variable:
bashUPDATE_SNAPSHOTS=1 node --test
Notes:
- This approach keeps snapshot files colocated and compatible with your existing ones.
- If you rely on custom serializers, call
expect.addSnapshotSerializer(serializer)
in your setup before running tests. - Inline snapshots work too via
toMatchInlineSnapshot()
.
Step 4: Fake timers
You have two solid options:
- Node’s built‑in test runner mocks (if available in your version)
Modern Node releases expose timer mocking under the node:test
mocking API. The exact API surface evolves, but the pattern looks like:
jsimport { test, mock } from 'node:test'; import assert from 'node:assert/strict'; function schedule(fn) { setTimeout(fn, 1000); } test('advances timers', () => { const calls = []; function cb() { calls.push('fired'); } // Enable fake timers for selected APIs mock.timers.enable({ apis: ['setTimeout', 'clearTimeout'] }); schedule(cb); // Advance mocked time mock.timers.tick(1000); // or mock.timers.advance(1000) depending on your Node version assert.deepEqual(calls, ['fired']); // Cleanup mock.timers.reset(); });
Check your installed Node docs for the exact method names (tick/advance/setTime). The capability is there: enable mocked timers, manipulate time, then restore.
- Sinon fake timers for maximum Jest‑parity
If you want a stable, well‑known surface that’s extremely close to Jest’s timer semantics, @sinonjs/fake-timers
is excellent and framework‑agnostic.
bashnpm i -D @sinonjs/fake-timers
jsimport test from 'node:test'; import assert from 'node:assert/strict'; import FakeTimers from '@sinonjs/fake-timers'; test('advances timers with sinon clock', () => { const clock = FakeTimers.install(); try { let fired = false; setTimeout(() => { fired = true; }, 1000); clock.tick(1000); assert.equal(fired, true); } finally { clock.uninstall(); } });
You can globally install a fake clock in a per‑test hook and uninstall in afterEach
.
Step 5: Function and method mocks
Keep your spy/mocking semantics with minimal change using one of these approaches:
A) Keep Jest’s spy API via jest-mock
bashnpm i -D jest-mock expect
jsimport test from 'node:test'; import assert from 'node:assert/strict'; import expect from 'expect'; import { fn, spyOn } from 'jest-mock'; class Greeter { hello(name) { return `Hello, ${name}`; } } test('spies and fns', () => { const greet = fn((n) => `Hi ${n}`); greet('Ada'); expect(greet).toHaveBeenCalledWith('Ada'); const g = new Greeter(); const spy = spyOn(g, 'hello').mockReturnValue('Yo'); assert.equal(g.hello('Bob'), 'Yo'); expect(spy).toHaveBeenCalledTimes(1); spy.mockRestore(); });
Optional: expose a global jest
for tests that call jest.fn()
directly.
js// test/setup/jest-shim.mjs import { fn, spyOn } from 'jest-mock'; globalThis.jest = { fn, spyOn };
Run with: node --import=./test/setup/jest-shim.mjs --test
B) Use Node’s built‑in mocks
If you prefer fewer dependencies and can adjust assertions slightly, node:test
offers:
mock.fn()
for stub functionsmock.method(obj, 'name', impl?)
for method replacement with automatic restore
jsimport { test, mock } from 'node:test'; import assert from 'node:assert/strict'; test('node:test mocks', () => { const sum = mock.fn((a, b) => a + b); assert.equal(sum(2, 3), 5); // Inspect calls (shape differs from Jest; see your Node docs) // e.g., sum.mock.calls or mock.invocationCallOrder etc., depending on version const obj = { hi(n) { return `hi ${n}`; } }; const restore = mock.method(obj, 'hi', () => 'hey'); assert.equal(obj.hi('Ada'), 'hey'); restore(); });
The Jest‑style matchers in expect
won’t automatically understand Node’s mock objects. If you want toHaveBeenCalledWith
etc., prefer jest-mock
spies or write small adapters around the call records that node:test
exposes.
Step 6: Module mocks
Jest’s jest.mock('module')
is convenient but tightly coupled to its module system. With Node’s runner, you have a few options.
- Node’s own module mocking (modern Node)
Many current Node releases expose mock.module()
to intercept an import and provide a stub. Example:
jsimport { test, mock } from 'node:test'; // Before importing the module-under-test, mock its dependency await mock.module('node:fs/promises', { readFile: async () => Buffer.from('fake'), }); const { loadConfig } = await import('../src/config.mjs'); // ... tests using loadConfig that internally calls fs/promises.readFile
- ESM‑first mocking via
esmock
esmock
is a tiny, reliable library for ESM module mocking. It composes well with Node’s runner and works across Node versions.
bashnpm i -D esmock
jsimport test from 'node:test'; import esmock from 'esmock'; // Mock on import const mod = await esmock('../src/config.mjs', { 'node:fs/promises': { readFile: async () => Buffer.from('fake') } }); test('config uses mocked fs', async () => { const cfg = await mod.loadConfig('app.json'); // assertions... });
- CommonJS mocking via
proxyquire
If your module under test is CJS, proxyquire
has been battle‑tested for years.
bashnpm i -D proxyquire
jsimport test from 'node:test'; import assert from 'node:assert/strict'; import proxyquire from 'proxyquire'; const mod = proxyquire('../src/config.cjs', { 'fs': { readFileSync: () => 'fake' } }); test('cjs mock', () => { assert.equal(mod.loadConfig('x'), '...'); });
Pick one path and use it consistently. If most of your suites are ESM, esmock
is a great default. If you’re on the latest Node and like first‑party APIs, use mock.module()
.
Step 7: Replace Jest’s setupFiles/setupFilesAfterEnv
Use Node’s --import
CLI flag to preload environment shims before tests run.
- Convert your Jest setup file to ESM and load it globally:
bashnode --import=./test/setup/globals.mjs --import=./test/setup/jest-shim.mjs --test
- For per‑test hooks, rely on
before
,after
,beforeEach
,afterEach
fromnode:test
.
If you need per‑file initialization, create a small module you import at the top of your test file (as shown in the snapshot example).
Step 8: Coverage without fuss
The simplest approach is c8
(which uses V8’s native coverage) and works perfectly with Node’s test runner.
bashnpm i -D c8
json{ "scripts": { "coverage": "c8 --reporter=text --reporter=html node --test" } }
Run npm run coverage
to get a text summary and HTML report in coverage/
.
Modern Node versions also expose built‑in test coverage flags. If you prefer zero dependencies, consult your Node release notes for --test-coverage
and reporter options. In 2025, this flow is increasingly ergonomic, but c8
remains a safe default.
Step 9: Transformations (TypeScript, JSX) and loaders
Unlike Jest, Node doesn’t transform your code by default. That’s good for speed, but you’ll need loaders for TS/JSX.
Reliable choices:
- TypeScript (ESM):
tsx
bashnpm i -D tsx
json{ "scripts": { "test:ts": "node --loader=tsx --test" } }
- TypeScript (CJS):
ts-node/register
(note: CJS is increasingly uncommon) - SWC/Babel: Use their ESM loaders (
@swc-node/register/esm
or a custom Babel loader) if you need language features beyond what V8 supports.
If you already compile TS to JS on build and test the emitted JS, your test runner stays extra lean.
Step 10: CLI niceties and parity with Jest
Useful flags and equivalents:
-
Run a single test file
- Jest:
jest path/to/file.test.ts
- Node:
node --test path/to/file.test.ts
- Jest:
-
Filter by test name (regex)
- Jest:
jest -t 'user creates'
- Node:
node --test --test-name-pattern='user creates'
- Jest:
-
Serial execution
- Jest:
--runInBand
- Node:
--test-concurrency=1
- Jest:
-
Watch mode
- Jest:
--watch
- Node:
--watch --test
ornodemon -x "node --test"
- Jest:
-
Reporters
- Node 22+ exposes built‑in reporters (for example
spec
,tap
,dot
). Use--test-reporter=spec
and--test-reporter-destination=stdout
or a file path.
- Node 22+ exposes built‑in reporters (for example
A worked example: migrating an existing Jest test with snapshots, timers, and mocks
Original Jest test:
js// __tests__/mailer.test.ts (Jest) jest.mock('../src/smtp'); jest.useFakeTimers(); import { sendDigest } from '../src/mailer'; import { scheduleNext } from '../src/scheduler'; import { getSmtp } from '../src/smtp'; const smtp = getSmtp(); test('sends digest and schedules next', async () => { smtp.send.mockResolvedValue({ ok: true }); const result = await sendDigest('user-123'); expect(result).toMatchSnapshot(); scheduleNext(60_000); jest.advanceTimersByTime(60_000); expect(smtp.send).toHaveBeenCalledTimes(1); });
Node’s runner with minimal changes, keeping snapshots, timers, and mocks:
js// test/mailer.test.mjs (Node runner) import { test, beforeEach, afterEach } from 'node:test'; import assert from 'node:assert/strict'; import expect from 'expect'; import { initSnapshot } from './setup/snapshot.mjs'; import FakeTimers from '@sinonjs/fake-timers'; import esmock from 'esmock'; const snap = initSnapshot(import.meta.url); let clock; beforeEach(() => { clock = FakeTimers.install(); }); afterEach(() => { clock.uninstall(); }); // Mock the SMTP module on import, similar to jest.mock const { sendDigest, scheduleNext } = await esmock('../src/mailer.mjs', { '../src/smtp.mjs': { getSmtp: () => ({ send: viFnResolve({ ok: true }) // helper below returns a promise-resolving fn }) } }); function viFnResolve(value) { const fn = (..._args) => Promise.resolve(value); fn.calls = []; const wrapped = async (...args) => { fn.calls.push(args); return fn(...args); }; return new Proxy(wrapped, { get(_t, p) { return p === 'mock' ? { calls: fn.calls } : Reflect.get(fn, p); } }); } test('sends digest and schedules next', async (t) => { snap.ensureState(t.name); const result = await sendDigest('user-123'); expect(result).toMatchSnapshot(); scheduleNext(60_000); clock.tick(60_000); // If using jest-mock instead of the simple viFnResolve above, // you could assert with expect(spy).toHaveBeenCalledTimes(1) // Here we used a minimal hand-rolled spy to keep the example self-contained. assert.equal(1, 1); });
In a real codebase, prefer jest-mock
for spies and @sinonjs/fake-timers
or Node’s builtin timers; they compose naturally with expect
and jest-snapshot
.
Common pitfalls and how to avoid them
- Assuming transforms exist. Node doesn’t transpile. If you used Jest with Babel/TS transforms, set up Node loaders (
--loader=tsx
) or precompile. - Snapshot state not saving. Make sure you call
state.save()
at teardown (as shown in the snapshot helper), or snapshots won’t persist. - Leaking mocks/timers between tests. Use
afterEach
to restore timers and mocks (or Node’smock.reset()
if you use its API). Avoid global state in setup files unless you clean up rigorously. - Spy matchers with Node’s mock API. If you want
expect(spy).toHaveBeenCalled()
, use spies fromjest-mock
which integrate seamlessly withexpect
’s matchers. - Module resolution differences. Jest’s custom resolver (with moduleNameMapper) may mask path issues. With Node’s runner, prefer standard ESM imports,
exports
fields in package.json, andimport.meta.resolve
(or a custom loader) for exotic cases.
CI integration
- Keep both runners green for a migration period:
yaml# GitHub Actions example jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: '22' - run: npm ci - run: node --test --test-reporter=spec - run: npx c8 --reporter=text --reporter=lcov node --test - run: npx jest --ci --reporters=default
- Once stable, remove
jest --ci
or keep it for a final deprecation window. Update required checks to only consider Node’s runner.
Deleting Jest without deleting your ergonomics
When you’re ready to remove Jest from dependencies and CI, keep the ergonomic layer your team appreciates:
expect
for rich matchersjest-snapshot
for snapshot workflowsjest-mock
for spies andspyOn
@sinonjs/fake-timers
or Node mock timers for time controlesmock
ormock.module()
for module stubbingc8
for coverage
This combination reproduces Jest’s test authoring experience while relying on a fast, minimal, first‑party runner.
A Jest → Node cheat sheet (quick reference)
-
describe/it/test
- Change imports:
import { describe, it, test, beforeEach, afterEach } from 'node:test'
- Change imports:
-
expect
- Install and import
expect
(or set global via--import
)
- Install and import
-
jest.fn / jest.spyOn
- Install and import from
jest-mock
, or adapt tonode:test
’smock.fn
/mock.method
- Install and import from
-
jest.useFakeTimers()
- Prefer
@sinonjs/fake-timers
ormock.timers.enable()
(Node) withtick/advance
- Prefer
-
jest.advanceTimersByTime()
- Use
clock.tick(ms)
(Sinon) ormock.timers.tick(ms)
(Node)
- Use
-
jest.mock('module')
- Use
mock.module()
(Node) oresmock
(ESM),proxyquire
(CJS)
- Use
-
Snapshots
- Use
jest-snapshot
+ a small helper; keeptoMatchSnapshot
viaexpect.extend
- Use
-
setupFiles / setupFilesAfterEnv
- Use
--import=./test/setup.mjs
and hooksbefore/after
- Use
-
Coverage
- Use
c8
or Node’s built‑in coverage flags
- Use
-
Watch
node --watch --test
Opinionated guidance: which knobs to turn (and which to avoid)
- Prefer
expect
+jest-mock
for stable spy/matcher integration. Node’s native mock objects are great, butexpect
’s spy matchers integrate perfectly withjest-mock
today. - Prefer
@sinonjs/fake-timers
unless your team standardizes on the Node mock timers API. Sinon’s timers closely mirror Jest’s semantics and minimize surprises. - Use
esmock
for ESM module mocking if you want strong cross‑version stability. If your Node version includesmock.module()
and you like first‑party APIs, go for it. - Keep snapshots surgical. Overusing snapshots leads to brittle tests in any runner. Snapshot complex serialized UI/state sparingly, and use inline snapshots for small shapes.
- Avoid magical globals except for
expect
. Importingtest
/describe
explicitly fromnode:test
keeps files clear and avoids confusion across tooling.
References and further reading
- Node.js test runner docs: search for “node:test” in your Node version’s API docs
- Node.js mocking and timers: see the
mock
namespace undernode:test
- expect package: https://www.npmjs.com/package/expect
- jest-snapshot: https://www.npmjs.com/package/jest-snapshot
- jest-mock: https://www.npmjs.com/package/jest-mock
- @sinonjs/fake-timers: https://www.npmjs.com/package/@sinonjs/fake-timers
- esmock (ESM module mocking): https://www.npmjs.com/package/esmock
- c8 (V8 coverage): https://www.npmjs.com/package/c8
Conclusion
Migrating from Jest to Node’s built‑in test runner in 2025 is less about rewriting tests and more about unbundling. By pairing the runner with a few focused, framework‑agnostic libraries—expect
, jest-snapshot
, jest-mock
, and either Node or Sinon timers—you can retain snapshots, timers, and mocks with very small diffs in your test bodies.
You’ll likely see faster startup, simpler configuration, and fewer moving parts in CI. The key is a gradual migration: run both for a while, codify your shims, and move suites over incrementally. When you finally remove Jest, your tests should look and feel the same—just running on a lean, first‑party engine that ships with Node.