A test for place_order needs a payment gateway, an SMS client, and a tax calculator. Wire in the real ones and every test run charges a card and texts a phone number. A unit test is supposed to run in milliseconds with no network, so all three have to be swapped for something else. That “something else” comes in five kinds, each solving a different problem, and treating them as interchangeable is why “just mock it” arguments in code review go in circles.

What is a test double

Gerard Meszaros coined “test double” in xUnit Test Patterns as the umbrella term for anything that stands in for a real dependency during a test, the way a stunt double stands in for an actor. Film crews use different doubles for different shots: one only has to stand in the background with the right haircut, another actually does the car crash. Test doubles split the same way. Dummy, stub, spy, fake, and mock are five different jobs, and what separates them is what the test checks, not how elaborate the substitute object is.

Here is the class every example below tests. Three of its dependencies are worth replacing; the fourth turns out not to matter at all:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
class PaymentDeclined(Exception):
    pass


class OrderProcessor:
    def __init__(self, gateway, sms_client, tax_calculator, logger):
        self.gateway = gateway
        self.sms_client = sms_client
        self.tax_calculator = tax_calculator
        self.logger = logger

    def place_order(self, customer_phone, subtotal):
        total = self.tax_calculator.total_with_tax(subtotal)
        try:
            charge_id = self.gateway.charge(total)
        except PaymentDeclined:
            self.logger.warning("payment declined for %s", customer_phone)
            raise
        self.sms_client.send(customer_phone, f"Order confirmed, charged ${total:.2f}")
        return charge_id

logger only runs on the error path. That matters for the first example.

Two small stand-ins get reused across the next few tests, so they are defined once here:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
from unittest.mock import Mock, create_autospec


class StubGateway:
    def __init__(self, charge_id):
        self.charge_id = charge_id

    def charge(self, amount):
        return self.charge_id


class StubTaxCalculator:
    def __init__(self, total):
        self.total = total

    def total_with_tax(self, subtotal):
        return self.total

What is a dummy object in testing

A dummy is passed in only to satisfy a parameter the test never exercises. It does nothing and checks nothing. The happy-path test below never reaches the exception branch, so logger can be anything that survives being assigned to an attribute:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
def test_place_order_returns_the_charge_id():
    dummy_logger = object()  # never called on this path
    processor = OrderProcessor(
        gateway=StubGateway(charge_id="ch_1"),
        sms_client=Mock(),
        tax_calculator=StubTaxCalculator(total=108.00),
        logger=dummy_logger,
    )

    charge_id = processor.place_order("+15551234567", subtotal=100.00)

    assert charge_id == "ch_1"

object() works because nothing calls a method on it. If a later change made place_order log on the success path too, this test would fail with an AttributeError. That failure is useful: it tells you the dummy stopped being a dummy.

What is a stub in unit testing

A stub returns a canned answer so the rest of the test stays deterministic. The real tax calculator might call a tax-rate API. The test doesn’t want that, so StubTaxCalculator returns 108.00 whatever subtotal it receives. You’ve already seen it at work in the dummy test above, along with StubGateway doing the same job for charge.

Nothing checks whether total_with_tax was called, or with what argument. A stub feeds the code under test a controlled input; it never verifies an interaction. That is the line between a stub and the next three doubles.

What is a test spy

A spy records the calls made to it so the test can inspect them after the code under test has run. unittest.mock.Mock records every call automatically, so most Python suites use it as a spy even when the word never appears in the test:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
def test_place_order_sends_a_confirmation_sms():
    sms_spy = Mock()
    processor = OrderProcessor(
        gateway=StubGateway(charge_id="ch_1"),
        sms_client=sms_spy,
        tax_calculator=StubTaxCalculator(total=108.00),
        logger=object(),
    )

    processor.place_order("+15551234567", subtotal=100.00)

    sms_spy.send.assert_called_once_with(
        "+15551234567", "Order confirmed, charged $108.00"
    )

Nothing was configured on sms_spy before the call. It accepted whatever it was given, recorded it, and the test questioned it afterward.

What is a fake object in testing

A stub can’t tell you whether two charges in a row get two different IDs, because it only knows one value. When a test needs behavior across several calls, you want a fake: a small implementation that actually works.

1
2
3
4
5
6
7
8
class FakeGateway:
    def __init__(self):
        self.charges = []

    def charge(self, amount):
        charge_id = f"ch_{len(self.charges) + 1}"
        self.charges.append((charge_id, amount))
        return charge_id
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
def test_two_orders_get_two_different_charge_ids():
    gateway = FakeGateway()
    processor = OrderProcessor(
        gateway=gateway,
        sms_client=Mock(),
        tax_calculator=StubTaxCalculator(total=108.00),
        logger=object(),
    )

    first_id = processor.place_order("+15551234567", subtotal=100.00)
    second_id = processor.place_order("+15557654321", subtotal=100.00)

    assert first_id != second_id
    assert gateway.charges == [(first_id, 108.00), (second_id, 108.00)]

FakeGateway has no network call and no payment processor behind it, but it keeps state the way the real one does. The test needed exactly that state, and a stub could not provide it.

What is a mock object

In Meszaros’s definition, a mock carries its expectations into the test: the rules for a valid call are fixed before the code runs, and a call that breaks them fails on the spot. unittest.mock is looser than that, since you still assert afterward, but create_autospec gets you the important half. It builds the double from a real class, so every call is checked against the real method signatures as it happens:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
class Gateway:
    def charge(self, amount: float) -> str:
        raise NotImplementedError


def test_place_order_charges_the_amount_after_tax():
    gateway = create_autospec(Gateway, instance=True)
    gateway.charge.return_value = "ch_1"
    processor = OrderProcessor(
        gateway=gateway,
        sms_client=Mock(),
        tax_calculator=StubTaxCalculator(total=108.00),
        logger=object(),
    )

    processor.place_order("+15551234567", subtotal=100.00)

    gateway.charge.assert_called_once_with(108.00)

Change place_order to call self.gateway.charge(total, currency="usd") and this test fails with a TypeError inside place_order, because Gateway.charge takes no currency argument. Now picture a plain Mock() in the same spot, with someone updating the assertion to match the new call. The test passes, and the code breaks the first time it meets the real gateway. A spy confirms that a call happened. An autospecced mock also confirms the call is one the real object would accept.

Mock vs stub vs spy vs fake: comparison table

DoubleWhat it doesReach for it when
DummyPassed in to satisfy a parameter; never calledA dependency exists only because the signature requires it on this path
StubReturns one canned answer no matter the inputThe test needs a fixed, deterministic value out of a dependency
SpyRecords calls so the test can inspect them afterwardThe test needs to confirm an interaction happened, without configuring it first
FakeA smaller working implementation of the real thingThe test needs real behavior across multiple calls, not one canned value
MockBuilt with expectations about valid calls, checked as calls happenThe test needs to verify the exact shape of a call: arguments, count, order

gateway was a stub, a fake, and a mock in three tests of the same class. The double follows what the test checks, not the dependency.

When test doubles make tests worse

Mocking every collaborator couples the test to how the code is written instead of what it does. Add a test asserting that total_with_tax is called exactly once, then refactor place_order to call it a second time for logging: the test breaks, and nothing a user cares about has changed. The fake test above has no such problem, because it asserts on the outcome (two distinct charge IDs, the right amounts recorded) instead of the call shape.

Mocking a library you don’t own has the same problem one level up. Autospec a third-party client directly and every test that touches it depends on that library’s method signatures; a minor upgrade that renames a parameter breaks tests across the suite. Wrap the library in a small interface of your own and double that instead. When the library changes, only the wrapper does.

Sometimes the right number of doubles is zero. If the real dependency is already fast and has no side effects, like a pure function or an in-memory SQLite connection, doubling it adds indirection and catches nothing. Doubles are for dependencies that are slow, remote, non-deterministic, or have side effects you don’t want in a test run.

How to choose the right test double

Start from what the test checks, not from what feels safest to fake. If you need a predictable input so a later assertion holds, stub it. To confirm the SMS went out, spy on it. If the dependency has to behave correctly across several calls, write a fake once and reuse it wherever that dependency appears. And when a caller passing the wrong arguments should fail the test before it fails in production, use an autospecced mock.

None of this works on a class that builds its own dependencies internally, because there is nowhere to hand a double in. That is the problem dependency injection solves, and it is the first thing to fix if a class resists every double in this article. Once the seam exists, decide which double belongs at which test level. If you work test-first, you’ll reach for doubles early, since the test exists before there is real code to call.