- Published on
Node.js Built-in Test Runner — Ditch Jest and Vitest for Zero-Dependency Testing
- Authors

- Name
- Sanjeev Sharma
- @webcoderspeed1
Introduction
Starting with Node.js 18, the node:test module provides a built-in testing framework that rivals Vitest and Jest. No installation. No npm dependencies. No configuration file. Just pure Node.js testing that works out of the box.
The node:test Module Overview
The node:test module is stable as of Node.js 22 LTS. It provides:
- Test runners (
test(),describe(),it()) - Built-in assertions (
node:assert) - Mocking and spying (
mock.fn(),mock.method()) - Multiple reporters (TAP, spec, dot)
- Experimental coverage support
No webpack. No Babel. No configuration.
import test from 'node:test';
import assert from 'node:assert';
test('basic arithmetic', () => {
assert.strictEqual(2 + 2, 4);
});
test('async operations', async () => {
const result = await Promise.resolve(42);
assert.strictEqual(result, 42);
});
Test Blocks and Assertions
Structure tests with describe() for grouping and test() or it() for individual tests.
import test from 'node:test';
import assert from 'node:assert';
test('Database', async (t) => {
const db = { users: [] };
await t.test('should insert user', () => {
db.users.push({ id: 1, name: 'Alice' });
assert.strictEqual(db.users.length, 1);
});
await t.test('should query user by id', () => {
const user = db.users.find(u => u.id === 1);
assert.deepStrictEqual(user, { id: 1, name: 'Alice' });
});
await t.test('should update user', () => {
db.users[0].name = 'Bob';
assert.strictEqual(db.users[0].name, 'Bob');
});
});
Nested subtests with t.test() provide clear hierarchical test organization without extra libraries.
Using node:assert for Assertions
The node:assert module provides all standard assertions:
import assert from 'node:assert';
assert.ok(value); // truthy
assert.strictEqual(actual, expected); // ===
assert.deepStrictEqual(obj1, obj2); // deep equality
assert.throws(() => { /* code */ }, Error);
assert.rejects(promise, Error);
assert.match(string, regex);
assert.doesNotThrow(() => { /* code */ });
For stricter comparisons, use assert.strictEqual() instead of assert.equal() to avoid type coercion bugs.
Mocking with mock.fn() and mock.method()
Built-in mocking eliminates sinon.js and jest.mock() boilerplate.
import test from 'node:test';
import assert from 'node:assert';
import { mock } from 'node:test';
test('mocking function calls', () => {
const fn = mock.fn((x: number) => x * 2);
fn(5);
fn(10);
assert.strictEqual(fn.mock.callCount(), 2);
assert.deepStrictEqual(fn.mock.calls[0].arguments, [5]);
assert.strictEqual(fn.mock.results[0].value, 10);
});
test('mocking object methods', () => {
const logger = {
info: (msg: string) => console.log(msg),
error: (msg: string) => console.error(msg),
};
mock.method(logger, 'info');
mock.method(logger, 'error');
logger.info('test message');
logger.error('error message');
assert.strictEqual(logger.info.mock.callCount(), 1);
assert.strictEqual(logger.error.mock.callCount(), 1);
});
Call return values directly from .mock.results:
import test from 'node:test';
import assert from 'node:assert';
import { mock } from 'node:test';
test('tracking return values', () => {
const fetch = mock.fn(async (url: string) => {
if (url.includes('users')) {
return { status: 200, data: [] };
}
throw new Error('Not found');
});
const result1 = fetch('https://api.example.com/users');
result1.then(r => {
assert.deepStrictEqual(r, { status: 200, data: [] });
});
const result2 = fetch('https://api.example.com/invalid');
result2.catch(e => {
assert.strictEqual(e.message, 'Not found');
});
});
- The node:test Module Overview
- Test Blocks and Assertions
- Using node:assert for Assertions
- Mocking with mock.fn() and mock.method()
- Async Tests and Timers
- Test Reporters
- Coverage with Experimental Test Coverage
- TypeScript Support via tsx
- When Built-in Test Runner Wins
- CI Integration
- Checklist
- Conclusion
Async Tests and Timers
Handle async operations and timer mocks effortlessly.
import test from 'node:test';
import assert from 'node:assert';
import { mock } from 'node:test';
test('async operations', async () => {
const delay = (ms: number) => new Promise(resolve => {
setTimeout(resolve, ms);
});
const start = Date.now();
await delay(100);
const elapsed = Date.now() - start;
assert.ok(elapsed >= 100);
});
test('timer mocks', async (t) => {
const timers = t.mock.timers;
timers.enable();
let called = false;
setTimeout(() => {
called = true;
}, 1000);
// Fast-forward time
timers.tick(1000);
assert.strictEqual(called, true);
timers.reset();
});
Timer mocking avoids slow test suites; no more jest.useFakeTimers() boilerplate.
Test Reporters
Run tests with different output formats using the --test-reporter flag.
# TAP (Test Anything Protocol) — verbose
node --test --test-reporter=tap test.js
# Spec reporter — familiar format
node --test --test-reporter=spec test.js
# Dot reporter — minimal output
node --test --test-reporter=dot test.js
# JSON reporter (Node 21+)
node --test --test-reporter=json test.js > results.json
# Multiple reporters in parallel
node --test \
--test-reporter=tap \
--test-reporter=spec \
test.js
The default spec reporter mimics Mocha/Jest format, making migration seamless.
Coverage with Experimental Test Coverage
Node.js 22.5+ includes built-in coverage without nyc or c8.
# Enable experimental coverage
node --experimental-test-coverage --test test.js
# Coverage with threshold checks
node --experimental-test-coverage \
--test-coverage-branches=80 \
--test-coverage-lines=80 \
--test-coverage-functions=80 \
--test-coverage-statements=80 \
test.js
Output includes line, branch, function, and statement coverage. HTML reports require external tools, but JSON output integrates with CI easily.
TypeScript Support via tsx
To run TypeScript tests, use tsx as a loader:
# Run TypeScript tests without compilation
node --loader tsx/cjs ./test.ts
# Or with tsx directly
tsx --test test.ts
# Or use tsx as test runner
tsx node:test test.ts
In package.json:
{
"scripts": {
"test": "node --loader tsx/cjs ./test.ts",
"test:coverage": "node --loader tsx/cjs --experimental-test-coverage ./test.ts"
}
}
When Built-in Test Runner Wins
The node:test runner is superior to Vitest and Jest for:
- Zero-dependency backend microservices
- CI pipelines that value speed (sub-100ms startup)
- Teams avoiding npm bloat
- Projects with <1000 tests
- Educational content and examples
Vitest still wins for:
- React component testing (jsdom built-in)
- HMR during development
- Visual UI runners
- Projects with 5000+ tests (better parallelization)
- Browser testing (with Vitest Browser Mode)
Jest is now obsolete for new Node.js projects.
CI Integration
GitLab CI example:
test:
image: node:22-alpine
script:
- node --test test/*.js
coverage: '/Lines\s*:\s*(\d+\.\d+)%/'
GitHub Actions example:
name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- run: node --test test/*.js
Checklist
- Update to Node.js 22 LTS minimum
- Replace Jest/Vitest with
node:test - Remove test dependencies from
package.json - Migrate test files to new syntax
- Set up CI with
node --testcommand - Enable
--experimental-test-coveragefor CI - Use
tsxloader for TypeScript tests - Configure multiple reporters if needed
- Remove
jest.config.jsand test config files
Conclusion
Node.js 22's built-in test runner eliminates test framework dependencies. For backend teams, this means faster CI pipelines, simpler onboarding, and zero configuration. If you're still using Jest, migration to node:test takes an afternoon and removes an entire category of dependencies.