Mocks, stubs, and fakes: the differences that matter
Dummy, stub, fake, mock, and spy are not synonyms. Understand the real divide between them, state vs behavior verification, and when to use each test double without falling into over-mocking.
You open the pull request, CI was green yesterday, and today it's red. Nobody touched the code. The test that broke calls the real payment API, and the gateway was down for two minutes. On the same codebase, a teammate is complaining that "I mocked everything and the test passes but tests nothing" — they rewrote the function internally and not a single test objected. Both problems share one root: confusion about which kind of test double to use, and when.
This article separates dummy, stub, fake, mock, and spy, shows the real divide between them (state verification vs behavior verification), and takes a firm position on when to reach for each.
The umbrella term: test double
"Test double" is the term coined by Gerard Meszaros and popularized by Martin Fowler for any object you put in place of a real dependency during a test. The analogy is the movie stunt double: someone standing in for the actor in the dangerous scene. Mock, stub, fake — they're all test doubles. The trouble is that in day-to-day talk everyone calls everything a "mock," and the conversation turns to noise.
It's worth pinning the five types before discussing when to use each. The order below runs from dumbest to smartest.
Dummy: just fills a parameter
The dummy is the simplest. It exists only to satisfy a method signature — you have to pass something, but that argument is never used on the path the test exercises.
// a dummy logger that does nothing
const dummyLogger = { log() {} };
service.process(order, dummyLogger);
If process never logs in this scenario, the dummyLogger is there purely to compile. You verify nothing on it. In practice null sometimes does the job, but in languages strict about types an explicit dummy reads cleaner.
Stub: returns canned answers
The stub is a test double that returns fixed values (canned answers) for the calls made during the test. It feeds the object under test with controlled data, and that's it. You don't ask whether it was called — you just use what it returns.
// repository stub: always returns the same user
const userRepoStub = {
findById: () => ({ id: 7, plan: "free" }),
};
const result = checkout.run(userRepoStub);
expect(result.discount).toBe(0); // verifies the STATE
Notice the assert: it looks at the result (result.discount), not at the interaction with the stub. That's state verification — you ask "given this input, what's the output state?". The stub is perfect for forcing paths: a user with no plan, an empty list, an API error response. You program the answer and observe what the system does with it.
Fake: a working lightweight implementation
The fake is a test double with real logic — a functional implementation, just simplified and unfit for production. The classic example is the in-memory repository: it keeps records in an array or a Map instead of hitting Postgres, but behaves like a real repository. You insert, query, delete, and it answers coherently.
class InMemoryUserRepo {
#users = new Map();
save(u) { this.#users.set(u.id, u); }
findById(id) { return this.#users.get(id) ?? null; }
}
The difference from a stub: the stub always returns the same thing, with no internal state; the fake has real behavior, with state that evolves. Save first, query later, and the fake returns what you saved. That makes it great for testing flows with several chained operations without spinning up a database. Verification is still about state: you act, then inspect.
Mock: pre-programmed with expectations
Here the philosophy flips. The mock is an object pre-programmed with expectations about the calls it should receive. It isn't there merely to return data — it's there to verify it was used the right way. If the expected call doesn't happen, or happens with the wrong arguments, the test fails.
const mailerMock = createMock();
mailerMock.expects("send")
.once()
.with({ to: "ana@x.com", template: "welcome" });
signup.register({ email: "ana@x.com" });
mailerMock.verify(); // fails if "send" wasn't called correctly
That verify() at the end is the mock's signature move. You don't inspect the system's state — you assert that a specific interaction happened. That's behavior verification: "did the object under test talk to the dependency in the agreed way?". Useful when the code's effect is the external call (send an email, publish an event, charge a card) and there's no meaningful return state to inspect.
Spy: records the calls
The spy sits in between. It records what happened — how many times it was called, with which arguments — and lets you assert that afterward, in the body of the test, rather than pre-programming expectations like the mock does.
const spy = createSpy();
notifier.onError = spy;
run();
expect(spy.calls.length).toBe(1);
expect(spy.calls[0].args[0]).toMatch(/timeout/);
Many frameworks blend spy and mock into the same object (jest.fn(), Python's unittest.mock Mock), which is why the boundary blurs in practice. Conceptually: a spy observes and you assert later; a mock comes with the expectation baked in and enforces it at verify.
The real divide: state vs behavior
Forget the five names for a second. There are only two schools of verification:
- State verification (stub, fake): you run the action and inspect the result or the final state. It doesn't matter how the object got there, it matters where it landed.
- Behavior verification (mock, spy): you assert that certain calls happened, with certain arguments, in a certain order. It matters how the object interacted with its dependencies.
That's the only distinction that changes the shape of your test. Dummy, stub, and fake serve state verification. Mock and spy serve behavior verification. Memorizing the names is useless if you don't first decide which kind of verification that test needs. If you're still unsure what each test should be asserting, this article's sibling, unit tests: what to actually test, attacks exactly that.
The over-mocking trap
This is where most of the pain lives. When you mock every collaborator and assert every call, the test becomes a mirror of the implementation. Swapped the order of two harmless calls? Red test. Extracted a private method? Red test. You didn't test behavior — you froze the code's internal structure. That's the test coupled to the implementation: brittle, expensive to maintain, and giving a false sense of coverage. The teammate who "mocked everything and the test tests nothing" fell into exactly this.
The classic symptom: you refactor without changing observable behavior and half the suite breaks. A good test breaks when behavior changes, not when structure changes.
How do I know I'm over-mocking?
Look at the asserts. If most of them are verify(call X happened) and almost none look at a result or state, you're tied to the implementation. Try rewriting the test to assert only the observable result — if you can't, maybe that dependency should be a fake, not a mock.
Mock or stub for this dependency?
Ask: is the effect that matters the return value (then stub/fake + state verification) or the call itself (then mock/spy + behavior verification)? Sending an email matters only because the call happens — a mock fits. Computing a discount matters for the number that comes out — a stub fits.
Can I just mock the third-party library directly?
You can, but avoid it. Mocking Stripe's SDK or the HTTP client ties you to their shape, which you don't control and which changes without warning. When they change, your mock stays green and lies. Wrap the dependency in your own adapter and fake/stub the adapter instead.
My opinion after plenty of red CI
Prefer fakes and stubs. They test observable behavior and survive refactors. Use strict mocks sparingly — only when the interaction is the behavior (side effects with no return: publish event, send notification, charge). And don't mock what you don't own: wrap the external API in an adapter and double the adapter, never the third-party lib directly.
To build a fake or stub worth anything, you need realistic sample data — generating a mock JSON with the right shape speeds that part up (mock JSON generator).
Takeaway
Test double is the umbrella; under it, the dummy fills, the stub answers, the fake works, the mock enforces, and the spy observes. What actually decides the test's design is the choice between state verification (stub/fake) and behavior verification (mock/spy) — pick that first, the name comes second. When in doubt, reach for a fake or a stub, save strict mocks for side effects, and never mock a dependency you don't control without wrapping it first.
- 01 How to investigate low disk space on Linux A diagnostic method to find out why the disk is full: df to find the mount, du for the directory, and the confusing cases like inodes and deleted files.
- 02 How to Safely Undo Changes in Git: reset, revert, restore, and reflog Which Git command to use to undo changes in each situation — reset, revert, restore, stash, and reflog — without losing your work.