A useful test protects observable behavior and important boundaries. Node ships a stable test runner and strict assertion library, so a project can begin testing without a third-party framework.
Import test or describe and it from node:test and assertions from node:assert/strict. Run node --test to discover conventional test files, or pass explicit paths. Keep test files separate from production entry points so importing code does not start a server.
Name a test after behavior and condition, not an implementation method. Arrange inputs and boundaries, perform one meaningful action, then assert result and externally relevant side effects. A test should fail for one understandable reason.
Use equal for primitives, deepEqual for structured values, throws for synchronous errors, and rejects for promises. Await the assertion or promise so the runner knows when async work completes. Prefer matching stable error codes or types over complete message text.
Do not mix callback completion and returned promises in one test. A test that starts async work without returning or awaiting it can pass before the assertion runs, then leak an unhandled rejection into another test.
Group related scenarios with describe or nested tests when setup and vocabulary are shared. before, beforeEach, afterEach, and after create explicit lifecycle ownership. Register cleanup immediately after acquiring a resource.
Tests may run concurrently. Avoid shared ports, global mutable configuration, fixed temporary paths, and one database record reused across cases. Use ephemeral ports, temporary directories, unique IDs, and isolated database state.
import test from 'node:test';
import assert from 'node:assert/strict';
function parseLimit(value) {
const limit = Number(value);
if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
throw new RangeError('limit must be from 1 to 100');
}
return limit;
}
test('parseLimit accepts a bounded integer', () => {
assert.equal(parseLimit('25'), 25);
});
test('parseLimit rejects an excessive value', () => {
assert.throws(() => parseLimit('101'), RangeError);
});
The tests protect accepted and rejected boundary behavior without reaching into implementation details.
The test context mock API can replace methods, track calls, and control supported timers. Mock narrow nondeterministic boundaries such as clock, random ID, email gateway, or payment client. Do not mock the function under test or recreate database behavior in a large fake.
Restore replacements after each test; context-owned mocks help automatic cleanup. A call-count assertion is useful only when the call itself is part of the contract. Prefer asserting returned state or emitted outcome when implementation can change safely.
Construct the application separately from listen. Start it on port 0, read the assigned port, send requests with fetch, and close the server after the test. Assert status, headers, body shape, and relevant persistence outcome.
Cover malformed JSON, oversized bodies, missing authentication, duplicate writes, dependency timeout, cancellation, and a response after a downstream failure. Use a real isolated database for repository contracts because a mock cannot prove SQL, indexes, transactions, or driver conversions.
import test from 'node:test';
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { once } from 'node:events';
test('health endpoint is ready', async (t) => {
const server = createServer((req, res) => {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ status: 'ok' }));
});
server.listen(0);
await once(server, 'listening');
t.after(() => server.close());
const { port } = server.address();
const response = await fetch(`http://127.0.0.1:${port}/health`);
assert.equal(response.status, 200);
assert.deepEqual(await response.json(), { status: 'ok' });
});
Port 0 avoids collisions, and the test owns server cleanup.
Coverage can reveal unexecuted branches but cannot prove assertions are meaningful. Use it to find risk gaps, then inspect boundary, state-transition, and failure behavior. A high percentage built from shallow tests creates false confidence.
Prioritize authentication, authorization, money or quota calculations, parsers, transactions, retries, cancellation, startup, and shutdown. Keep a small smoke suite for deployment while broader tests run before release.
Do not hide flaky tests behind retries. Capture seed, timing, runtime version, active handles, and isolated logs; then remove shared state, real-clock assumptions, uncontrolled network access, or missing awaits.
Run tests in the same Node LTS major and module mode used by production. Verify clean installation from the lockfile. A suite that passes only in a long-lived developer directory may depend on undeclared packages or stale artifacts.
Yes. node:test is stable and provides tests, suites, hooks, mocking, reporters, and command-line integration; node:assert/strict provides assertions.
No. Mock narrow nondeterministic boundaries. Use real isolated databases and protocol-level integration tests when compatibility is the behavior being proved.
Explore 500+ free tutorials across 20+ languages and frameworks.