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

·6 min read · By Codeloom
Intermediate 11 min read

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:

Jestnode: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()
beforeAllbefore
afterAllafter

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.