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 integratejsdom/happy-domand 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.
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.
How Do the Mocking Approaches Compare?
| 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. 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 --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.
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:
- Check whether any test in the suite renders a component or touches the DOM.
- If none do, run
node --testagainst the existing suite and see how many assertions need only a mechanicalexpecttoassertswap. - If some tests need a DOM, split the suite:
node --testfor backend packages, Jest or Vitest for anything rendering UI. - Re-evaluate yearly, since DOM support has never been on the built-in runner's roadmap and that isn't likely to change.
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.