Mocks, stubs and fakes
Replace the database, the email API, or a slow dependency with something you control. Three techniques, what they're each for, and where they go wrong.

Say you're testing a function that sends a welcome email after someone registers. Run that test for real and every test run sends an actual email, to an actual address, through an actual third-party API that costs money and can go down. None of that has anything to do with whether your registration logic is correct. This is what mocking solves: swap the risky, slow, or external part for something you control, so the test can focus on the one thing it's supposed to check.
Three words that mean different things
People use "mock" for all of these, but they're not interchangeable:
A stub is a canned answer. Call it, get back a fixed value, no matter what. You'd stub a getExchangeRate() call to always return 1.1 so a currency test isn't at the mercy of a live API.
A fake is a working, lightweight stand-in. An in-memory array instead of a real database is the classic example, which is exactly what Linkstash's openDb() does by defaulting to ":memory:" SQLite, a real database engine that happens to live in RAM instead of on disk.
A mock (in the strict sense) records what was called, with what arguments, and lets you assert on it afterward. You'd mock a sendEmail() function to check it was called once, with the right address, without an actual email going anywhere.
Building a fake by hand
Here's registration logic that depends on an email sender, with the dependency passed in instead of hardcoded, which is what makes it testable at all:
No network call, no real inbox, and a test that runs in milliseconds instead of waiting on an API. registerUser never knows the difference between the fake and a real sender, because both expose the same send(to, subject) shape. That's the part that makes this work: the function depends on an interface, not a concrete implementation, so swapping one for the other at test time is just passing a different argument.
The real tool: vi.fn and vi.spyOn
Hand-rolled fakes are great for teaching the idea, but Vitest gives you the same thing with less code. vi.fn() creates a function that records every call:
import { describe, it, expect, vi } from "vitest";
import { registerUser } from "./register.ts";
it("sends a welcome email on registration", () => {
const emailSender = { send: vi.fn() };
registerUser("aarav@example.com", emailSender);
expect(emailSender.send).toHaveBeenCalledWith("aarav@example.com", "Welcome!");
});vi.spyOn does something different: it wraps a real function so it still runs normally, but every call is recorded. That's what Linkstash's auth tests use to prove argon2.verify still runs even when no user matches, from the previous lesson:
const spy = vi.spyOn(argon2, "verify");
expect(await verifyUser(db, "nobody@example.com", "correct horse battery")).toBeNull();
expect(spy).toHaveBeenCalledTimes(1);
spy.mockRestore();Notice argon2.verify isn't replaced with a stub here. It genuinely runs, hashing a dummy value, and the spy just watches. That's the right tool for this job: the test cares that the real, slow work happened, not what it returned. A stub would have hidden the exact behavior the test exists to prove.
Always restore your spies
spy.mockRestore() puts the original function back. Skip it and the spy can leak into the next test in the same file, which is one of the more confusing kinds of test failure to debug, since the symptom shows up in a test that looks completely unrelated to the one that forgot to clean up.
The trap: mocking too much
Mock the thing your test is actually about, and you've written a test that can't fail no matter what you break. Imagine mocking applyDiscount itself inside a test that's supposed to check applyDiscount. It would pass forever, checking nothing. A good rule: mock the boundary (network, database, filesystem, clock), never the logic you're trying to verify. If you find yourself mocking three or four things just to get one test running, that's usually a sign the function under test is doing too much and could stand to be split up.
Previous: edge cases and the bugs you would have shipped. Next: testing async code without flakes.

Written by
Rhythm Bhiwani
Engineer and relentless builder, happiest reverse-engineering hard problems until they click.
Enjoyed this?
Tap the heart to leave some love.
Be the first to react
Comments
Join the conversation.
Loading comments…


