Headline: Node.js ships a built-in test runner —
node --testexecutesnode:testfiles with no Jest, no Vitest, and no config file — and paired with type stripping it runs.tstests with zero build. I moved my scripts and small libraries onto it:node:testfor structure,node:assert/strictfor assertions, the built-inmockfor spies and fake timers, and--experimental-test-coveragefor coverage. I kept Vitest for anything that touches the DOM or renders React.
Key takeaways
- The Node.js test runner lives in the built-in
node:testmodule and runs withnode --test; it needs no dependency, no config file, and has been stable since Node.js 20. - Assertions come from the built-in
node:assertmodule — importnode:assert/strictsoassert.equaluses strict (===) comparison. - The
mockobject fromnode:testgives youmock.fn(),mock.method(), andmock.timerswithout a separate mocking library; module mocking viamock.module()is still experimental. - Combined with type stripping,
node --test "src/**/*.test.ts"runs TypeScript tests with no build step on Node.js 22.18+ and Node.js 24. - Coverage is available behind
--experimental-test-coverage, and output format is chosen with--test-reporter(spec,tap,dot,junit,lcov).
Can Node.js run tests without Jest or Vitest?
Yes — Node.js has a built-in test runner, and it needs zero dependencies. The runner is the node:test core module, invoked with node --test; it landed experimentally in Node.js 18 and became stable in Node.js 20. It auto-discovers test files, runs each in its own child process for isolation, and reports in a TAP-based format.
// math.test.ts
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { add } from './math.ts';
test('add sums two numbers', () => {
assert.equal(add(2, 3), 5);
});
Run it with node --test. No jest.config.js, no transform, no ts-jest.
How do I structure tests with node:test?
node:test exports test, plus describe/it and the hooks before, after, beforeEach, afterEach.
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
describe('Cart', () => {
let cart: Cart;
beforeEach(() => { cart = new Cart(); });
it('starts empty', () => assert.equal(cart.total(), 0));
it('sums item prices', () => {
cart.add({ price: 10 });
cart.add({ price: 5 });
assert.equal(cart.total(), 15);
});
});
Focus one test with { only: true } and --test-only, or filter with --test-name-pattern.
How do I mock functions and timers?
Mocking is built in through the mock object — no jest.fn to install.
import { test, mock } from 'node:test';
import assert from 'node:assert/strict';
test('notifies the customer once', () => {
const notify = mock.fn();
placeOrder({ items: [{ price: 10 }], notify });
assert.equal(notify.mock.callCount(), 1);
});
mock.method(obj, 'name') spies on a real method, and mock.timers.enable({ apis: ['setTimeout'] }) plus mock.timers.tick(1000) gives deterministic fake timers. Full module mocking via mock.module() is still experimental, so I favor dependency injection instead.
Can I run TypeScript tests without a build step?
Yes, on Node.js 22.18+ and Node.js 24, because Node.js strips type annotations at load time.
node --test "src/**/*.test.ts"
Relative imports need the explicit .ts extension, and Node.js never type-checks — run tsc --noEmit as a separate CI gate. This removed ts-jest and the Vitest transform from every script and library repo I own.
How do I get coverage and CI reports?
Watch mode is node --test --watch. Coverage is a flag: node --test --experimental-test-coverage prints a per-file table. Reporters are chosen with --test-reporter, and you can emit several at once:
node --test \
--test-reporter=spec \
--test-reporter=junit --test-reporter-destination=junit.xml \
--experimental-test-coverage
Thresholds like --test-coverage-lines=80 (Node.js 22+) fail the run below a percentage — enough to gate a PR without a coverage service.
When should I keep Vitest or Jest?
Keep them whenever a test needs a browser-like environment or a build transform the runtime lacks.
| Need | Node.js test runner | Vitest / Jest |
|---|---|---|
| Dependencies | Zero (built in) | A framework + transform |
| DOM / React | No jsdom, no JSX | jsdom/happy-dom + JSX |
| TypeScript | Native via type stripping | esbuild/SWC transform |
| Snapshots | Basic, recent | Mature |
| Best for | Scripts, libraries, backend | Frontend, components, big suites |
Backend and library code gets the built-in runner; anything that renders a component stays on Vitest.
FAQ
Q: Is the Node.js test runner production-ready?
A: Yes — the runner and node:test API are stable since Node.js 20. Only module mocking and coverage remain behind experimental flags.
Q: Do I need a config file?
A: No. node --test auto-discovers test files and takes options as CLI flags.
Q: Which assertion library does it use?
A: The built-in node:assert; import node:assert/strict for strict equality. Third-party assertion libraries still work.
Q: Can it test React components?
A: Not well — no DOM, no JSX transform. Use Vitest/Jest with jsdom for components.
Q: How does it mock ES modules?
A: Via mock.module(), still experimental. I prefer dependency injection for stable code.
Originally published on devya.dev. Also on eng-ahmed.com. Built by Devya Solutions.
Top comments (0)