Zero‑Dependency Testing in Node.js: Why node:test Beats Jest for New Projects
Zero‑dependency testing in Node.js is possible with the built‑in node:test runner. This blog explains its features, shows a concrete Express integration test, compares it to Jest, and gives a quick checklist for adopting node:test in new projects.
17 Sept 2025, 15:57 UTC

Why the Switch Matters
New Node.js services often start with a full‑blown testing stack: Jest, Vitest, or Mocha plus a separate mocking library. Each package adds a dependency, a build step, and a version that must stay in sync with your CI pipeline. If your only goal is to run unit and integration tests with the same semantics you already have in Node, the built‑in node:test runner is a lean, zero‑dependency alternative that ships with Node 18.13+ (LTS) and Node 20+ (current). The trade‑off is a smaller feature set, but for many teams the simplicity outweighs the loss of fancy tooling.
Key Features of node:test
- Global discoverability –
node --testautomatically runs files matching**/*.test.{js,mjs,cjs}ortest/**/*.{js,mjs,cjs}. - Describe/it syntax –
import { describe, it, test } from 'node:test';mirrors Jest’s API for a smooth learning curve. - Async/await support – all test callbacks can be async, and the runner waits for promises.
- Hooks –
before,after,beforeEach,afterEachwork out of the box. - Snapshots –
assert.snapshot(value)writes inline snapshot comments; no external directory is required. - TAP output – the default
--reporter tapformat is CI‑friendly and parsable by many tools. - Coverage –
--experimental-test-coverage(Node 20+) emits a V8 coverage report incoverage/.
Running Tests – The Flags You Need
# Discover all test files
node --test
# Run a single file
node --test path/to/user.test.js
# Filter by test name pattern
node --test-name-pattern "^UserService" --test
# Control parallelism (default is number of CPUs)
node --test-concurrency 4 --test
# Enable coverage (Node 20+)
node --experimental-test-coverage --test
All commands run as the current user; no special permissions are required. The node:test module lives in the Node core, so you never need to install or update a third‑party package to keep your test runner current.
Worked Example: Testing an Express Route
Below is a minimal Express application that exposes a /hello endpoint. We’ll spin up the server in a before hook, hit the endpoint with the native fetch API, and tear down the server in after. No mocking library is used; all HTTP semantics are real.
// server.js
import express from 'express';
export const createServer = () => {
const app = express();
app.get('/hello', (req, res) => {
res.json({ message: 'world' });
});
return app;
};
// hello.test.js
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import { createServer } from './server.js';
let server;
let port;
before(async () => {
const app = createServer();
// Pick an arbitrary free port; Node will pick one if 0 is used
server = http.createServer(app);
await new Promise(resolve => server.listen(0, resolve));
port = server.address().port;
});
after(() => {
server.close();
});
test('GET /hello returns JSON', async () => {
const res = await fetch(`http://localhost:${port}/hello`);
assert.strictEqual(res.status, 200);
const body = await res.json();
assert.deepStrictEqual(body, { message: 'world' });
});
Run the test with:
node --test hello.test.js
The output is TAP‑formatted:
TAP version 13
ok 1 GET /hello returns JSON
1..1
Because node:test does not ship a test runner UI, you can pipe the TAP output to tap-spec or tap-summary if you want richer formatting.
Trade‑Offs and Limitations
- No built‑in mocking or spying – you’ll need
node:test's experimentalmockmodule (Node 22+) or a tiny helper library likesinon. Jest’sjest.mockis still a one‑liner for many use cases. - Snapshot diffing – snapshots are stored inline; updating them requires editing the test file, not a CLI
-uflag. - IDE integration – most editors still rely on external test adapters for watch mode, code coverage UI, and inline assertions. You can use
node --testwithnodemonorts-node-devfor a basic watch loop. - Coverage output – the experimental flag produces a V8 JSON report; converting it to HTML or text requires a separate tool like
nycorlcov‑info. - Parallelism pitfalls –
--test-concurrencycan cause flakiness if tests share global state (databases, ports). Use--test-concurrency=1or isolate resources per file. - TypeScript support –
node:testdoes not transpile TS; you must compile to JS or usetsxorts-nodeto run tests.
When to Adopt node:test
If your service targets Node 20+ and you want:
- Zero external test dependencies (reducing supply‑chain risk).
- Simple CI pipelines that only need TAP output.
- Fast local test runs without a watcher.
- Minimal bundle size for serverless functions.
then node:test is a strong candidate. For larger codebases that rely on Jest’s rich ecosystem—snapshot UIs, module mocking, or legacy Node 12 support—stick with Jest or Vitest and consider node:test for new micro‑services or as a lightweight fallback.
Actionable Checklist
- Confirm your Node version:
node --version(must be ≥18.13 or 20+). - Add a
testscript topackage.json:"scripts": { "test": "node --test" } - Create an initial test file and run
npm testto verify TAP output. - Add
--experimental-test-coverageto your CI job and inspectcoverage/coverage-final.jsonfor coverage stats. - If you need mocking, experiment with
node:test'smockmodule or add a minimal library. - Gradually replace Jest/Vitest tests in new modules with
node:test; keep legacy tests in parallel if needed.
By following this checklist, you can start a new Node.js project with a lean, zero‑dependency test stack that scales with your team’s needs.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.