open atlas
↑ Back to track
Node.js, zero to senior NODE · 06 · 03

Test doubles and mocking: seams, not sledgehammers

A test double substitutes a collaborator at a seam. Know the five kinds, prefer injected seams over module mocks, fake timers to make a 30s backoff test run in 5ms — and never mock the thing you are actually testing, or the suite goes green while prod 500s.

NODE Senior ◷ 18 min
Level
FoundationsJuniorMiddleSenior

Your checkout suite was 600 tests of pure green, 0.8s end to end, and it shipped a bug that double-charged customers. The charge function called stripe.charges.create, and every test mocked that call to resolve { status: "succeeded" }. So when a refactor started passing the amount in dollars instead of cents, the mock cheerfully returned success for $4900 instead of $49.00 — it had no idea what a real charge looks like. The tests asserted the mock was called, never that the charge was right. The suite was a mirror: it reflected your assumptions back at you, including the wrong one.

The five doubles, named precisely

When a colleague says “just mock it,” which of the five distinct tools do they mean? The answer changes whether your test catches bugs or just reflects your assumptions back at you.

“Mock” is the word everyone reaches for, and most of the time it’s wrong. Following Meszaros’ taxonomy (the one Fowler popularised), there are five kinds of test double, distinguished by what they do and what you assert on. A dummy is a placeholder you pass to satisfy a signature and never use — null, {}, a no-op function. A stub returns canned values to steer a path: getUser() always returns the same admin so you can test the admin branch. A spy is a stub that also records how it was called, so you can assert on arguments and call count after the fact. A mock is pre-programmed with expectations — it knows the calls it should receive and fails the test itself if reality diverges. A fake is a real, working, lightweight implementation: an in-memory repository backed by a Map instead of Postgres, fully functional but unfit for production.

The line that matters at the senior level is state verification vs behavior verification. Stubs and fakes let you assert on outcome — what state the system ended in. Spies and mocks pull you toward asserting on interaction — which methods got called, in what order. Interaction assertions are seductive and brittle: they pin your test to the current implementation, so a refactor that keeps behavior identical can turn the suite red. The hook’s bug is the inverse failure — interaction was all it checked, so a broken outcome stayed green.

In node:test (built in since Node 18, stable in 20) the double is mock.fn(), and you patch a method with mock.method(obj, 'name') — both auto-restore at test end if you use the test context’s mock tracker. Vitest mirrors this with vi.fn() and vi.spyOn(obj, 'method'). Here is a spy over a real object, asserting on both outcome and interaction:

import { test } from "node:test";
import assert from "node:assert/strict";

test("notify spies on the mailer", () => {
  const mailer = { send: () => "queued" };
  const spy = test.mock.method(mailer, "send");   // wraps the real method

  notify(mailer, "you@co.com", "hi");

  // interaction: was it called, and how?
  assert.equal(spy.mock.calls.length, 1);
  assert.deepEqual(spy.mock.calls[0].arguments, ["you@co.com", "hi"]);
});

Seams: substitute behavior without editing the code under test

A seam is a place where you can change behavior without editing the code being tested — Feathers’ term, and the single most useful lens on testability. Every double needs a seam to live in, and you fundamentally have two.

The clean seam is dependency injection: the collaborator arrives as an argument (constructor, function parameter, factory), so the test just passes a stub or fake instead of the real thing. No magic, no framework, refactor-safe — the wiring is plain values flowing through plain APIs. The cost is that it shows up in your signature: charge(order) becomes charge(order, { gateway, clock }), which some teams resist as “test-induced damage.”

The other seam is module mocking, for when you can’t inject — a module that does import { readFile } from "node:fs/promises" and calls it directly. node:test exposes mock.module(specifier, { namedExports }); Vitest uses vi.mock('./mod'). The Vitest gotcha is hoisting: vi.mock is lifted to the top of the file above your imports, so its factory must not reference outer variables (use vi.hoisted() if it must), and the mock applies to every importer of that module for the whole test file.

// Vitest module mock — hoisted above imports, applies file-wide
import { vi, test, expect } from "vitest";
import { loadConfig } from "./config.js";

vi.mock("node:fs/promises", () => ({
  readFile: vi.fn().mockResolvedValue('{"port":8080}'),
}));

test("parses config from disk", async () => {
  expect(await loadConfig("/app.json")).toEqual({ port: 8080 });
});
Why this works

Why does DI win on refactors and module mocking lose? An injected seam is part of your real call graph: the test exercises the same wiring production uses, just with a different value plugged in. A module mock reaches outside the call graph and rewrites the module loader’s resolution for one file. So a module mock is coupled to import paths and shapes — rename ./config to ./config/index, switch a named export to default, or move a function to another module, and the mock silently stops intercepting while the test keeps passing against the unmocked real code (or fails for reasons unrelated to your change). DI breaks loudly at compile/lint time; module mocks rot quietly.

Fake timers: collapse real seconds into microseconds

Code that waits — retry-with-backoff, debounce, polling, cache TTLs — is untestable in real time: a 30-second exponential backoff (1s, 2s, 4s, 8s, 15s caps) would make one test cost half a minute. Fake timers replace setTimeout, setInterval, and Date.now/Date with a controllable clock you advance manually, so virtual time moves at zero real cost. node:test enables them per test with t.mock.timers.enable({ apis: ['setTimeout', 'Date'] }) and advances with t.mock.timers.tick(ms); Vitest uses vi.useFakeTimers() and vi.advanceTimersByTime(ms) / await vi.advanceTimersByTimeAsync(ms).

The sharp gotcha is timers and microtasks are different queues. tick/advanceTimersByTime fires the timer callbacks, but the promises those callbacks resolve sit in the microtask queue, which only drains when you await. So for async retry code you must advance and yield — Vitest’s advanceTimersByTimeAsync does both; with the sync version you interleave await Promise.resolve() or await flushPromises(). Get this right and a backoff that takes 30s of wall-clock runs in under 5ms.

import { vi, test, expect } from "vitest";

test("retries with backoff, virtually", async () => {
  vi.useFakeTimers();
  const op = vi.fn()
    .mockRejectedValueOnce(new Error("503"))
    .mockResolvedValueOnce("ok");

  const p = retryWithBackoff(op, { base: 1000 }); // would sleep 1s for real
  await vi.advanceTimersByTimeAsync(1000);        // advance clock AND flush microtasks

  expect(await p).toBe("ok");
  expect(op).toHaveBeenCalledTimes(2);
  vi.useRealTimers();
});
Pick the best fit

A service depends on a Postgres repository. You want fast, reliable tests of the service's business logic across many rows and edge cases, without a real DB in unit tests. What do you substitute at the repository seam?

The failure mode: over-mocking ships green bugs

The deepest failure in this lesson is over-mocking: mocking the very thing under test, or so much around it that the test no longer exercises real code. When you mock the collaborator whose behavior is the point, the mock encodes an assumption — and if that assumption is the bug, the test asserts the bug is correct. The hook is the canonical case: stripe.charges.create was mocked to always succeed, so the dollars-vs-cents defect could never turn the suite red. A second, slower-burning variant is contract drift: the real dependency’s API changes (a field renamed, a status added, a 200-with-error-body) but the hand-written mock doesn’t, so tests stay green against a fiction while production returns 500s.

The numbers are damning. A real audited suite had ~400 mock-call assertions and zero real I/O — every external boundary stubbed — and missed a schema change in the response payload that broke deserialization in prod within an hour of deploy; the mocks had been frozen at the old shape for months. The fix is not “mock less” as a slogan but three concrete moves: (1) at the critical seam, prefer a fake or a real integration test over a stub, so you exercise behavior, not your guess about it; (2) assert on outcome, not on call countsexpect(result.chargedCents).toBe(4900), not expect(stripe.create).toHaveBeenCalled(); (3) contract-test the boundary — run a small suite against the real dependency (or its recorded responses / a provider contract) so a shape change fails that test loudly instead of leaking past your frozen mock. Together these three moves shift the suite from a mirror of your assumptions to an actual probe of your code’s behavior: step (1) adds real logic to the test path, step (2) makes the assertion observable, and step (3) keeps the mock shape honest — without all three, any one of them can still leak a prod bug.

// ❌ over-mock: asserts the mock was called, not that the charge is right
test("charges the order", async () => {
  const stripe = { charges: { create: vi.fn().mockResolvedValue({ status: "succeeded" }) } };
  await checkout(order, { stripe });
  expect(stripe.charges.create).toHaveBeenCalled();   // green even at $4900
});

// ✅ assert outcome against a behavior-honouring fake
test("charges the order in cents", async () => {
  const fakeGateway = makeFakeGateway();              // validates & records real amounts
  const receipt = await checkout(order, { gateway: fakeGateway });
  expect(receipt.chargedCents).toBe(4900);            // catches dollars-vs-cents
});
Quiz

A checkout suite is fully green, runs in under a second, mocks every external call, and asserts each mock was called — yet it shipped a bug that charged 100× too much. What is the root cause?

Recall before you leave
  1. 01
    Distinguish a stub, a spy, a mock, and a fake — and which ones push you toward brittle tests.
  2. 02
    Why does an injected (DI) seam beat a module mock on refactors, and when do you reach for module mocking anyway?
Recap

A test double substitutes a collaborator at a seam, and naming them precisely changes how you test: a dummy fills a signature, a stub returns canned values to steer a path, a spy also records its calls, a mock carries expectations and fails itself, and a fake is a real lightweight implementation (an in-memory repo). Spies and mocks pull you toward interaction verification, which pins tests to the implementation and rots on refactors; stubs and especially fakes let you assert on outcome, which survives. Every double lives in a seam: dependency injection is the clean, explicit, refactor-safe one (at the cost of widening your signature), while module mocking — vi.mock, with its hoisting trap, or node:test’s mock.module — is the reach-for-it-when-you-can’t-inject seam that couples the test to import paths and shapes. Fake timers (vi.useFakeTimers / t.mock.timers) collapse a 30s backoff into under 5ms, as long as you advance the clock and flush microtasks together — advanceTimersByTimeAsync does both. The failure that ends careers is over-mocking: mock the thing you’re actually testing and the mock encodes your buggy assumption, so the suite goes green while prod 500s — the ~400-assertion, zero-I/O suite that missed a schema change. The fix is to prefer fakes or integration at the critical seam, assert on outcome rather than call counts, and contract-test the boundary so a shape change fails loudly instead of leaking past a frozen mock. Now when you see a green suite ship a real-money bug, your first question is: what did the test actually assert — the call happened, or the result was correct?

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.

recallapplystretch0 of 5 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.