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 integratejsdom/happy-domand 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
| Feature | Jest | node:test |
|---|---|---|
| Function mocks | jest.fn() | mock.fn() |
| Module mocks | jest.mock('./module') | mock.module() (Node 22.3+, still experimental) |
| Timer mocks | jest.useFakeTimers() | mock.timers.enable() |
| Snapshot testing | Built-in, mature | Built-in since Node 22, fewer format options |
| DOM environment | jsdom/happy-dom via config | None 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 --testcovers 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 --testfor 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.