Node.js Built-in Test Runner: A Complete Guide to node:test
Learn how to use the native Node.js test runner with no external dependencies. Covers test organization, mocking, snapshots, and code coverage.
What you'll learn
- ✓How to write and organize tests with node:test
- ✓Using describe, it, and test for structure
- ✓Mocking functions, timers, and modules
- ✓Running tests with coverage and watch mode
Prerequisites
- •Basic Node.js knowledge
- •Familiarity with testing concepts
- •Node.js 20 or later installed
Since Node.js 20, the built-in test runner is stable and production-ready. You no longer need Jest, Mocha, or Vitest for many projects. The node:test module gives you test organization, assertions, mocking, snapshot testing, code coverage, and watch mode out of the box. This guide covers everything you need to start using it.
Your first test
Create a file ending in .test.js or .test.mjs:
// math.test.js
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
function add(a, b) {
return a + b;
}
describe('add', () => {
it('should add two positive numbers', () => {
assert.strictEqual(add(2, 3), 5);
});
it('should handle negative numbers', () => {
assert.strictEqual(add(-1, -2), -3);
});
it('should handle zero', () => {
assert.strictEqual(add(0, 0), 0);
});
});
Run it:
node --test math.test.js
Or run all test files in your project:
node --test
By default, Node.js looks for files matching **/*.test.{js,mjs,cjs}, **/*-test.{js,mjs,cjs}, and **/*_test.{js,mjs,cjs}.
Test organization with describe and it
The describe function groups related tests. You can nest them:
import { describe, it, before, after, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
describe('UserService', () => {
let db;
before(() => {
db = createTestDatabase();
});
after(() => {
db.close();
});
beforeEach(() => {
db.clear();
});
describe('createUser', () => {
it('should create a user with valid data', async () => {
const user = await createUser(db, { name: 'Alice', email: 'alice@test.com' });
assert.equal(user.name, 'Alice');
assert.ok(user.id);
});
it('should reject duplicate emails', async () => {
await createUser(db, { name: 'Alice', email: 'alice@test.com' });
await assert.rejects(
() => createUser(db, { name: 'Bob', email: 'alice@test.com' }),
{ message: /duplicate email/i }
);
});
});
describe('deleteUser', () => {
it('should remove the user from the database', async () => {
const user = await createUser(db, { name: 'Alice', email: 'alice@test.com' });
await deleteUser(db, user.id);
const found = await findUser(db, user.id);
assert.equal(found, null);
});
});
});
Skipping and filtering tests
import { describe, it } from 'node:test';
// Skip a test
it('should handle edge case', { skip: 'Not implemented yet' }, () => {
// This test won't run
});
// Run only this test
it('focused test', { only: true }, () => {
// Use --test-only flag to enable
});
// TODO test — marks as a placeholder
it.todo('should validate email format');
Run only specific tests by name:
node --test --test-name-pattern="createUser"
Mocking functions
The built-in mock object replaces functions with tracked spies:
import { describe, it, mock } from 'node:test';
import assert from 'node:assert/strict';
describe('notification service', () => {
it('should call the email sender', async () => {
const sendEmail = mock.fn(async (to, subject) => {
return { sent: true };
});
await notifyUser({ sendEmail }, 'alice@test.com', 'Welcome!');
assert.strictEqual(sendEmail.mock.calls.length, 1);
assert.deepStrictEqual(sendEmail.mock.calls[0].arguments, [
'alice@test.com',
'Welcome!',
]);
});
it('should retry on failure', async () => {
const sendEmail = mock.fn();
sendEmail.mock.mockImplementationOnce(() => {
throw new Error('Network error');
});
sendEmail.mock.mockImplementationOnce(() => {
return { sent: true };
});
await notifyUser({ sendEmail }, 'alice@test.com', 'Welcome!');
assert.strictEqual(sendEmail.mock.calls.length, 2);
});
});
Mocking timers
Test time-dependent code without waiting:
import { describe, it, mock } from 'node:test';
import assert from 'node:assert/strict';
describe('debounce', () => {
it('should delay execution', () => {
mock.timers.enable({ apis: ['setTimeout'] });
let called = false;
const fn = debounce(() => { called = true; }, 1000);
fn();
assert.strictEqual(called, false);
mock.timers.tick(999);
assert.strictEqual(called, false);
mock.timers.tick(1);
assert.strictEqual(called, true);
mock.timers.reset();
});
});
Mocking modules
You can mock entire modules using mock.module (available in Node.js 22+):
import { describe, it, mock } from 'node:test';
import assert from 'node:assert/strict';
describe('fetchUsers', () => {
it('should parse the API response', async () => {
mock.module('./api-client.js', {
namedExports: {
fetchFromAPI: async () => ({
data: [{ id: 1, name: 'Alice' }],
}),
},
});
const { fetchUsers } = await import('./user-service.js');
const users = await fetchUsers();
assert.deepStrictEqual(users, [{ id: 1, name: 'Alice' }]);
});
});
Snapshot testing
Snapshot tests capture the output of a function and compare it against a stored reference:
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
describe('formatReport', () => {
it('should match the expected output', (t) => {
const report = formatReport({
title: 'Q2 Sales',
total: 150000,
items: ['Widget A', 'Widget B'],
});
t.assert.snapshot(report);
});
});
Run with --test-update-snapshots to create or update snapshots:
node --test --test-update-snapshots
Code coverage
Generate coverage reports without installing Istanbul or c8:
node --test --experimental-test-coverage
Output:
# Coverage report
# File | Line % | Branch % | Funcs %
# math.js | 100.00 | 100.00 | 100.00
# user-service.js| 85.71 | 75.00 | 100.00
Set a minimum coverage threshold:
node --test --experimental-test-coverage --test-coverage-branches=80 --test-coverage-lines=80 --test-coverage-functions=80
The process exits with code 1 if coverage falls below the threshold.
Watch mode
Re-run tests automatically when files change:
node --test --watch
This watches the test files and their dependencies. When any imported file changes, the affected tests re-run.
Parallel test execution
By default, test files run in parallel using worker threads. Each file gets its own worker, so tests in different files cannot interfere with each other.
Control concurrency:
# Run 4 test files in parallel
node --test --test-concurrency=4
# Run sequentially (useful for debugging)
node --test --test-concurrency=1
Custom test reporters
Node.js supports TAP, spec, and dot reporters, and you can use custom ones:
# Spec reporter (default)
node --test --test-reporter=spec
# TAP format (for CI integration)
node --test --test-reporter=tap
# Dot reporter (minimal)
node --test --test-reporter=dot
Migrating from Jest
Here is a quick mapping from Jest to node:test:
| Jest | node:test |
|---|---|
expect(x).toBe(y) | assert.strictEqual(x, y) |
expect(x).toEqual(y) | assert.deepStrictEqual(x, y) |
expect(fn).toThrow() | assert.throws(fn) |
jest.fn() | mock.fn() |
jest.spyOn(obj, 'method') | mock.method(obj, 'method') |
jest.useFakeTimers() | mock.timers.enable() |
beforeAll | before |
afterAll | after |
Key takeaways
The Node.js native test runner is mature enough for most projects. It eliminates the configuration overhead of external testing frameworks and stays in sync with the runtime. Use describe/it for organization, mock for test doubles, and run with --experimental-test-coverage in CI. For large projects with complex mocking needs, Jest or Vitest may still offer more features, but for libraries, APIs, and new projects, node:test is the pragmatic choice.
Related articles
- Node.js Node.js Native Test Runner
Learn to write tests with Node.js built-in test runner. Covers test organization, assertions, mocking, subtests, code coverage, and watch mode.
- Testing API Testing with Supertest and Jest
Learn how to write fast, reliable API tests for Express and Node.js backends using Supertest and Jest.
- Node.js Node.js vs Deno vs Bun: JavaScript Runtimes Compared
Compare Node.js, Deno, and Bun on performance, security, compatibility, and developer experience. Choose the right JavaScript runtime for 2026.
- Node.js Node.js Cluster Module for Multi-Core Scaling
Scale your Node.js application across all CPU cores using the cluster module. Covers load balancing, zero-downtime restarts, and production patterns.