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.
What you'll learn
- ✓How to use the built-in Node.js test runner (node:test)
- ✓Writing tests with describe, it, and test
- ✓Assertions with node:assert
- ✓Mocking functions, timers, and modules
- ✓Code coverage and watch mode
Prerequisites
- •Node.js 18+ (basic support) or 20+ (stable)
- •Basic understanding of unit testing concepts
- •JavaScript fundamentals
Since Node.js 18, there is a built-in test runner that requires no external dependencies. No Jest, no Mocha, no Vitest installation needed. For many projects, especially libraries and backend services, it provides everything you need: test organization, assertions, mocking, subtests, code coverage, and watch mode.
Getting started
Create a test file (by convention, *.test.js or *.test.mjs):
// math.test.js
const { describe, it } = require('node:test');
const assert = require('node:assert');
const { add, multiply } = require('./math.js');
describe('math', () => {
it('should add two numbers', () => {
assert.strictEqual(add(2, 3), 5);
});
it('should multiply two numbers', () => {
assert.strictEqual(multiply(3, 4), 12);
});
});
Run it:
node --test
That command finds and runs all test files matching the default glob pattern (**/*.test.{js,mjs,cjs}, **/*-test.{js,mjs,cjs}, **/*_test.{js,mjs,cjs}).
Test organization
describe and it
const { describe, it, before, after, beforeEach, afterEach } = require('node:test');
describe('UserService', () => {
let db;
before(() => {
db = createTestDatabase();
});
after(() => {
db.close();
});
beforeEach(() => {
db.clear();
});
describe('createUser', () => {
it('should create a user with valid data', () => {
const user = UserService.createUser(db, { name: 'Alice', email: 'alice@test.com' });
assert.ok(user.id);
assert.strictEqual(user.name, 'Alice');
});
it('should reject duplicate emails', () => {
UserService.createUser(db, { name: 'Alice', email: 'alice@test.com' });
assert.throws(() => {
UserService.createUser(db, { name: 'Bob', email: 'alice@test.com' });
}, { message: /duplicate email/i });
});
});
describe('findUser', () => {
it('should return null for non-existent user', () => {
const user = UserService.findUser(db, 'nonexistent-id');
assert.strictEqual(user, null);
});
});
});
The test function
test is an alternative to it that works at the top level:
const test = require('node:test');
const assert = require('node:assert');
test('basic addition', () => {
assert.strictEqual(1 + 1, 2);
});
test('async operation', async () => {
const result = await fetchData();
assert.ok(result.length > 0);
});
Subtests
test('string utilities', async (t) => {
await t.test('capitalize', () => {
assert.strictEqual(capitalize('hello'), 'Hello');
});
await t.test('reverse', () => {
assert.strictEqual(reverse('hello'), 'olleh');
});
});
Assertions
Node.js provides node:assert with many assertion methods:
const assert = require('node:assert');
// Strict equality (uses ===)
assert.strictEqual(actual, expected);
assert.notStrictEqual(actual, notExpected);
// Deep equality (for objects and arrays)
assert.deepStrictEqual({ a: 1, b: [2, 3] }, { a: 1, b: [2, 3] });
// Truthiness
assert.ok(value); // truthy
assert.ok(!value); // falsy
// Throws
assert.throws(() => { throw new Error('fail'); }, { message: 'fail' });
assert.doesNotThrow(() => { return 42; });
// Async throws
await assert.rejects(async () => {
throw new Error('async fail');
}, { message: 'async fail' });
// Match with regex
assert.match('hello world', /hello/);
assert.doesNotMatch('hello world', /goodbye/);
Skipping and filtering tests
// Skip a test
it('incomplete feature', { skip: 'Not implemented yet' }, () => {
// This test will not run
});
// Skip conditionally
it('windows-only test', { skip: process.platform !== 'win32' }, () => {
// Only runs on Windows
});
// TODO tests
it('should handle edge case', { todo: true }, () => {
// Marked as TODO in the output
});
// Run only specific tests
it('focus on this test', { only: true }, () => {
// Use with --test-only flag
});
Run only focused tests:
node --test --test-only
Filter by name pattern:
node --test --test-name-pattern="UserService"
Mocking
Mock functions
const { mock, test } = require('node:test');
const assert = require('node:assert');
test('mock a function', () => {
const fn = mock.fn((a, b) => a + b);
fn(1, 2);
fn(3, 4);
assert.strictEqual(fn.mock.calls.length, 2);
assert.deepStrictEqual(fn.mock.calls[0].arguments, [1, 2]);
assert.strictEqual(fn.mock.calls[0].result, 3);
assert.strictEqual(fn.mock.calls[1].result, 7);
});
Mock methods on objects
test('mock object method', () => {
const calculator = {
add(a, b) { return a + b; },
};
mock.method(calculator, 'add', () => 42);
assert.strictEqual(calculator.add(1, 2), 42);
assert.strictEqual(calculator.add.mock.calls.length, 1);
// Restore original
calculator.add.mock.restore();
assert.strictEqual(calculator.add(1, 2), 3);
});
Mock timers
test('mock timers', () => {
mock.timers.enable({ apis: ['setTimeout', 'setInterval'] });
let called = false;
setTimeout(() => { called = true; }, 5000);
mock.timers.tick(5000);
assert.strictEqual(called, true);
mock.timers.reset();
});
Mock modules (Node.js 22+)
test('mock a module', async () => {
// Register a mock for the 'fs' module
mock.module('fs', {
namedExports: {
readFileSync: () => 'mocked content',
}
});
const fs = require('fs');
assert.strictEqual(fs.readFileSync('any-file'), 'mocked content');
mock.restoreAll();
});
Async tests
test('async test with await', async () => {
const data = await fetchData('https://api.example.com/users');
assert.ok(Array.isArray(data));
assert.ok(data.length > 0);
});
test('test with timeout', { timeout: 5000 }, async () => {
await longRunningOperation();
});
Code coverage
Run tests with coverage:
node --test --experimental-test-coverage
Output:
# Coverage report
# -------------------------------------------------------
# File | Line % | Branch % | Funcs % | Uncov Lines
# -------------------------------------------------------
# math.js | 100.00 | 100.00 | 100.00 |
# userService.js | 85.71 | 75.00 | 100.00 | 24-28
# -------------------------------------------------------
# All files | 92.86 | 87.50 | 100.00 |
# -------------------------------------------------------
For lcov output (compatible with VS Code extensions):
node --test --experimental-test-coverage --test-reporter=lcov --test-reporter-destination=coverage/lcov.info
Watch mode
Automatically re-run tests when files change:
node --test --watch
This watches the test files and their dependencies. When any file changes, affected tests re-run.
Test reporters
# Default (TAP-like)
node --test
# Spec reporter (human-readable)
node --test --test-reporter=spec
# TAP (machine-readable)
node --test --test-reporter=tap
# Dot reporter (minimal)
node --test --test-reporter=dot
# Multiple reporters simultaneously
node --test \
--test-reporter=spec --test-reporter-destination=stdout \
--test-reporter=lcov --test-reporter-destination=coverage/lcov.info
Practical example: testing an HTTP service
// server.js
const http = require('http');
function createServer() {
return http.createServer((req, res) => {
if (req.method === 'GET' && req.url === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'ok' }));
} else if (req.method === 'GET' && req.url === '/users') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify([{ id: 1, name: 'Alice' }]));
} else {
res.writeHead(404);
res.end('Not found');
}
});
}
module.exports = { createServer };
// server.test.js
const { describe, it, before, after } = require('node:test');
const assert = require('node:assert');
const { createServer } = require('./server.js');
describe('HTTP Server', () => {
let server;
let baseUrl;
before(async () => {
server = createServer();
await new Promise((resolve) => {
server.listen(0, () => {
const { port } = server.address();
baseUrl = `http://localhost:${port}`;
resolve();
});
});
});
after(async () => {
await new Promise((resolve) => server.close(resolve));
});
it('should return health status', async () => {
const res = await fetch(`${baseUrl}/health`);
assert.strictEqual(res.status, 200);
const body = await res.json();
assert.deepStrictEqual(body, { status: 'ok' });
});
it('should return users', async () => {
const res = await fetch(`${baseUrl}/users`);
assert.strictEqual(res.status, 200);
const users = await res.json();
assert.ok(Array.isArray(users));
assert.strictEqual(users[0].name, 'Alice');
});
it('should return 404 for unknown routes', async () => {
const res = await fetch(`${baseUrl}/unknown`);
assert.strictEqual(res.status, 404);
});
});
Package.json script
{
"scripts": {
"test": "node --test",
"test:watch": "node --test --watch",
"test:coverage": "node --test --experimental-test-coverage",
"test:spec": "node --test --test-reporter=spec"
}
}
Summary
The Node.js native test runner provides a complete testing solution with zero dependencies. It supports describe/it organization, lifecycle hooks, mocking (functions, methods, timers, modules), async tests, code coverage, watch mode, and multiple reporters. For most Node.js projects, especially libraries and APIs, it is sufficient and eliminates the need for Jest or Mocha. The API is familiar to anyone who has used other test runners, making migration straightforward.
Related articles
- Node.js 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.
- Node.js Event Emitters in Node.js
Understand the EventEmitter pattern in Node.js. Covers custom emitters, error handling, async events, memory leaks, and real-world patterns.
- Node.js Node.js Streams: A Practical Guide
Master Node.js streams for efficient data processing. Covers readable, writable, transform, and duplex streams with real-world examples.
- Node.js Worker Threads in Node.js
Learn how to use Worker Threads for CPU-intensive tasks in Node.js. Covers thread creation, message passing, SharedArrayBuffer, and thread pools.