Published on

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

Authors

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');
  });
});

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 --test command
  • Enable --experimental-test-coverage for CI
  • Use tsx loader for TypeScript tests
  • Configure multiple reporters if needed
  • Remove jest.config.js and 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.