Mocks vs. stubs vs. fakes vs. spies — what's actually different, and when do you reach for each?
The four types of test double split cleanly into two families — those that control input and those that verify interactions — and confusing them is the most common cause of brittle tests.
LEARN BY DOINGPut your intuition to work3 steps
Mocks vs. stubs vs. fakes vs. spies — what's actually different, and when do you reach for each?
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.#Why the Vocabulary Actually Matters
"Just mock it" is the phrase that causes the most confusion in this space, because in casual usage "mock" gets used for every kind of test double. The distinction matters because each type answers a different question, and reaching for the wrong one is a common, specific source of brittle or misleading tests. The classic taxonomy — coined by Gerard Meszaros and popularized further by Martin Fowler — draws a hard line between doubles that let you control input (stubs, fakes) and doubles that let you verify interactions (mocks, spies).
#Four Types, One Dependency
All four examples below double the same dependency: a PaymentGateway used by an OrderService.checkout() method.
interface PaymentGateway {charge(amount: number, cardToken: string): Promise<{ id: string; status: 'succeeded' | 'declined' }>;}
#Stub — Feeds a Canned Answer, No Behavior Verification
A stub exists purely to make the test possible by returning a fixed value; you never assert anything about how it was called.
test('checkout marks the order as PAID when the gateway succeeds (stub)', async () => {const stubGateway: PaymentGateway = {charge: async () => ({ id: 'ch_1', status: 'succeeded' }),};const orderService = new OrderService(stubGateway);const result = await orderService.checkout('order-1', 4999, 'tok_visa');expect(result.status).toBe('PAID');// We never assert *how* charge was called — the stub just feeds a canned answer.});
#Mock — Pre-Programmed With Expectations About How It Will Be Called
A mock is used specifically to verify an interaction — that a call happened, with what arguments, how many times.
test('checkout charges the exact order total exactly once (mock)', async () => {const mockCharge = jest.fn().mockResolvedValue({ id: 'ch_2', status: 'succeeded' });const mockGateway: PaymentGateway = { charge: mockCharge };const orderService = new OrderService(mockGateway);await orderService.checkout('order-2', 4999, 'tok_visa');expect(mockCharge).toHaveBeenCalledTimes(1);expect(mockCharge).toHaveBeenCalledWith(4999, 'tok_visa');});
#Fake — A Real, Working Implementation That's Unfit for Production
A fake isn't a canned answer — it's actual working logic, usually a simplified in-memory version of the real thing.
class InMemoryPaymentGateway implements PaymentGateway {private ledger: { id: string; amount: number; cardToken: string }[] = [];private nextId = 1;async charge(amount: number, cardToken: string) {if (cardToken === 'tok_chargeDeclined') {return { id: `ch_${this.nextId++}`, status: 'declined' as const };}const id = `ch_${this.nextId++}`;this.ledger.push({ id, amount, cardToken });return { id, status: 'succeeded' as const };}totalCharged(): number {return this.ledger.reduce((sum, entry) => sum + entry.amount, 0);}}test('two successful checkouts accumulate the correct ledger total (fake)', async () => {const fakeGateway = new InMemoryPaymentGateway();const orderService = new OrderService(fakeGateway);await orderService.checkout('order-3', 4999, 'tok_visa');await orderService.checkout('order-4', 1500, 'tok_visa');expect(fakeGateway.totalCharged()).toBe(6499);});test('a declined card leaves the ledger untouched (fake)', async () => {const fakeGateway = new InMemoryPaymentGateway();const orderService = new OrderService(fakeGateway);await expect(orderService.checkout('order-5', 2000, 'tok_chargeDeclined')).rejects.toThrow('payment declined');expect(fakeGateway.totalCharged()).toBe(0);});
Notice the fake earns its keep here in a way stubs and mocks can't: it has real, stateful behavior, so it can express scenarios like "a second charge should see the first one's effect" without hand-coding every possible return value in the test itself.
#Spy — Wraps a Real Object and Records How It Was Used
A spy is a mock's cousin: it also lets you assert on calls, but it wraps a real implementation instead of replacing it, so the real behavior still runs.
test('checkout invokes the real gateway with the right arguments (spy)', async () => {const realGateway = new InMemoryPaymentGateway();const chargeSpy = jest.spyOn(realGateway, 'charge');const orderService = new OrderService(realGateway);await orderService.checkout('order-6', 2000, 'tok_visa');expect(chargeSpy).toHaveBeenCalledWith(2000, 'tok_visa');expect(realGateway.totalCharged()).toBeGreaterThan(0); // the real logic still ran});
#The Decision Table
| Type | Controls return value? | Verifies interactions? | Runs real logic? | Reach for it when... |
|---|---|---|---|---|
| Stub | Yes, fixed | No | No | You just need a dependency to return something so the code under test can proceed |
| Mock | Yes, fixed | Yes | No | The fact that a call happened, with specific arguments, is the thing under test |
| Fake | No — computed | No (usually) | Yes, simplified | You need stateful, realistic behavior across multiple calls without a real infrastructure dependency |
| Spy | No — real | Yes | Yes | You want to assert on how a real object was used without changing its behavior |
#The Trap: Over-Mocking Couples Tests to Implementation
🧠 Check your understanding
0/2 · 0/2 answered1. You want to assert that OrderService called the email sender exactly once with the customer's address, and there's no other observable side effect to check. Which test double type are you relying on?
2. Which test double type is most likely to make a test suite brittle if it's overused, because tests end up coupled to implementation details rather than outcomes?