Unit testing in Node.js no longer requires installing anything: since Node 20, the built-in node:test runner and node:assert module give you a stable, fast test framework with mocking and watch mode in the standard library. That changes the default advice. For new services, start with zero test dependencies and add a framework only when you hit a concrete limitation. This guide sets up unit testing in Node.js from scratch, covers the mocking and structure decisions that keep suites maintainable, and spends deliberate time on the code paths where testing overlaps with security — validators, authorization checks, and error handling — because that is where thin coverage costs the most.
Zero-Dependency Baseline
A complete working setup:
// src/parse-limit.js
export function parseLimit(input, { max = 100 } = {}) {
const n = Number(input);
if (!Number.isInteger(n) || n < 1) return 10;
return Math.min(n, max);
}
// test/parse-limit.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { parseLimit } from "../src/parse-limit.js";
test("clamps to max", () => {
assert.equal(parseLimit("5000"), 100);
});
test("rejects non-integers with the default", () => {
for (const bad of ["abc", "1.5", "-1", "1e309", "", null]) {
assert.equal(parseLimit(bad), 10, `input: ${String(bad)}`);
}
});
node --test # runs test/**/*.test.js
node --test --watch # re-runs on change
Always import node:assert/strict — the non-strict variant's loose equality (assert.equal(1, "1") passes) is a source of tests that lie.
Where do Jest and Vitest still earn their install? Rich snapshot workflows, a large existing ecosystem of matchers, and front-end component testing. If your team already runs Vitest happily, there is no urgency to migrate; the structural advice below is runner-agnostic.
Structure: Test Behavior at the Seams
The unit-testing suites that survive refactors share two properties: they test observable behavior rather than implementation detail, and the code under test was designed with seams — dependencies passed in rather than reached for.
// Hard to test: reaches out for its dependencies
export async function chargeUser(userId, amount) {
const user = await db.users.find(userId);
return stripe.charge(user.customerId, amount);
}
// Easy to test: dependencies are parameters
export function makeChargeUser({ findUser, charge }) {
return async (userId, amount) => {
const user = await findUser(userId);
if (!user) throw new NotFoundError(userId);
return charge(user.customerId, amount);
};
}
The factory version needs no module-mocking magic — hand it plain fakes:
test("charges the stored customer id", async () => {
const charge = mock.fn(async () => ({ ok: true }));
const chargeUser = makeChargeUser({
findUser: async () => ({ customerId: "cus_123" }),
charge,
});
await chargeUser("u1", 500);
assert.equal(charge.mock.calls[0].arguments[0], "cus_123");
});
mock.fn and mock.method come from node:test itself (import { mock } from "node:test"), including mock timers for testing timeouts and retries without real waiting.
Test the Security-Relevant Units Hardest
Most suites over-test happy paths and under-test the functions where a bug becomes a vulnerability. Give these units adversarial attention:
Input validators and sanitizers. Test them with hostile inputs, not just wrong ones: prototype-pollution shapes ({"__proto__": {"admin": true}} as a parsed body), path traversal strings (../../etc/passwd) against file-path handling, and absurd lengths against anything that feeds a regex. Catastrophic regex backtracking (ReDoS) is very testable — assert the validator returns within a time budget on a pathological input.
Authorization decisions. Every branch of "who can do this" deserves an explicit test, especially the deny cases. A refactor that accidentally inverts a role check will pass every happy-path test you have.
test("viewer cannot delete projects", async () => {
await assert.rejects(
() => deleteProject({ role: "viewer" }, "p1"),
{ name: "ForbiddenError" }
);
});
Error paths. Assert that failures reject with the right error type and that no sensitive detail rides along in messages destined for clients. Error-handling code is reliably the least-covered code in a codebase, and it is where information leaks live — we go deeper on that failure class in our guide to uncaught exceptions in JavaScript.
Note what unit tests do not cover: known-vulnerable versions in your dependency tree. That is a scanner's job — software composition analysis in the same CI pipeline complements the suite rather than duplicating it, and the two together catch different halves of "this deploy is unsafe."
Coverage That Means Something
Get a coverage signal into CI, but read it correctly:
# Built-in (experimental flag as of Node 20/22)
node --test --experimental-test-coverage
# Or the widely used standalone tool
npx c8 --reporter=text --reporter=lcov node --test
Coverage percentage is a detector of untested code, not a measure of test quality — 100% line coverage of a function proves the lines executed, not that anything meaningful was asserted. Two habits make coverage useful: set a ratchet (coverage may not decrease in a PR) instead of a vanity target, and review the uncovered-lines report for the specific files where gaps are dangerous — auth, validation, parsing, crypto wrappers. Ten uncovered lines in a report formatter and ten in a session-token check are not the same risk.
Wiring CI
Keep the pipeline boringly deterministic:
# .github/workflows/test.yml
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci # never npm install in CI
- run: npx c8 node --test
npm ci matters for test integrity as much as build integrity: your tests should run against the exact locked dependency tree that will ship. Flaky suites usually trace to shared state (tests mutating a common fixture), real time (use mock timers), real network (fake it at the seam), or test-order dependence — node --test runs files in parallel by default, which surfaces order-dependence early and is worth keeping enabled for that reason alone.
A Reasonable Definition of Done
For a service to call its unit testing in Node.js adequate: every exported function of security-relevant modules has deny-case and hostile-input tests; error paths assert on error types and message hygiene; the suite runs in seconds locally with no network; coverage ratchets in CI; and no test depends on execution order. That bar is achievable in an afternoon with the standard library alone — the tooling stopped being the hard part; deciding what deserves adversarial tests is the job. If you want structured practice on the attack patterns worth encoding as test cases, the Safeguard Academy exercises map directly onto validator and authz test design.
FAQ
Do I still need Jest or Vitest for unit testing in Node.js?
Not for services. Since Node 20 the built-in node:test runner is stable and covers tests, mocking, watch mode, and (behind a flag) coverage. Jest and Vitest still make sense for front-end component testing or teams invested in their ecosystems.
How do I unit test code that talks to a database?
Design the seam: pass the data-access function in as a dependency and hand the test a fake. Reserve real-database tests for a separate, smaller integration suite. If a "unit" test needs Docker to run, it is an integration test wearing the wrong name.
What coverage percentage should we require?
Prefer a ratchet — coverage never decreases — over a fixed number. Then audit uncovered lines in high-risk modules (auth, validation, parsing) specifically. An 80% target satisfied by formatter tests while the authz module sits at 40% is worse than useless; it is false confidence.
Can unit tests catch security vulnerabilities?
They catch a specific slice: logic flaws in validators, inverted authorization branches, ReDoS-prone patterns, error-message leaks. They cannot see vulnerable dependencies or runtime misconfigurations, which is why the same pipeline should also run dependency scanning and, for web apps, dynamic testing.