Skip to content

Node.js Built-in Test Runner vs Jest: A 2026 Comparison

Does node --test Actually Replace Jest?

Node's built-in test runner covers assertions, mocking, and coverage without a dependency. Here's where it matches Jest and where Jest still wins.

· · 8 min read
A developer programming on a laptop

Quick Take

I deleted Jest from a small CLI tool and replaced it with node --test in under an hour. That win didn't repeat on a larger app with React components, and the reason why is the actual point of this comparison.

The pitch for Node's built-in test runner is simple: one less dependency, one less thing to keep updated, one less item in package.json that can drift out of sync with your Node version. That pitch holds up well for a backend package. It falls apart the moment you need to render a React component in a test, and knowing where that line sits is what actually matters here.

Quick take: node --test, stable since Node 20, covers assertions, mocking, snapshots, and coverage with zero dependencies, and runs TypeScript test files natively since Node 23.6. It's a strong Jest replacement for Node-only backend code and CLI tools. It has no built-in DOM environment, so React or Vue component tests still need Jest or Vitest, which integrate jsdom/happy-dom and framework testing utilities directly.

What Does a Test File Look Like Side by Side?

// Jest
import { describe, it, expect, jest } from '@jest/globals';
import { formatPrice } from './pricing.js';

describe('formatPrice', () => {
  it('formats cents as dollars', () => {
    expect(formatPrice(1999)).toBe('$19.99');
  });

  it('calls the logger on invalid input', () => {
    const logSpy = jest.fn();
    formatPrice(-1, { onError: logSpy });
    expect(logSpy).toHaveBeenCalledTimes(1);
  });
});
// node:test, zero dependencies
import { describe, it, mock } from 'node:test';
import assert from 'node:assert/strict';
import { formatPrice } from './pricing.js';

describe('formatPrice', () => {
  it('formats cents as dollars', () => {
    assert.strictEqual(formatPrice(1999), '$19.99');
  });

  it('calls the logger on invalid input', () => {
    const logSpy = mock.fn();
    formatPrice(-1, { onError: logSpy });
    assert.strictEqual(logSpy.mock.calls.length, 1);
  });
});

The structure is nearly identical, describe/it survived the transition unchanged. The real difference is expect(x).toBe(y) versus assert.strictEqual(x, y), and jest.fn() versus mock.fn(). If your team already thinks in Jest's expect API, that's a real (if small) retraining cost across a whole test suite. A built-in test runner is a testing framework shipped directly inside the language runtime rather than installed as a separate package, and node:test is exactly that: it's been part of Node.js core, stable, since Node 20, according to the Node.js docs. That matters practically because node:assert has existed in Node since the very first releases, so node --test is really pairing two already-stable pieces of the runtime rather than introducing new surface area, which is part of why the migration path from Jest tends to be mechanical rather than risky.

How Do You Run It?

node --test
node --test --experimental-test-coverage
node --test src/**/*.test.ts   # TypeScript directly, no build step, Node 23.6+

No config file, no jest.config.js, no transform setup for TypeScript. That last point matters more than it sounds: a Jest + TypeScript setup needs ts-jest or a Babel transform configured correctly, a source of its own recurring version-compatibility headaches. node --test running .ts files directly sidesteps that entire category of configuration problem. Per the Node.js documentation, native TypeScript execution landed as a default-on feature in Node 23.6, after shipping earlier behind the --experimental-strip-types flag on Node 22.x. Type stripping only removes type annotations at parse time, it does not transform syntax, so features that require actual code generation, enums and namespaces among them, still need the separate --experimental-transform-types flag or a real build step. In practice that limitation bites less often than you'd expect, since most modern TypeScript code avoids enums and namespaces anyway.

Close-up of a laptop screen displaying lines of source code
Photo by Behnam Norouzi on Unsplash

Where Does the Built-in Runner Fall Short?

// This needs a DOM. node --test has no built-in DOM environment.
import { render, screen } from '@testing-library/react';
import { describe, it } from 'node:test';
import { LoginForm } from './LoginForm.jsx';

describe('LoginForm', () => {
  it('shows a validation error', () => {
    render(<LoginForm />); // fails: no `document` in the node:test environment
    // ...
  });
});

Jest and Vitest both integrate jsdom (or happy-dom for Vitest) as a configurable test environment, providing a document, window, and the DOM APIs a rendered React component needs. node --test runs in a plain Node.js environment with none of that, and there's no first-party plan to add it, DOM emulation is explicitly out of scope for what the built-in runner is trying to be. For component-level frontend testing, that gap is the deciding factor, not a preference. A DOM environment means a simulated document and window object good enough to let a component-testing library render markup and query it without a real browser, and it is the one piece of test infrastructure the built-in runner does not attempt to provide.

A blue jigsaw puzzle with a single piece missing from the middle
Photo by Tanja Tepavac on Unsplash

How Do the Mocking Approaches Compare?

FeatureJestnode:test
Function mocksjest.fn()mock.fn()
Module mocksjest.mock('./module')mock.module() (Node 22.3+, still experimental)
Timer mocksjest.useFakeTimers()mock.timers.enable()
Snapshot testingBuilt-in, matureBuilt-in since Node 22, fewer format options
DOM environmentjsdom/happy-dom via configNone built-in

mock.module() is the newest and least mature entry here, module mocking in the built-in runner still has rough edges around ESM interop that Jest's older, CommonJS-era mocking system doesn't share. If your test suite leans heavily on mocking entire modules rather than individual functions, that's worth testing carefully before committing to a full migration. The API shapes differ enough to matter during a port. Jest gives you jest.fn(), jest.spyOn(), and jest.mock() as three separate entry points, while the built-in runner puts all of it behind a single mock object imported from node:test, with mock.fn(), mock.method(), and mock.module() as the equivalents. Restoring behaviour differs too: Jest leans on config flags like restoreMocks, whereas node:test resets mocks per test file and gives you mock.reset() for anything finer. Neither is harder, but a mechanical find-and-replace won't get you there.

How Do You Decide Which One to Use?

  • Node-only backend package, CLI tool, or library with no DOM dependency: node --test covers it, and removing a dependency is a real, if modest, win for install time and maintenance surface.
  • Frontend app with React/Vue components under test: Jest or Vitest, the DOM environment isn't optional. Vitest specifically integrates well if you're already on Vite for the build.
  • Mixed monorepo, some packages backend-only, some frontend: it's reasonable to use node --test for the backend packages and Vitest for the frontend ones, rather than forcing one runner across a codebase where the two halves have genuinely different needs.

One question settles this faster than any feature matrix: does anything in the suite need a document or a window? If the answer is no, the built-in runner is a genuine option and you get to delete somewhere between 3 and 5 devDependencies along with a config file. If the answer is yes for even a handful of tests, you need Jest or Vitest for those, and the only real decision left is whether to run 2 runners side by side or keep one for everything. Teams underestimate how workable the split is in a monorepo, where each package already has its own test script and nobody has to think about which runner is in play.

How to decide, step by step:

  1. Check whether any test in the suite renders a component or touches the DOM.
  2. If none do, run node --test against the existing suite and see how many assertions need only a mechanical expect to assert swap.
  3. If some tests need a DOM, split the suite: node --test for backend packages, Jest or Vitest for anything rendering UI.
  4. Re-evaluate yearly, since DOM support has never been on the built-in runner's roadmap and that isn't likely to change.
A small gold balance scale resting on colorful painted wood, weighing two options
Photo by Elena Mozhvilo on Unsplash

Conclusion

node --test isn't a strictly-worse or strictly-better alternative to Jest, it's a better fit for a narrower job: testing Node.js code with no DOM dependency, with zero install footprint and native TypeScript support. The moment a test needs to render a component, that narrower scope becomes the deciding factor, and Jest or Vitest remain the right tool, not because the built-in runner is immature, but because DOM emulation was never something it set out to do.

Frequently Asked Questions

What testing features does Node's built-in test runner actually have?
node --test, stable since Node 20, includes describe/it/test blocks, before/after hooks, built-in mocking via t.mock, snapshot testing, and native code coverage via --experimental-test-coverage (stabilized in later Node 22.x releases), all without installing a single dependency. It uses node:assert for assertions rather than a separate expect() API, which is the biggest day-to-day difference from Jest.
Can node --test run TypeScript directly?
Yes, as of Node 23.6+ (and Node 22 with the --experimental-strip-types flag on earlier 22.x versions), node --test runs .ts test files directly using Node's built-in type stripping, no ts-node, tsx, or a build step required, as long as your TypeScript doesn't rely on features requiring actual transformation, like enums or namespaces, without the additional --experimental-transform-types flag.
Why would I still choose Jest or Vitest over the built-in runner?
Component testing (React, Vue) needs a DOM environment (jsdom or happy-dom) and framework-specific testing utilities (React Testing Library), which Jest and Vitest integrate directly and the built-in runner doesn't provide out of the box. Vitest specifically also wins on watch-mode UI, in-source testing, and tighter Vite integration for frontend projects. For a Node-only backend package or CLI tool with no DOM dependency, the built-in runner usually covers everything needed.