Node.js Built-in Test Runner: Write Tests Without Any Dependencies
Node.js has shipped a built-in test runner since v18 (stable in v20). It requires zero dependencies — no Jest, no Mocha, nothing to install. The API is familiar if you've used any other test framework.
Basic Test Structure
import { test, describe, it } from 'node:test';
import assert from 'node:assert/strict';
test('adds two numbers', () => {
assert.equal(1 + 1, 2);
});
describe('string utilities', () => {
it('converts to uppercase', () => {
assert.equal('hello'.toUpperCase(), 'HELLO');
});
it('trims whitespace', () => {
assert.equal(' hello '.trim(), 'hello');
});
});Run with:
node --test
<span class="hljs-comment"># or a specific file
node --<span class="hljs-built_in">test src/utils.test.jsAssertions
The test runner uses Node's built-in assert module:
import assert from 'node:assert/strict';
// Equality
assert.equal(actual, expected); // ==
assert.strictEqual(actual, expected); // ===
assert.deepEqual(obj1, obj2); // deep equality
assert.deepStrictEqual(obj1, obj2); // deep strict equality
// Truthiness
assert.ok(value); // truthy
assert.ok(!value); // falsy (or use assert.equal(value, false))
// Errors
assert.throws(() => badFn(), TypeError); // throws specific error type
assert.rejects(asyncFn(), /message/); // async throws
// Negation
assert.notEqual(a, b);
assert.notDeepStrictEqual(obj1, obj2);Async Tests
import { test } from 'node:test';
import assert from 'node:assert/strict';
test('fetches user data', async () => {
const user = await fetchUser(1);
assert.equal(user.id, 1);
assert.ok(user.name);
});
test('rejects on invalid id', async () => {
await assert.rejects(
() => fetchUser(-1),
{ message: 'Invalid user ID' }
);
});Subtests
Nest tests with t.test() for organization:
import { test } from 'node:test';
import assert from 'node:assert/strict';
test('user service', async (t) => {
await t.test('creates a user', async () => {
const user = await createUser({ name: 'Alice' });
assert.ok(user.id);
assert.equal(user.name, 'Alice');
});
await t.test('throws on duplicate email', async () => {
await createUser({ email: 'alice@example.com' });
await assert.rejects(
() => createUser({ email: 'alice@example.com' }),
{ code: 'DUPLICATE_EMAIL' }
);
});
});Before/After Hooks
import { describe, it, before, after, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
describe('database tests', () => {
let db;
before(async () => {
db = await connectDB(':memory:');
await db.migrate();
});
after(async () => {
await db.close();
});
beforeEach(async () => {
await db.seed();
});
afterEach(async () => {
await db.truncate();
});
it('finds users by email', async () => {
const user = await db.users.findByEmail('alice@example.com');
assert.equal(user.name, 'Alice');
});
});Skipping and Only
import { test } from 'node:test';
// Skip a test
test.skip('not yet implemented', () => {
// ...
});
// Mark as todo
test.todo('add payment tests');
// Run only this test (useful during development)
test.only('focused test', () => {
// only this runs when --test-only flag is passed
});Run with node --test --test-only to execute only tests marked with .only.
Mocking
The built-in test runner includes a mock module:
import { test, mock } from 'node:test';
import assert from 'node:assert/strict';
test('calls the logger on error', () => {
const logger = { error: mock.fn() };
handleError(new Error('boom'), logger);
assert.equal(logger.error.mock.calls.length, 1);
assert.equal(logger.error.mock.calls[0].arguments[0], 'boom');
});
test('mocks a module function', () => {
// Mock Date.now for deterministic tests
const dateMock = mock.method(Date, 'now', () => 1000000000000);
assert.equal(getTimestamp(), 1000000000000);
dateMock.mock.restore();
});Reporters and Output
# TAP output (default)
node --<span class="hljs-built_in">test
<span class="hljs-comment"># Spec-style output
node --<span class="hljs-built_in">test --test-reporter=spec
<span class="hljs-comment"># JUnit XML (for CI)
node --<span class="hljs-built_in">test --test-reporter=junit --test-reporter-destination=test-results.xml
<span class="hljs-comment"># Multiple reporters
node --<span class="hljs-built_in">test \
--test-reporter=spec --test-reporter-destination=stdout \
--test-reporter=junit --test-reporter-destination=results.xmlRunning Tests in CI
# .github/workflows/test.yml
name: Tests
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: npm ci
- run: node --test --test-reporter=specGlob Patterns
# Test all files matching a pattern
node --<span class="hljs-built_in">test <span class="hljs-string">'src/**/*.test.js'
<span class="hljs-comment"># Multiple patterns
node --<span class="hljs-built_in">test <span class="hljs-string">'src/**/*.test.js' <span class="hljs-string">'tests/**/*.spec.js'When to Use the Built-in Runner
Use node:test when:
- You want zero test dependencies
- You're building a library or CLI tool
- You're on Node 20+ and want the simplest setup
Stick with Jest/Vitest when:
- You need snapshot testing
- You need module mocking with
jest.mock()/ automatic mock hoisting - Your team already has deep Jest knowledge
Key Takeaways
node:testis stable since Node 20 — no install, no config- Use
node:assert/strictfor assertions - Hooks (
before,after,beforeEach,afterEach) work the same as in other frameworks - Built-in mocking handles function spies and method replacement
- Multiple built-in reporters including spec and JUnit for CI