How to test a Node.js API with the built-in test runner (node:test)
Write, run, mock and measure coverage for a small Node.js HTTP API using only node:test and node:assert. Covers port-0 servers, fetch(), spies, t.mock.method(), fake dates and coverage thresholds.
9 min read

In this article
For years, testing a Node.js API meant installing a framework first: Jest, Mocha with Chai, or Vitest. Each one brings dependencies, configuration and its own idea of how mocks work. Node.js now ships a stable test runner in the node:test module, plus node:assert for assertions. With them you can write, run, mock and measure coverage for a small API without adding a single test dependency.
This tutorial builds a tiny JSON API on the built-in http module and tests it end to end with node --test. You will start a real server on a random port, call it with the built-in fetch(), replace a dependency with a spy, fake the clock, and fail the build when line coverage drops below a threshold.
Everything here was written against the Node.js 24 documentation and run on Node.js 24 (the current Active LTS line) on 7 October 2026. The same APIs exist in Node.js 22 and 26.
What the built-in test runner gives you
The node:test module has been stable since Node.js 20. In Node.js 24 it covers most of what a typical service needs:
| Need | Built-in feature | Status in the v24 docs |
|---|---|---|
| Test structure | test(), describe() / it(), hooks before, after, beforeEach, afterEach | Stable |
| Assertions | node:assert/strict | Stable |
| Spies and mocks | mock.fn(), t.mock.method() | Stable (part of node:test) |
| Fake timers and dates | mock.timers.enable() | Documented under node:test |
| Watch mode | node --test --watch | Stable |
| Code coverage | --experimental-test-coverage | Experimental |
| Snapshot tests | t.assert.snapshot() | No longer experimental |
Running node --test with no arguments finds files that match patterns such as **/*.test.js, **/test-*.js and anything under a test/ folder. Because TypeScript type stripping is on by default in current releases, .test.ts files are matched as well.

Step 1: the project
Create a folder with this package.json. The "type": "module" line lets us use import syntax in .js files.
{
"name": "items-api",
"type": "module",
"scripts": {
"test": "node --test",
"test:coverage": "node --test --experimental-test-coverage --test-coverage-lines=80"
}
}The application takes its storage and a notification callback as parameters. Passing dependencies in, rather than importing them inside the module, is what makes the next steps easy to test.
// src/app.js
import { createServer } from 'node:http';
const MAX_BODY_BYTES = 10_000;
function send(res, status, payload) {
res.writeHead(status, { 'content-type': 'application/json' });
res.end(JSON.stringify(payload));
}
async function readJson(req) {
let size = 0;
const chunks = [];
for await (const chunk of req) {
size += chunk.length;
if (size > MAX_BODY_BYTES) throw new Error('Body too large');
chunks.push(chunk);
}
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
}
export function createApp({ store, notify = async () => {} }) {
return createServer(async (req, res) => {
const { pathname } = new URL(req.url, 'http://localhost');
if (req.method === 'GET' && pathname === '/health') {
return send(res, 200, { status: 'ok', checkedAt: new Date().toISOString() });
}
if (req.method === 'POST' && pathname === '/items') {
let body;
try {
body = await readJson(req);
} catch {
return send(res, 400, { error: 'Request body must be valid JSON under 10 KB' });
}
const name = typeof body?.name === 'string' ? body.name.trim() : '';
if (name === '') {
return send(res, 400, { error: 'name is required' });
}
const item = await store.insert({ name });
await notify(item);
return send(res, 201, item);
}
if (req.method === 'GET' && pathname === '/items') {
try {
return send(res, 200, { data: await store.list() });
} catch {
return send(res, 503, { error: 'Items are temporarily unavailable' });
}
}
return send(res, 404, { error: 'Not found' });
});
}Note the body-size limit in readJson(): an API that buffers request bodies without a limit can be pushed into running out of memory. A simple in-memory store is enough for the tests:
// src/memory-store.js
export function createMemoryStore() {
const rows = [];
return {
async insert({ name }) {
const row = { id: rows.length + 1, name };
rows.push(row);
return row;
},
async list() {
return [...rows];
},
};
}Step 2: start a real server per test file
Testing over HTTP catches routing, status codes and JSON handling, which unit tests of individual functions miss. The trick is to listen on port 0 so the operating system picks a free port, then read the actual port from server.address(). Several test files can run in parallel without colliding.
By default the runner executes each test file in its own child process (the process isolation mode), so state in one file cannot leak into another. Tests inside one describe() block run in order.
Here is the complete test file. The sections below explain each part.
// test/items.test.js
import assert from 'node:assert/strict';
import { once } from 'node:events';
import { after, before, beforeEach, describe, it, mock } from 'node:test';
import { createApp } from '../src/app.js';
import { createMemoryStore } from '../src/memory-store.js';
describe('items API', () => {
let server;
let baseUrl;
let store;
const notify = mock.fn(async () => {});
before(async () => {
store = createMemoryStore();
server = createApp({ store, notify });
server.listen(0, '127.0.0.1'); // port 0: the OS picks a free port
await once(server, 'listening');
baseUrl = `http://127.0.0.1:${server.address().port}`;
});
after(() => {
server.close();
});
beforeEach(() => {
notify.mock.resetCalls();
});
it('creates an item and notifies once', async () => {
const res = await fetch(`${baseUrl}/items`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name: ' Invoice template ' }),
});
assert.equal(res.status, 201);
assert.deepEqual(await res.json(), { id: 1, name: 'Invoice template' });
assert.equal(notify.mock.callCount(), 1);
assert.deepEqual(notify.mock.calls[0].arguments, [{ id: 1, name: 'Invoice template' }]);
});
it('rejects a missing name with 400', async () => {
const res = await fetch(`${baseUrl}/items`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({}),
});
assert.equal(res.status, 400);
assert.deepEqual(await res.json(), { error: 'name is required' });
assert.equal(notify.mock.callCount(), 0);
});
it('rejects malformed JSON with 400', async () => {
const res = await fetch(`${baseUrl}/items`, { method: 'POST', body: '{not json' });
assert.equal(res.status, 400);
});
it('answers 503 when the store fails', async (t) => {
// Replaced only for this test; the runner restores it afterwards.
t.mock.method(store, 'list', async () => {
throw new Error('database unavailable');
});
const res = await fetch(`${baseUrl}/items`);
assert.equal(res.status, 503);
assert.equal(store.list.mock.callCount(), 1);
});
it('lists items once the store is back', async () => {
const res = await fetch(`${baseUrl}/items`);
assert.equal(res.status, 200);
assert.ok(Array.isArray((await res.json()).data));
});
it('reports a fixed time on /health', async (t) => {
t.mock.timers.enable({ apis: ['Date'], now: new Date('2026-10-07T09:00:00Z') });
const res = await fetch(`${baseUrl}/health`);
assert.deepEqual(await res.json(), {
status: 'ok',
checkedAt: '2026-10-07T09:00:00.000Z',
});
});
});Run it:
npm test
# or directly
node --testThe default reporter prints a tree of suites and tests with timings, then totals for passed, failed, skipped and cancelled tests. A non-zero exit code on failure is all a CI system needs.
Step 3: spies and method mocks
Two mocking styles appear in the file.
A spy created with mock.fn()
notify is created with mock.fn(). It behaves like the async function you pass in, and records every call: notify.mock.callCount() returns the number of calls and notify.mock.calls[0].arguments holds the arguments of the first call. Because the spy is shared across tests, beforeEach calls notify.mock.resetCalls() so each test starts from zero.
A temporary method mock with t.mock.method()
The 503 test replaces store.list with a function that throws, to prove the API returns a clean error instead of crashing. It uses the t.mock object from the test context rather than the global mock. The documentation points out the benefit: mocks created through the test context are restored automatically when that test finishes, so the next test sees the real method again.
The replaced method is itself a spy, so store.list.mock.callCount() confirms that the route actually called the store.
Step 4: control time with mock timers
The /health route returns the current time, which makes an exact assertion impossible unless you control the clock. t.mock.timers.enable({ apis: ['Date'], now: ... }) fixes Date to a known instant for the rest of the test.
The same API can fake setTimeout and setInterval; you then move time forward with mock.timers.tick(milliseconds) instead of waiting. Two limits from the documentation are worth knowing:
- Only the APIs you list in
apisare faked. FakingDatealone, as here, leaves the timers used byfetch()and the HTTP server untouched. - Destructured imports such as
import { setTimeout } from 'node:timers'are not supported by the timer mocks. CallsetTimeoutthrough the global or the module object.

Step 5: coverage with a threshold
Coverage collection is still marked experimental, but it is usable today and needs no extra packages. The test:coverage script in package.json runs:
node --test --experimental-test-coverage --test-coverage-lines=80After the test results, the runner prints a table of line, branch and function coverage per file, with the uncovered line numbers. --test-coverage-lines=80 makes the process exit with code 1 if line coverage is below 80 percent; matching flags exist for branches and functions. Test files and anything under node_modules are excluded from the report by default.
To feed a coverage service or an editor plugin, add the lcov reporter alongside the normal one:
node --test --experimental-test-coverage \
--test-reporter=spec --test-reporter-destination=stdout \
--test-reporter=lcov --test-reporter-destination=lcov.infoIf a block genuinely cannot be reached in tests, you can exclude it with a /* node:coverage ignore next */ comment. Use that sparingly; a growing number of ignore comments usually means the code needs restructuring.
Step 6: everyday commands
| Task | Command |
|---|---|
| Run all tests | node --test |
| Run one file | node --test test/items.test.js |
| Run tests whose name matches | node --test --test-name-pattern="503" |
| Re-run on file changes | node --test --watch |
| Custom file patterns | node --test "**/*.spec.js" (quote globs) |
| Limit parallel files | node --test --test-concurrency=2 |
Node.js 24.16.0 also added --test-randomize, marked as early development, which shuffles test order to expose hidden dependencies between tests. The example file above deliberately shares one server and one store across its tests, so if you adopt randomised order, create the store inside each test instead.
When to still reach for a framework
The built-in runner is a good default for back-end services, libraries and CLIs. A third-party framework can still be the better choice when you need browser-like environments for front-end components, a large existing suite that depends on framework-specific matchers, or a team that relies on a particular watch-mode user interface. Moving gradually is possible: node --test runs any file that exits with code 0 as a pass, so existing scripts can sit next to new node:test files during a migration.
Summary
node:testplusnode:assert/strictcovers structure, assertions, spies, method mocks and fake timers without dependencies.- Start a real server on port 0 and test it with
fetch()for meaningful API tests. - Prefer
t.mockover the globalmockso replacements are restored after each test. - Use
--experimental-test-coveragewith--test-coverage-linesto fail CI when coverage drops.
Next step: add node --test to your CI workflow and run it on every supported Node.js line you deploy to.
Frequently asked questions
Do I need Jest or Mocha to test Node.js code?
Not for most back-end code. The built-in
node:testmodule has been stable since Node.js 20 and includes test structure, hooks, spies, method mocks, timer mocks and a coverage report.Is code coverage in node:test production-ready?
The Node.js 24 documentation still marks
--experimental-test-coverageas experimental, so flags and output may change. It works for CI thresholds today, but pin your Node.js version in CI so a change does not surprise you.Can node --test run TypeScript test files?
Yes, as long as the files use only erasable TypeScript syntax. Unless
--no-strip-typesis passed, the default patterns also match files such as*.test.ts.How do I run tests in parallel?
Test files already run in separate child processes by default. Use
--test-concurrencyto limit how many run at once.
Your next customer could start right here.
Introduce your tool, product or business to readers exploring technology.
Explore placements and request a private quote
Sources
- Test runner (Node.js v24 docs) — Node.js
- Command-line options (Node.js v24 docs) — Node.js
- HTTP (Node.js v24 docs) — Node.js
- Assert (Node.js v24 docs) — Node.js
Written with substantial AI assistance. Facts checked against cited sources. Editorial policy.
Comments
No comments yet. Be the first to join the conversation.
Leave a comment
Every comment is read by a moderator before it appears. Your email address is never published; it is used only to contact you about this comment.
More from Cloudsoft Zone

TutorialNode.js
Node.js streams in practice: pipeline(), backpressure and async generators
Process files of any size in Node.js with flat memory use. A hands-on guide to stream/promises pipeline(), backpressure, async generator transforms, AbortSignal timeouts and streaming downloads over HTTP.
By Cloudsoft Zone

Industry updateNode.js
Which Node.js version to use in October 2026: LTS lines and the new schedule
Node.js 26 becomes LTS on 28 October 2026, Node.js 24 moves to maintenance and 20.x is already end of life. Here is every supported line, the new yearly release model from Node.js 27, and which version to run.
By Cloudsoft Zone

ComparisonNode.js
Express 5 vs Fastify 5: which Node.js framework to pick in 2026
Express 5 and Fastify 5 compared on support status, validation, error handling, upgrades and performance, with the same API built and tested in both.
By Cloudsoft Zone
