Testing with the Built-in Test Runner

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.