top of page

Mocking, Stubbing, and Faking: When to Use Each

  • Shawn West
  • Mar 12
  • 9 min read

Updated: Aug 10

The payment suite was green. Every test passed, had passed for months, and the team trusted it. Then a support ticket came in: a customer in the EU had been charged nothing, their order marked "paid," and the fulfillment queue had shipped the goods. The money never moved.

The engineer who dug in found the bug in an afternoon. Weeks earlier, the payment provider had changed its API: charge now expected the amount in integer cents, not dollars, and it enforced that the currency matched the card. The service code still passed dollars as a float and hard-coded "USD". In production, the real gateway rejected the call. In the test suite, a mock accepted it — because the mock had been written to expect exactly the call the buggy code made. The test wasn't checking whether the charge would succeed. It was checking that the code called the gateway the way the test author assumed it should. The author's assumption and the code's bug were the same assumption. So the test agreed with the bug and reported success.

This is the failure that "just mock it" invites, and avoiding it is the whole reason the words mock, stub, and fake are worth keeping distinct. They are not synonyms for "test double." They verify different things, and the wrong choice can pass while your system fails. Sorting them out is a foundational move — the kind of thing unit testing best practices assumes you already have straight.

Why "mock everything" feels right

When a class reaches out to a database, a payment provider, or an email service, the real dependency is slow, flaky, or has side effects you cannot have in a test. The obvious fix is to replace it with a stand-in. Most frameworks hand you one tool for this — unittest.mock, jest.fn(), Mockito — and it is easy to use, so it becomes the answer to every dependency. Replace the collaborator, assert it was called, move on.

It feels rigorous because the test is specific: it names the method, the arguments, the return value. But specificity about the call is not the same as confidence about the result. A mock encodes your belief about how the dependency behaves. If that belief is wrong — or goes stale when the dependency changes — the mock keeps agreeing with you, and the test stays green while reality diverges.

Before you reach for a double this week, look at your most-mocked collaborator and ask: if that dependency changed its contract tomorrow, would any test in this suite turn red? If the answer is no, your doubles are encoding assumptions nobody is checking.

What each double actually verifies

The three doubles differ along one axis: do they check what your code did to the collaborator, or what your code produced as a result?

  • A stub returns canned answers so your code can run. It has no opinion about how it was called. It exists to supply a value — find(id) returns a User — so the test can go on to check the outcome.

  • A mock records how it was called and lets you assert on that. The assertion — "you called charge once, with these arguments" — is the check. The call is treated as the contract.

  • A fake is a real, working implementation that is simpler than production — an in-memory version of the dependency that actually does the thing, minus the cost. An in-memory repository that stores and queries objects in a dict; an email service that appends to a list instead of sending.

Two more round out the family and are worth naming so you stop misusing the word "mock" for them. A spy wraps the real object, delegating calls while recording them — useful when you need the real behavior and visibility into how it was used. A dummy is a placeholder that is never actually exercised; it just fills a required parameter so a signature is satisfied.

Write down, for the collaborator you are about to double, which of these you are reaching for and why. If you cannot say whether you need a return value (stub), a call assertion (mock), or working behavior (fake), you are not ready to pick the double yet — and that is the next section.

The one question that picks the double

Here is the discovery move, and it is the whole article in a sentence: does the interaction or the outcome define correctness here?

Some behavior is only observable as a call. When your code's job is to notify — send the email, publish the event, enqueue the job — there may be no return value and no state your code owns to inspect. The fact that the call happened, with the right payload, is the correctness condition. That is a mock's home: the interaction is the contract.

Most behavior, though, is defined by its result. Your code fetches a user and computes a greeting; charges a card and records a receipt; reads config and picks a branch. What matters is the output or the resulting state, not the sequence of calls that produced it. For those, you want a stub (if you just need a value to flow through) or a fake (if the dependency's own rules matter to the outcome) — and you specifically do not want to assert on the calls, because doing so nails the test to one particular implementation.

Run the question against a test you already have open: is the assertion at the bottom checking a returned value or a piece of state (outcome), or is it an assert_called_with (interaction)? If it is the latter, ask whether the interaction is genuinely the contract — a fire-and-forget notification — or whether you reached for a mock out of habit and coupled the test to how the code happens to be written today.

The same charge test, three ways

(Developed example — composite scenario.)

One service method, checkout, charges a card and returns a receipt. The real gateway's contract: charge takes amount as integer minor units (cents), a currency that must match card.currency, and it raises GatewayError otherwise. Here is the code — and it has the two bugs from the opening: it passes dollars as a float, and it hard-codes the currency.

def checkout(order, gateway):
    card = order.card
    # BUG 1: dollars as float, not integer cents
    # BUG 2: currency hard-coded, ignores the card's currency
    receipt = gateway.charge(amount=order.total, currency="USD", card=card)
    order.mark_paid(receipt.id)
    return receipt

For an EU order, order.total is 42.0 (dollars) and card.currency is "EUR". The real gateway would reject this twice over. Watch what each double does with it.

Stub — supplies a value, checks the outcome. The stub returns a canned receipt for any arguments. The test asserts on the result: the order ends up paid.

class StubGateway:
    def charge(self, amount, currency, card):
        return Receipt(id="rcpt_123")   # canned, ignores arguments

def test_checkout_marks_order_paid():
    order = make_order(total=42.0, currency="EUR")
    receipt = checkout(order, StubGateway())
    assert order.is_paid           # outcome check
    assert receipt.id == "rcpt_123"

This passes. It is not lying — it verifies the outcome logic (mark the order paid, return the receipt) honestly. But the stub has no opinion about the gateway's rules, so it is silent on the two bugs. It tells you the code around the call is wired correctly, and nothing about whether the call would be accepted.

Mock — asserts the interaction. The mock verifies the call. The trap is that the assertion is written to match what the code does, and the test author held the same wrong belief as the code.

def test_checkout_charges_the_card():
    order = make_order(total=42.0, currency="EUR")
    gateway = Mock()
    gateway.charge.return_value = Receipt(id="rcpt_123")

    checkout(order, gateway)

    # The assertion mirrors the buggy call exactly.
    gateway.charge.assert_called_once_with(
        amount=42.0, currency="USD", card=order.card
    )

This passes too — and it is the dangerous green. The mock confirms the code called charge the way the test expected, which is precisely the way the real gateway now rejects. The interaction was verified against a contract the test author guessed at, not the one the provider actually enforces. The mock encodes the assumption; it cannot catch a bug that lives inside that assumption.

Fake — a working implementation with the real rules. The fake enforces the gateway's actual contract: integer cents, matching currency, or it raises.

class FakeGateway:
    def __init__(self):
        self.charges = []

    def charge(self, amount, currency, card):
        if not isinstance(amount, int):
            raise GatewayError("amount must be integer minor units (cents)")
        if currency != card.currency:
            raise GatewayError(f"currency {currency} != card {card.currency}")
        self.charges.append((amount, currency, card))
        return Receipt(id=f"rcpt_{len(self.charges)}")

def test_checkout_against_real_rules():
    order = make_order(total=42.0, currency="EUR")
    with pytest.raises(GatewayError):
        checkout(order, FakeGateway())   # FAILS today — bug caught

This one goes red, in CI, before the EU customer ever gets a free order. The fake fails for both reasons — the float amount and the currency mismatch — because it actually applies the gateway's rules instead of assuming them away. Fix the code to pass int(order.total_cents) and card.currency, and the fake goes green for the right reason: the call it recorded is one the real gateway would accept.

The lesson is not "fakes good, mocks bad." It is that the stub checked the outcome and stayed neutral on the contract; the mock checked the interaction and inherited the author's blind spot; the fake checked the outcome through the real rules and caught what the other two could not. When the gateway's own behavior determines correctness, only the double that reproduces that behavior can protect you — which is also why a broken contract like this is exactly what integration testing exists to catch when a fake is not faithful enough.

Build a fake for your one riskiest external contract this sprint and point an existing "mock everything" test at it. If it goes red, you just found a bug your green suite was hiding.

Choosing at a glance

Double

Verifies

Best when

Failure mode

Stub

An outcome, using a canned return value

You need a value to flow through so you can assert on the result

Silent on the dependency's real rules; passes a call the real thing would reject

Mock

The interaction (call + arguments)

The call itself is the contract — fire-and-forget notify, publish, enqueue

Encodes your assumption about the dependency; goes stale when the contract changes and stays green

Fake

An outcome, through a real simplified implementation

The dependency's own behavior shapes the result and a stub is too naive

Fidelity drift — the fake and production disagree over time unless kept honest

Spy

A real call, while still recording it

You need genuine behavior and visibility into how it was used

Slower and more coupled to reality; easy to over-assert on incidental calls

Keep this next to your test file and, for each double you add, name the row you are in and the failure mode you are accepting. If you cannot state the failure mode, you have not chosen deliberately.

Where mocks are the right call

Mocks earn their place when the interaction genuinely is the observable behavior. A test that your code publishes an OrderShipped event to the message bus, with the correct order ID, is a legitimate mock: there is no return value and no local state to inspect: the emitted call is the entire contract, and asserting on it is asserting on what the code is for.

The trouble starts when mocks are used for behavior that is really defined by an outcome. Then every assertion about arguments and call order welds the test to today's implementation. Refactor the code — same inputs, same result, different internal calls — and the mock-heavy test breaks even though nothing is actually wrong. That is the coupling tax: tests that fail on change rather than on defects, which trains the team to stop trusting red.

A useful default preference order, cheapest-and-safest first: the real implementation when it is fast and side-effect-free; a fake when the real thing is slow, external, or has side effects but its behavior matters; a stub when you only need a value to reach the assertion; a mock when the interaction truly is the contract; a spy when you need real behavior plus visibility. Reserve the last two for cases you can defend, not as the reflex.

Audit one test file this week: count the assert_called lines. If they outnumber the assertions on returned values and state, your suite is testing how the code is written, not what it does — and this is one of the hard parts of testing worth budgeting real time for.

A method you can apply per collaborator

When you are about to double a dependency, run four steps:

  1. State the correctness condition. Write one sentence: "This test is correct if ___." If the blank is a returned value or resulting state, you are in outcome territory (stub or fake). If it is "the collaborator was called like this," you are in interaction territory (mock) — and pause to confirm the interaction is the real contract.

  2. Pick the cheapest faithful double. Walk the preference order — real, fake, stub, mock, spy — and stop at the first one that can actually express the correctness condition without lying about the dependency's rules.

  3. If you chose a fake, pin its fidelity. Write one integration or contract test against the real dependency that asserts the same rule your fake enforces (integer cents, currency match). That test is what keeps the fake honest as the provider changes.

  4. If you chose a mock, name what would make it stale. Note the assumption the mock encodes. When the dependency's contract could drift under you, that note tells the next person where to look — and tells you whether you should have used a fake instead.

The output is a deliberate double with a written reason, not a reflexive Mock(). That is the difference between a suite that catches the EU-order bug and one that ships it green. If you are deciding how many of each kind of test to own in the first place, that is the subject of the test pyramid versus the test trophy.

Sources

The payment-suite incident and the checkout walkthrough are a composite scenario — assembled from common patterns in test-double misuse, not a report of a specific company or product. The behavior described (a mock passing while the real gateway rejects an updated contract) is illustrative of how interaction-based assertions encode stale assumptions; the vocabulary of stub, mock, fake, spy, and dummy follows the widely used taxonomy popularized by Gerard Meszaros in xUnit Test Patterns and refined in Martin Fowler's writing on test doubles. No statistics are cited because none are needed for the argument.

Keep learning. This article is part of the Software Testing Foundations path in the ShiftQuality Learning Center.

bottom of page