Testing a function that sends an email raises an obvious problem: nobody wants a thousand messages genuinely leaving the building every time the suite runs.
The answer fits in one sentence: the sending is replaced by a double, and the test checks the double was asked to work.
Definition
A mock is a fake implementation standing in for a real dependency during a test. It hands back an answer decided in advance and records everything asked of it: how many calls, with which arguments, in what order.
import { expect, it, vi } from "vitest";
import { notify } from "./notifications.js";
it("sends one message per recipient", () => {
const send = vi.fn();
notify(["lea@example.com"], send);
expect(send).toHaveBeenCalledTimes(1);
expect(send).toHaveBeenCalledWith("lea@example.com");
});The vi.fn() function does nothing and returns undefined, which is enough here. Its value lies elsewhere: it keeps a record of its calls, and that record is what the test questions.
Four doubles, routinely confused
| Name | What it does |
|---|---|
| Spy | Watches the calls without changing the real behavior |
| Stub | Hands back a fixed answer, with no logic at all |
| Mock | A stub whose calls are afterwards verified |
| Fake | A simplified but genuinely working implementation |
Everyday vocabulary calls all of this a "mock", and the shortcut carries little consequence. The distinction worth keeping is the last one: a fake, such as an in-memory database, actually works, where the other three merely answer.
Replacing a module, stopping time
Passing the dependency in as an argument is the simplest route, but it is not always available. Vitest can also intercept a whole import, and replace the engine's clock.
vi.mock("./mailer.js", () => ({
send: vi.fn().mockResolvedValue({ id: "msg_1" }),
}));
vi.useFakeTimers();
retryInOneHour();
vi.advanceTimersByTime(3600 * 1000);
expect(send).toHaveBeenCalledTimes(1);The simulated clock is the cure for slow tests: without it, checking a retry scheduled with setTimeout() would mean genuinely waiting an hour.
Frequently asked questions
Should every dependency be mocked?
No, only those that leave the program: network, disk, clock, randomness, payment. A calculation function imported by another has no reason to be replaced, and replacing it only blinds the test to any incompatibility between the two.
What happens when the real dependency changes?
That is the structural weakness of the technique: the double keeps answering as before, the suite stays green, and the breakage only surfaces in production. The usual safeguard is a handful of integration tests that do call the real implementation.
How is a network call simulated?
Two schools. The quickest replaces fetch() with a double returning a ready-made response. The more faithful one intercepts the request at the simulated network level, which lets the real code of Axios or of whatever client is in use run. The first is enough for a unit test, the second is better as soon as headers or an error code matter.