Get started with the built-in Node.js test runner

Write and run Node.js tests with node:test, strict assertions, subtests, mocks, hooks, coverage, reporters, and an HTTP server test that cleans up its resources.

Best practices
A testing station inspecting components for defects

Node.js includes a test runner in node:test, so a backend service or library can start testing without installing a separate runner. It supports test discovery, subtests, hooks, mocks, and configurable reports. Coverage and other individual features have their own version and stability boundaries.

The runner arrived experimentally in Node.js 18, and its core became stable in Node.js 20. The examples here target Node.js 24 and were run with Node.js 24.19.0. Check the versioned test documentation before using them on an older release.

Write the first test

Set "type": "module" in package.json so these .js files use ES modules. Alternatively, use .mjs extensions and update the import paths.

Save math.js:

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

Then create math.test.js:

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

test('add', async (t) => {
  await t.test('adds positive numbers', () => {
    assert.equal(add(1, 2), 3);
  });
  await t.test('adds negative numbers', () => {
    assert.equal(add(-1, -1), -2);
  });
});

Run it from the project directory:

node --test

The runner discovers supported test filenames, including *.test.js. Await the subtests so their parent does not finish while they are still pending. In strict assertion mode, assert.equal() uses strict equality; it does not accept a string merely because it can be coerced to the expected number.

Use describe and it if you prefer suites

Save this alternative as math-suite.test.js:

import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { add } from './math.js';

describe('add', () => {
  it('adds two numbers', () => {
    assert.equal(add(1, 2), 3);
  });
});

These APIs are part of node:test; choosing this style does not require a different assertion library.

Select tests and watch for changes

# Run one file.
node --test math.test.js

# Match both the parent and the desired nested test.
node --test --test-name-pattern='^add$|negative' math.test.js

# Run tests marked only.
node --test --test-only

# Rerun tests as watched files change; stop with Ctrl+C.
node --test --watch

Name filtering must allow a nested test's ancestors to run. A pattern that matches only negative does not automatically select its parent add test.

For a focused run, this complete example marks both the parent and selected child:

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

test('focused group', { only: true }, async (t) => {
  t.runOnly(true);
  await t.test('selected case', { only: true }, () => {
    assert.equal(2 + 2, 4);
  });
  await t.test('another case', () => {
    assert.fail('This case is excluded during a --test-only run');
  });
});

Save it separately as focused.test.js and run node --test --test-only focused.test.js. Without the selection flag, the deliberately failing case runs. Remove focus markers before committing normal tests, and ensure CI runs the full suite.

Mock a dependency without making a network call

Injecting a function makes the dependency explicit. Save this as request.test.js:

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

async function request(fetcher) {
  return fetcher('https://example.test');
}

test('request invokes its fetcher once', async (t) => {
  const mockedFetch = t.mock.fn(async () => ({ ok: true }));
  const response = await request(mockedFetch);
  assert.equal(response.ok, true);
  assert.equal(mockedFetch.mock.callCount(), 1);
  assert.deepEqual(mockedFetch.mock.calls[0].arguments, ['https://example.test']);
});

No HTTP request occurs: the function receives a mock. t.mock.method(object, 'methodName', replacement) can replace an object method, and t.mock.timers can control supported timer APIs for time-dependent tests. Prefer the test context's mock tracker when practical, so its changes are cleaned up when the test ends. Consult the target Node version for timer and module-mocking support; they are not all interchangeable across releases.

Set up and clean up with hooks

A parent test can register hooks for its subtests. This example creates a fresh array for each child and clears it afterwards:

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

test('item collection', async (t) => {
  let items;
  t.before(() => { items = []; });
  t.beforeEach(() => { items = ['seed']; });
  t.afterEach(() => { items.length = 0; });
  t.after(() => { items = undefined; });

  await t.test('adds an item', () => {
    items.push('new');
    assert.equal(items.length, 2);
  });
  await t.test('starts with a fresh fixture', () => {
    assert.deepEqual(items, ['seed']);
  });
});

Async hooks can await setup and cleanup. Avoid sharing a mutable fixture when enabling concurrent subtests; use independent fixtures or explicit coordination instead.

Generate coverage and reports

On Node.js 24, coverage is enabled with an experimental flag:

node --test --experimental-test-coverage math.test.js

The report includes line, branch, and function coverage. Coverage records what ran, not whether assertions were adequate. Use it to find gaps alongside tests for meaningful behavior.

Choose a reporter explicitly when a CI system expects a particular format:

node --test --test-reporter=spec math.test.js
node --test --test-reporter=tap math.test.js
node --test --test-reporter=junit --test-reporter-destination=results.xml math.test.js

Do not assume the default output is always TAP; defaults depend on the Node release and execution environment. The destination option writes the JUnit report to a file for CI to collect.

Test an HTTP service and release its resources

This test starts a real local server on an operating-system-assigned port. Save it as http.test.js:

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { once } from 'node:events';

test('server returns 200', async (t) => {
  const server = createServer((req, res) => {
    res.writeHead(200).end('ok');
  });
  t.after(() => new Promise((resolve, reject) => {
    server.close((error) => error ? reject(error) : resolve());
    server.closeAllConnections();
  }));

  server.listen(0, '127.0.0.1');
  await once(server, 'listening');
  const port = server.address().port;
  const response = await fetch(`http://127.0.0.1:${port}/`);
  assert.equal(response.status, 200);
  assert.equal(await response.text(), 'ok');
});

Port 0 avoids hard-coding a port that another test might occupy. The fetch address matches the IPv4 loopback address used for listening, avoiding a localhost resolution mismatch on hosts that prefer IPv6. Register cleanup before making the request so an assertion failure still closes the server and its HTTP connections. Applications using upgraded connections such as WebSockets need additional connection cleanup.

Common questions

Can this replace Jest?

It can cover many backend, library, and command-line testing needs. Compare your actual mocking, snapshot, coverage, and integration requirements before migrating. Browser-like DOM environments and framework-specific tooling may make Jest or Vitest a better fit for a particular frontend project; the built-in runner does not supply a browser environment by itself.

How does it work with TypeScript?

Node's built-in type stripping depends on the release and supported syntax. It does not type-check the program or apply arbitrary tsconfig.json transformations and path aliases. Use a compatible loader such as tsx where appropriate, or compile and test the resulting JavaScript. Run a separate type check, and include tests against the artifact you will deploy. See Node.js TypeScript support.

How do I control parallel execution?

With the default process isolation, test files run in separate processes, with parallelism controlled by --test-concurrency. Tests within a file do not all become concurrent automatically. Enable the relevant concurrency option for tests or suites that can safely overlap, and make resource cleanup independent. Awaiting each subtest one by one, as the introductory examples do, still schedules them sequentially.

Connect testing with production diagnostics

Keep test reports as CI artifacts. For a deployed service, Guance's log collection guide explains how to collect application events that help investigate failures outside the test environment. Keep those records separate from local test fixtures and attach service/version context when configuring production collection.

References and related reading

The runnable examples were checked locally on Node.js 24.19.0. Watch mode, TypeScript loaders, and external CI integrations were not exercised.

Get a tailored plan

Contact us

Join the community

Scan with WeChat
to join the community

Try Guance

Start online and pay only for what you use.

Get started

Choose a Guance plan

Code hosting