Skip to content
Codeloom
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.

·7 min read · By Codeloom
Intermediate 12 min read

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.