DEV Community

Ahmed Mahmoud
Ahmed Mahmoud

Posted on • Originally published at devya.dev

Node.js Has a Test Runner Now: Field Notes on Dropping Jest for Scripts and Libraries

Headline: Node.js ships a built-in test runner — node --test executes node:test files with no Jest, no Vitest, and no config file — and paired with type stripping it runs .ts tests with zero build. I moved my scripts and small libraries onto it: node:test for structure, node:assert/strict for assertions, the built-in mock for spies and fake timers, and --experimental-test-coverage for 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:test module and runs with node --test; it needs no dependency, no config file, and has been stable since Node.js 20.
  • Assertions come from the built-in node:assert module — import node:assert/strict so assert.equal uses strict (===) comparison.
  • The mock object from node:test gives you mock.fn(), mock.method(), and mock.timers without a separate mocking library; module mocking via mock.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);
});
Enter fullscreen mode Exit fullscreen mode

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);
  });
});
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)