Testing with the built-in test runner

node:test and assert, suites and subtests, mocks and fake timers, watch mode, coverage and global setup.

The first test

Node ships a test runner. No framework, no configuration file: a file named *.test.js with node:test imports is discovered and executed in its own process.

// sum.js
export const sum = (a, b) => a + b;

// sum.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { sum } from "./sum.js";

test("adds two numbers", () => {
  assert.equal(sum(2, 3), 5);
});

test("rejects non-numbers", () => {
  assert.throws(() => sum("2", 3), TypeError);
});
node --test                  # discover and run every matching file
node --test --watch          # rerun on change
node --test test/unit/*.test.js
node --test --test-only      # run just the tests marked with { only: true }
  • node:assert/strict uses strict equality, which catches "1" == 1 style mistakes that the loose module accepts.
  • An assertion failure fails the test; an unhandled rejection or a thrown error does the same.
  • Async tests are just functions returning a promise — no callback, no done argument.
  • node --test runs each file in a separate process, so module state never leaks between files.

Suites, subtests and mocks

import { describe, it, before, after, mock } from "node:test";
import assert from "node:assert/strict";

describe("checkout", () => {
  before(() => { /* start a test database */ });
  after(() => { /* stop it */ });

  it("charges the customer once", async (t) => {
    const charge = mock.fn(async () => ({ ok: true }));
    const result = await checkout({ charge }, { id: 1 });

    assert.equal(result.ok, true);
    assert.equal(charge.mock.callCount(), 1);

    await t.test("and records an audit entry", () => {
      assert.ok(listener.entries.length > 0);
    });
  });
});

mock.timers.enable({ apis: ["setTimeout"], now: 0 });
APIPurpose
test / itA single case
describeGroup related cases into a suite
t.testA subtest that reports separately
before / afterRun once around a suite
beforeEach / afterEachRun around every case
mock.fnReplace a function and count calls
t.mock.methodReplace one method for the life of one test
mock.timersControl setTimeout and Date
t.skip / todoMark intent without failing the run
  • Assert on returned values and thrown errors before reaching for a mock; mocks verify interactions, which couples the test to the implementation.
  • Injected dependencies are far easier to fake than imported ones, which is the strongest practical argument for passing collaborators as arguments.
  • With fake timers enabled, Date.now() only moves when you advance it, so time-dependent tests become deterministic.
  • Every test must be independent: no shared ports, no shared files, no reliance on execution order.

Watch, coverage and setup

node --test --test-concurrency=4
node --test --experimental-test-coverage --test-coverage-lines=80
node --test --test-name-pattern="checkout"
node --test --test-reporter=spec
node --test --test-reporter=junit --test-reporter-destination=report.xml
node --test --import ./test/setup.js   # global hooks, tracing, env defaults
  • Coverage thresholds fail the run, which is what makes them useful in CI; set them per directory so an untested area does not block everything.
  • The JUnit reporter is what most CI dashboards understand without a plugin.
  • --test-name-pattern is for local iteration only; CI should always run the full suite.
  • A setup file loaded with --import can register global hooks, set environment defaults and install a tracer.
  • Node's runner does not provide a DOM. Browser-shaped tests still need jsdom or a real browser driver.
💡
Because each test file runs in its own process, module-level singletons are not shared. That removes a whole class of flaky tests, but it also means you cannot cache an expensive resource across files — start it in setup instead.

FAQ

Do I still need Jest or Vitest?
Not for a Node service. The built-in runner covers suites, mocks, fake timers, coverage and reporters with zero dependencies. Reach for a framework when you need browser-like environments, snapshot testing or a rich plugin ecosystem.
How do I test code that talks to a database?
Run the same engine in a disposable container, apply migrations in before, and give each test its own transaction or schema. In-memory substitutes usually diverge from production behaviour and produce false confidence.

Errors, logging and debugging Databases, HTTP clients and external services

Last refreshed 2026-09-18.