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.

· · 4 min read

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.

TL;DR: 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.

A Test File, 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.

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

Where the Built-in Runner Falls 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.

Mocking Comparison

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.

Decision Framework

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

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.