Skip to content
Node.js nd testing 5 min read

The Built-in Node.js Test Runner

Modern Node.js ships a complete test runner in the core node:test module, so you can write and execute a real test suite without installing Jest, Mocha, or Vitest. It gives you a familiar test()/describe()/it() API, subtests, parallelism, watch mode, and pluggable reporters, all paired with the built-in node:assert library. For libraries and services that want fast, dependency-free CI, this is often all you need. This page covers writing tests, organizing them, running them with node --test, choosing a reporter, and re-running on change.

Availability

The test runner landed experimentally in Node 18 and became stable in Node 20, so on any current LTS (20 or 22) it is ready to use with no flags and no install. It works in both ES modules and CommonJS files. By convention, Node discovers test files matching patterns like *.test.js, *-test.js, or anything under a test/ directory.

import { test } from 'node:test';
import assert from 'node:assert/strict';

Importing node:assert/strict gives you the strict-mode assertions by default, so assert.equal() behaves like assert.strictEqual() (uses ===). It is the recommended entry point for new code.

Writing your first test

A test is just a call to test() with a name and an async (or sync) function. Inside, you make assertions; if any assertion throws, the test fails. There is no global expect — you use the node:assert API directly.

// math.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';

function add(a, b) {
  return a + b;
}

test('add() sums two numbers', () => {
  assert.equal(add(2, 3), 5);
});

test('add() handles negatives', () => {
  assert.equal(add(-4, 1), -3);
});

Run it directly:

node --test math.test.js

Output:

✔ add() sums two numbers (1.2ms)
✔ add() handles negatives (0.4ms)
ℹ tests 2
ℹ pass 2
ℹ fail 0

Grouping with describe and it

For larger suites, describe() groups related tests and it() defines each case — the same BDD vocabulary you know from other frameworks. You can nest describe blocks, and lifecycle hooks (before, after, beforeEach, afterEach) run setup and teardown at the appropriate scope.

import { describe, it, before, beforeEach } from 'node:test';
import assert from 'node:assert/strict';

describe('ShoppingCart', () => {
  let cart;

  beforeEach(() => {
    cart = { items: [] };
  });

  it('starts empty', () => {
    assert.equal(cart.items.length, 0);
  });

  it('adds items', () => {
    cart.items.push('book');
    assert.deepEqual(cart.items, ['book']);
  });
});

Subtests and async tests

The test() callback receives a context object t, and calling t.test() creates a subtest. Subtests are awaited as part of their parent, so the parent only finishes once its children do. This is handy for table-driven tests or grouping assertions without a separate describe block. Because tests are promise-aware, you simply await inside and return — no done callback required.

import { test } from 'node:test';
import assert from 'node:assert/strict';

test('user API', async (t) => {
  await t.test('fetches a user', async () => {
    const res = await fetch('https://jsonplaceholder.typicode.com/users/1');
    const user = await res.json();
    assert.equal(user.id, 1);
  });

  await t.test('rejects unknown users', async () => {
    const res = await fetch('https://jsonplaceholder.typicode.com/users/99999');
    assert.equal(res.ok, false);
  });
});

Assertions with node:assert

node:assert/strict covers the common cases without any extra matcher library. The most-used methods:

MethodChecks
assert.equal(a, b)a === b (strict mode)
assert.deepEqual(a, b)Deep structural equality
assert.ok(value)Value is truthy
assert.throws(fn, /regex/)fn throws a matching error
assert.rejects(promise)A promise rejects
assert.match(str, /regex/)String matches a pattern
import { test } from 'node:test';
import assert from 'node:assert/strict';

function parsePort(value) {
  const n = Number(value);
  if (!Number.isInteger(n) || n < 1) throw new RangeError('invalid port');
  return n;
}

test('parsePort validates input', () => {
  assert.equal(parsePort('8080'), 8080);
  assert.throws(() => parsePort('abc'), /invalid port/);
});

Skipping, todo, and focusing tests

You can mark tests to skip, flag work-in-progress with todo, or run a subset with only. Skipped and todo tests still appear in the report so they are not silently lost.

import { test } from 'node:test';

test('flaky network call', { skip: 'needs live server' }, () => {});
test('rewrite parser', { todo: true }, () => {});
test('focus this one', { only: true }, () => {});

To honor only, run with node --test --test-only.

Running tests

Point the runner at your project and it discovers test files automatically. Common invocations:

# Run every test file Node can find under the project
node --test

# Run a single file
node --test math.test.js

# Filter by test name
node --test --test-name-pattern="user API"

# Re-run automatically when files change
node --test --watch

Watch mode keeps the process alive and re-executes affected tests on save, giving you a tight feedback loop without nodemon.

Reporters

Node bundles several reporters and lets you pick one with --test-reporter. The default is spec for a TTY and tap when output is piped or running in CI. You can even emit multiple formats at once by pairing each reporter with a destination.

ReporterOutput
specHuman-readable, indented tree (default in a terminal)
tapTest Anything Protocol — machine-parseable
dotOne character per test, compact
junitJUnit XML for CI dashboards
# Pretty console output plus a JUnit file for CI
node --test \
  --test-reporter=spec --test-reporter-destination=stdout \
  --test-reporter=junit --test-reporter-destination=results.xml

Add --experimental-test-coverage to print a line/branch coverage summary after the run — no nyc or c8 required. It is still experimental but works well on Node 22.

Best Practices

  • Import node:assert/strict so equality checks use === by default and never silently coerce types.
  • Name test files with the .test.js suffix (or place them in test/) so node --test discovers them automatically.
  • Prefer await t.test(...) subtests or describe/it for structure; both keep async tests deterministic.
  • Use beforeEach/afterEach to isolate state so tests never depend on execution order.
  • Add an "test": "node --test" script to package.json and a --watch variant for local development.
  • Let the runner default to tap/junit reporters in CI and spec locally — drive it with --test-reporter rather than hand-formatting.
  • Reach for node:test first on libraries with no other dependencies; adopt Jest or Vitest only when you need their richer mocking or snapshot ecosystems.
Last updated June 14, 2026
Was this helpful?