Testing with the Built-in Test Runner

Published by Programming Line in Node.js. Last updated .

Every serious Node.js project needs tests, and since Node 18 you don’t need to install Jest, Mocha, or Ava to write them. Node ships its own test runner as a core module, node:test, paired with the built-in node:assert module for assertions. It’s fast, requires zero dependencies, and is stable and production-ready as of Node 20. This lesson covers writing tests, organizing them with describe/it, running setup and teardown hooks, mocking functions, and running the suite from the command line with watch mode and coverage.

Overview: How the built-in test runner works

The node:test module exports a test() function (plus BDD-style aliases describe() and it()) that you use to declare test cases. Each test is just a function; inside it you make assertions with node:assert, and if any assertion throws, the test fails. There is no special build step and no custom test file format — a test file is a normal JavaScript file that Node executes directly.

You run tests with the node --test CLI flag. When you point it at a directory (the default is the current directory), Node walks the tree looking for files that match its test-file conventions: files named test.js, anything matching *.test.js, *-test.js, test-*.js, or any file inside a folder named test or tests. Each matching file is executed as its own isolated child process, so a crash or an infinite loop in one file cannot take down the whole suite or leak state into another file.

Inside a test, the callback receives a TestContext (conventionally named t). The context lets you create subtests (t.test(...)), skip or mark tests as work-in-progress, set a timeout, and — since Node 20 — create mock functions and mock timers via t.mock. Tests can be synchronous or asynchronous; if a test function returns a promise, the runner awaits it before deciding pass or fail. That’s what makes async test functions work correctly with await-based assertions like assert.rejects().

By default, when stdout is a terminal, Node prints a human-readable spec report; when it isn’t (for example, in CI), it prints raw TAP (Test Anything Protocol) output, which any TAP-compatible tool can parse. You can force a specific reporter with --test-reporter, including tap, dot, junit, or a custom reporter module. The process exits with code 0 if every test passed and a nonzero code if anything failed or was left incomplete, which is exactly what CI pipelines check.

Syntax

test(name, [options], fn)
describe(name, [options], fn)
it(name, [options], fn)
before(fn) / after(fn)
beforeEach(fn) / afterEach(fn)
Part Meaning
name A string describing what the test verifies; shown in the report.
options Optional object: { skip, todo, concurrency, timeout }.
fn The test body. Can be sync, return a promise, or be declared async. Receives a TestContext as its first argument.
describe / it BDD-style aliases for grouping (describe) and defining (it) tests; behave the same as test/t.test.
before/after Run once before/after all tests in the enclosing file or describe block.
beforeEach/afterEach Run before/after every individual test in scope — ideal for resetting state.

CLI form: node --test [options] [path...]. Common options: --watch (re-run on file change), --test-reporter=<name>, and --experimental-test-coverage (still experimental as of Node 22, so treat coverage numbers as a guide, not a gate).

Examples

Example 1: A first test file

// math.js
function add(a, b) {
  return a + b;
}

function divide(a, b) {
  if (b === 0) {
    throw new Error("Cannot divide by zero");
  }
  return a / b;
}

module.exports = { add, divide };
// math.test.js
const test = require("node:test");
const assert = require("node:assert/strict");
const { add, divide } = require("./math.js");

test("add() returns the sum of two numbers", () => {
  assert.strictEqual(add(2, 3), 5);
  assert.strictEqual(add(-1, 1), 0);
});

test("divide() throws when dividing by zero", () => {
  assert.throws(() => divide(10, 0), /Cannot divide by zero/);
});

test("divide() returns the correct quotient", () => {
  assert.strictEqual(divide(10, 2), 5);
});

Output (running node --test from the project root):

▶ math.test.js
  ✔ add() returns the sum of two numbers
  ✔ divide() throws when dividing by zero
  ✔ divide() returns the correct quotient
▶ math.test.js (3 tests, 3 passed)

tests 3
pass 3
fail 0

Node discovers math.test.js automatically because its name matches the *.test.js convention. Notice math.js itself isn’t picked up as a test file — only files matching the naming pattern are treated as suites. We used node:assert/strict, which uses strict (===-based) comparisons everywhere, including in methods like equal and deepEqual — the recommended default for new code.

Example 2: Grouping with describe/it and hooks

// user-store.js
class UserStore {
  constructor() {
    this.users = new Map();
  }

  add(id, name) {
    this.users.set(id, name);
  }

  async findById(id) {
    await new Promise((resolve) => setImmediate(resolve));
    return this.users.get(id) ?? null;
  }
}

module.exports = { UserStore };
// user-store.test.js
const { describe, it, beforeEach } = require("node:test");
const assert = require("node:assert/strict");
const { UserStore } = require("./user-store.js");

describe("UserStore", () => {
  let store;

  beforeEach(() => {
    store = new UserStore();
    store.add(1, "Ada");
  });

  it("finds an existing user by id", async () => {
    const name = await store.findById(1);
    assert.strictEqual(name, "Ada");
  });

  it("returns null for a missing user", async () => {
    const name = await store.findById(999);
    assert.strictEqual(name, null);
  });

  it("keeps stores isolated between tests", () => {
    assert.strictEqual(store.users.size, 1);
  });
});

Output:

▶ UserStore
  ✔ finds an existing user by id
  ✔ returns null for a missing user
  ✔ keeps stores isolated between tests
▶ UserStore (3 tests, 3 passed)

beforeEach creates a brand-new UserStore before every it(), so the three tests never share state — the third test proves that even though earlier tests ran first, store.users still only has one entry. This is the standard pattern for testing anything stateful: reset in beforeEach, never rely on leftover state from the previous test.

Example 3: Mocking dependencies

// notifier.js
async function notify(sender, userId, message) {
  return sender.send(userId, message);
}

module.exports = { notify };
// notifier.test.js
const test = require("node:test");
const assert = require("node:assert/strict");
const { notify } = require("./notifier.js");

test("notify() calls sender.send with the right arguments", async (t) => {
  const sender = {
    send: t.mock.fn(async (userId, message) => `sent:${userId}:${message}`),
  };

  const result = await notify(sender, 42, "hello");

  assert.strictEqual(result, "sent:42:hello");
  assert.strictEqual(sender.send.mock.callCount(), 1);
  assert.deepStrictEqual(sender.send.mock.calls[0].arguments, [42, "hello"]);
});

test.skip("not implemented yet: retry on failure", () => {
  // Planned for a future release.
});

test("batch notifications", { todo: true }, () => {
  assert.ok(false);
});

t.mock.fn() wraps a function and records every call: how many times it ran (mock.callCount()) and what arguments each call received (mock.calls[i].arguments). This lets you test notify() without a real notification service. test.skip excludes a test from the run entirely, while { todo: true } runs the test but doesn’t fail the suite even if it throws — both show up in the report so nothing is silently forgotten.

How it works step by step

  • You run node --test. Node scans the given path(s) for files matching the test-file conventions.
  • Each matching file is launched as its own child process, isolating global state and crashes between files.
  • Inside a file, top-level code runs immediately: this is when describe() blocks execute and register their nested it() calls — registration happens synchronously before any test body runs.
  • Tests then execute in the order they were registered. Before each one, any applicable before/beforeEach hooks run; after each one, afterEach/after hooks run.
  • If a test function returns a promise (including implicitly, via async), the runner awaits it. Assertion failures throw synchronously or reject the promise, which the runner catches and records as a failure.
  • Once every file finishes, results are aggregated and printed via the active reporter, and the process exits with 0 (all passed) or 1 (something failed).

Common Mistakes

1. Not awaiting an async assertion

If a test function is synchronous but calls an async assertion like assert.rejects() without awaiting or returning it, the test finishes — and is reported as passed — before the assertion has even run.

const test = require("node:test");
const assert = require("node:assert/strict");

async function brokenAsyncFn() {
  throw new Error("boom");
}

test("bad: missing await on assert.rejects", () => {
  assert.rejects(brokenAsyncFn());
});

Fix it by making the test async and awaiting the assertion, so the runner actually waits for the rejection to be checked:

const test = require("node:test");
const assert = require("node:assert/strict");

async function brokenAsyncFn() {
  throw new Error("boom");
}

test("good: awaiting assert.rejects", async () => {
  await assert.rejects(brokenAsyncFn());
});

2. Using loose node:assert instead of node:assert/strict

The plain node:assert module’s equal()/deepEqual() use ==-style loose comparison, which can hide real type bugs.

const test = require("node:test");
const assert = require("node:assert");

function getUserIdFromForm() {
  return "42"; // bug: should return a number
}

test("bad: loose assert hides a type bug", () => {
  const userId = getUserIdFromForm();
  assert.equal(userId, 42); // passes: "42" == 42
});

Importing node:assert/strict and using strictEqual surfaces the bug instead of masking it:

const test = require("node:test");
const assert = require("node:assert/strict");

function getUserIdFromForm() {
  return "42"; // bug: should return a number
}

test("good: strict assert catches the type bug", () => {
  const userId = getUserIdFromForm();
  assert.strictEqual(userId, 42); // fails: "42" !== 42
});

Other frequent pitfalls: naming a file helpers.js when it contains tests (it won’t be discovered because it doesn’t match a test-file pattern); leaving console.log debugging in tests instead of asserting on the value; and writing a beforeEach that mutates a module-level object instead of creating a fresh one, which lets state leak between tests that run concurrently.

Best Practices

  • Always use node:assert/strict (or the /strict variants) for new tests — it avoids surprising type coercion.
  • Name test files with a recognized pattern (*.test.js is the clearest) so node --test discovers them automatically without extra configuration.
  • Reset shared state in beforeEach, not just before, so tests stay independent and can run in any order.
  • Use t.mock to isolate the unit under test from real network calls, databases, or timers, rather than reaching for a mocking library.
  • Add a \"test\": \"node --test\" script to package.json so npm test works the same locally and in CI.
  • Run node --test --watch while developing for instant feedback, but always run the full suite once, without --watch, before pushing.
  • Prefer assert.rejects()/assert.throws() over wrapping code in try/catch to test error paths — it’s shorter and fails loudly if no error is thrown.
  • Keep test files close to the code they test (co-located *.test.js files) so they’re easy to find and stay in sync as the implementation changes.

Practice Exercises

  • Write a string-utils.js module with a slugify(text) function that lowercases text and replaces spaces with hyphens. Write a string-utils.test.js file with at least three test() cases covering normal input, extra whitespace, and an empty string.
  • Create a small Counter class with increment(), decrement(), and value. Using describe/it and a beforeEach hook, write tests that verify the counter starts at zero and updates correctly, making sure each test starts from a fresh instance.
  • Write a function fetchUserName(httpClient, id) that calls httpClient.get(`/users/${id}`) and returns response.name. Test it using t.mock.fn() to fake httpClient.get, and assert both the returned name and that get was called with the expected URL.

Summary

  • node:test is Node’s built-in, dependency-free test runner, stable since Node 20, run from the CLI with node --test.
  • Tests are plain functions passed to test(), it(), or nested under describe(); async tests are awaited automatically by the runner.
  • before/after and beforeEach/afterEach hooks handle setup and teardown — reset state per test to keep tests independent.
  • Use node:assert/strict for assertions to avoid loose-equality bugs, and assert.rejects()/assert.throws() to test error paths.
  • t.mock.fn() and t.mock.method() provide built-in mocking without extra libraries; test.skip and { todo: true } mark incomplete work without breaking the build.
  • --watch reruns tests on file changes during development; --experimental-test-coverage reports coverage (still experimental); reporters like tap and junit integrate with CI systems.