Unit testing with node:test and Vitest
Node ships a stable test runner (node:test) plus node:assert; Vitest adds Vite speed and rich matchers. Test behavior not implementation, keep each test independent, and fake the clock so time-dependent tests are fast and deterministic.
A retry helper had a test that waited a real five seconds for a setTimeout backoff, and the suite was green — until CI got busy. Under load the timer fired a few milliseconds late, the assertion ran before the retry completed, and the test failed roughly one run in twenty. The team marked it skip, shipped, and three weeks later a real backoff regression slipped through because the only test that covered it had been muted. The failure was never in the retry code. It was a test coupled to the wall clock, and the fix was four lines: fake the timers, advance them by hand, and the same test became instant and 100% deterministic.
The built-in runner: node:test and node:assert
Before you reach for a third-party runner, consider what you already have: since Node 18 (stable in Node 20) the built-in runner covers the whole unit-testing workflow with zero install cost, and that matters on a lean CI pipeline or a published library that can’t afford extra transitive deps.
Since Node 20 the test runner is stable and built in — no dependency, no config. You write tests with test() (or describe() / it()), assert with node:assert, and run them with node --test, which discovers files matching *.test.js, *-test.js, and files under test/ directories. Add --watch to re-run on change, and --experimental-test-coverage to get a coverage summary. Subtests nest with await t.test(...), and hooks (before, after, beforeEach, afterEach) set up and tear down state.
import { test, describe, it, beforeEach } from "node:test";
import assert from "node:assert/strict";
import { Cart } from "./cart.js";
describe("Cart", () => {
let cart;
beforeEach(() => { cart = new Cart(); }); // fresh state per test
it("totals line items", () => {
cart.add({ price: 10, qty: 2 });
assert.strictEqual(cart.total(), 20);
});
it("rejects a negative quantity", () => {
assert.throws(() => cart.add({ price: 10, qty: -1 }), /quantity/);
});
});The key with node:assert is to import the strict variant (node:assert/strict) so assert.equal means ===, not the loose == that quietly treats "2" and 2 as equal. assert.strictEqual compares primitives; assert.deepStrictEqual walks objects and arrays structurally; assert.match checks a string against a regex. For failures you expect, assert them directly: assert.throws(fn, /pattern/) for synchronous throws and await assert.rejects(promise, /pattern/) for rejected promises — a test that asserts the absence of an error where one should fire is a test that passes for the wrong reason.
Mocking at the boundaries
A unit test should exercise your logic, so you replace the slow or non-deterministic things at its edges — the network, the filesystem, the clock — and leave your own code real. node:test gives you mock.fn() for standalone spies, t.mock.method(obj, "name") to replace a method (auto-restored when the test ends), and t.mock.module() to swap a whole module. A spy records every call: mock.calls holds the arguments, return values, and call count, so you assert on how a collaborator was used.
import { test, mock } from "node:test";
import assert from "node:assert/strict";
test("charges the gateway once with the order total", () => {
const gateway = { charge: mock.fn(() => ({ id: "ch_1" })) };
const order = { total: 4200, gateway };
checkout(order);
assert.strictEqual(gateway.charge.mock.callCount(), 1);
assert.deepStrictEqual(gateway.charge.mock.calls[0].arguments, [4200]);
});The discipline that matters: mock at the boundary, not your own logic. If you mock the function under test, the test asserts your mock, not your code. Stub the payment gateway’s HTTP call; do not stub the checkout() you are trying to verify.
▸Why this works
Fake timers are the highest-leverage mock for backend code. mock.timers.enable({ apis: ["setTimeout", "Date"] }) swaps the real clock for a controllable one; mock.timers.tick(5000) advances virtual time instantly, firing any timer due within that window without the wall clock moving at all. A five-second backoff test runs in microseconds and never flakes on a busy CI box, because there is no real time and therefore no race. Vitest’s vi.useFakeTimers() / vi.advanceTimersByTime() does the same thing. This single technique turns the slowest, flakiest category of unit test into the fastest, most reliable one.
Choosing the runner: node:test vs Vitest vs Jest
| Runner | Dependencies | Startup / speed | API & env | Reach for it when |
|---|---|---|---|---|
node:test | zero (built in) | fastest cold start; no bundler | core API + node:assert; no DOM | libraries, pure backend, minimal deps |
| Vitest | one dev dep (Vite) | fast HMR watch; reuses Vite transform | Jest-like expect/vi; jsdom; TS built in | rich matchers, snapshots, DOM, monorepo |
| Jest | several deps | slower start; heavy transform | mature expect; jsdom; huge ecosystem | existing Jest suite, legacy plugins |
node:test is the right default for a library or a backend with few dependencies: nothing to install, the fastest startup, and it speaks the core API. Reach for Vitest when you want Jest-style ergonomics — chainable expect matchers, inline snapshots, vi.mock, a jsdom/happy-dom environment for code that touches the DOM, TypeScript with no extra config, and a snappy HMR-powered watch that re-runs only the affected tests. Vitest is near-drop-in for Jest (vi.fn for jest.fn, vi.mock for jest.mock), so it is also the standard escape hatch from a slow Jest setup. Pick Jest itself mainly when you already have a Jest suite and plugins you do not want to migrate.
You are starting a new published npm library: pure TypeScript, no DOM, and you want the smallest dependency tree and fastest CI cold start. Which test runner is the senior default?
Principles that keep tests honest
When you see a test suite getting muted or skipped rather than fixed, these three rules are what was violated. Three rules separate a suite you trust from one you mute. Test behavior, not implementation: assert the observable outcome (the return value, the call to a collaborator) rather than private internals, so a refactor that keeps behavior identical does not turn the suite red. A test that breaks every time you rename a private method is testing the wrong thing. Arrange-Act-Assert: set up inputs, perform the one action, assert the one outcome — one logical assertion per test makes a failure point straight at its cause. Keep each test independent: no shared mutable state, no reliance on run order. Reset state in beforeEach; if test B only passes because test A ran first and left a row in a module-level array, you have an order-dependent suite that goes flaky the moment the runner parallelizes or shuffles.
// ❌ order-dependent: module-level state leaks between tests
const users = [];
test("a: adds a user", () => { users.push({ id: 1 }); assert.strictEqual(users.length, 1); });
test("b: starts empty", () => { assert.strictEqual(users.length, 0); }); // FAILS after a
// ✅ independent: fresh state each time
let store;
beforeEach(() => { store = new UserStore(); });A test mocks the function it is supposed to verify and asserts the mock was called. What is wrong with it?
A test waits a real 5 seconds for a setTimeout-based retry, and it flakes under CI load. Why do fake timers fix both the slowness and the flakiness?
Order the steps to write one deterministic unit test for a setTimeout-based retry:
- 1 Enable fake timers: mock.timers.enable({ apis: ['setTimeout'] })
- 2 Arrange: build the subject and stub its boundary (e.g. a failing-then-succeeding fetch spy)
- 3 Act: call the retry function (it schedules a setTimeout instead of waiting)
- 4 Advance virtual time: mock.timers.tick(5000) to fire the scheduled callback
- 5 Assert behavior: the spy was called the expected number of times and the result resolved
- 01Why does faking timers turn a slow, flaky timeout test into a fast, deterministic one — and how do you do it with node:test?
- 02When should you choose node:test over Vitest, and vice versa?
Node ships a stable, zero-dependency test runner: write tests with test() or describe()/it(), assert with node:assert/strict (use the strict import so equal means ===), and run them with node --test, which discovers *.test.js files and supports --watch and --experimental-test-coverage; hooks (before/after/beforeEach/afterEach) build and tear down state. Assert the failures you expect with assert.throws and await assert.rejects, and mock at the boundaries only — mock.fn, t.mock.method, and especially fake timers (mock.timers.tick), which turn a real 5-second backoff test into an instant, deterministic one by advancing virtual time instead of waiting on the wall clock. Reach for Vitest when you want Jest-style expect matchers, snapshots, a DOM environment, TypeScript with no config, and a fast HMR watch — it is near-drop-in for Jest — and keep node:test as the default for libraries and dependency-light backends. Whichever you pick, the principles are the same: test behavior not implementation so refactors stay green, follow Arrange-Act-Assert with one outcome per test, and keep every test independent of shared mutable state and run order, or the suite goes flaky and gets muted — which is how a real regression slips past the one test that covered it. Now when you see a flaky test — or a test someone added skip to — you know the root is almost always one of these three rules broken, and you know how to fix it without touching a line of production code.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.