Test Doubles: Mock, Stub, Spy, Fake - the Real Differences


“Just mock it” is the default response to any testing difficulty. The database is slow? Mock it. The external API is unreliable? Mock it. The side effect is hard to verify? Mock it.

This advice isn’t wrong, but it’s incomplete. “Mock” is often used as a catch-all term for any test replacement, when in fact there are several distinct types of test doubles with different behaviors and appropriate uses. Using the wrong type - or using any type unnecessarily - leads to tests that pass while the code is broken.

The terminology comes from Gerard Meszaros’ book “xUnit Test Patterns.” It’s worth understanding the distinctions.

Dummy

The simplest kind. An object that’s passed around but never actually used. It’s needed to satisfy a parameter requirement but plays no role in the test.

def send_notification(user: User, message: str, logger: Logger) -> None:
    logger.info(f"Sending to {user.email}")
    # ... send logic

def test_send_notification_formats_message():
    user = User(email="test@example.com")
    dummy_logger = Mock()  # we don't care about logging in this test

    # We're testing the message format, not logging behavior
    result = send_notification(user, "Hello!", dummy_logger)
    assert result.body == "Hello, test@example.com!"

A dummy exists only to fill a required parameter. It’s not configured to return anything useful and you don’t assert on it.

Stub

A stub provides canned responses to calls made during the test. It returns specific values that drive the unit under test toward the behavior you want to test.

def get_user_discount(user_id: int, repo: UserRepository) -> float:
    user = repo.find(user_id)
    if user.tier == "premium":
        return 0.20
    return 0.0

def test_premium_user_gets_20_percent_discount():
    stub_repo = Mock()
    stub_repo.find.return_value = User(id=1, tier="premium")  # <- stub behavior

    discount = get_user_discount(1, stub_repo)

    assert discount == 0.20

The stub controls what repo.find() returns, letting you test the “premium user” branch without a real database. You typically don’t assert that stub_repo.find was called - you only care about the output.

Mock

A mock is a stub with behavioral expectations. You set up expectations on how it should be called, and the mock verifies those expectations were met.

def notify_on_low_stock(product_id: int, repo: ProductRepository, notifier: Notifier):
    product = repo.find(product_id)
    if product.stock < 10:
        notifier.send_alert(f"Low stock: {product.name}")

def test_sends_alert_when_stock_below_10():
    stub_repo = Mock()
    stub_repo.find.return_value = Product(id=1, name="Widget", stock=5)

    mock_notifier = Mock()

    notify_on_low_stock(1, stub_repo, mock_notifier)

    # Assert the mock was called correctly
    mock_notifier.send_alert.assert_called_once_with("Low stock: Widget")

The distinction: with a stub, you care about what the unit returns. With a mock, you care about what calls it makes to its dependencies.

The danger with mocks: you’re testing that your code calls a specific method with specific arguments. This couples the test to implementation details. If you rename send_alert to send_notification, the test breaks even if the behavior is the same. Over time, tests full of mock assertions become an obstacle to refactoring.

Use mocks when you genuinely need to verify that a side effect happened - an email was sent, a message was published, a service was called. Don’t use them to verify internal implementation steps that don’t represent observable behavior.

Spy

A spy is like a mock but less strict. Instead of setting expectations upfront, it records what happened so you can verify afterward. In Python’s unittest.mock, Mock objects are actually spies by default - they record calls whether or not you set expectations.

The conceptual difference: a strict mock fails immediately if called in a way that wasn’t expected. A spy lets everything through and you query it afterward.

# Spy pattern: record calls, verify after
spy_notifier = MagicMock()

notify_on_low_stock(1, stub_repo, spy_notifier)

# Check what was recorded
print(spy_notifier.send_alert.call_args_list)
# Verify selectively
assert spy_notifier.send_alert.called
assert "Widget" in spy_notifier.send_alert.call_args[0][0]

Fake

A fake is the most valuable and underused test double. It’s a real working implementation, but simplified for testing. Not a configured mock - actual logic.

The canonical example is an in-memory database:

class InMemoryUserRepository:
    def __init__(self):
        self._users: dict[int, User] = {}

    def find(self, user_id: int) -> User | None:
        return self._users.get(user_id)

    def save(self, user: User) -> User:
        self._users[user.id] = user
        return user

    def find_all(self) -> list[User]:
        return list(self._users.values())

Tests using this fake run real application logic against a real implementation. No mock configuration needed. No “did this exact method get called with these exact arguments.” The test says “when I save a user and then find them, I get back the same user” - and it’s testing actual repository behavior.

Fakes require maintenance: they have to stay in sync with the real interface. But they produce tests that survive refactoring and catch real logic bugs.

Another common fake: an in-memory cache instead of Redis, an in-memory message bus instead of Kafka, a fake SMTP server instead of a real email provider.

When to Use Each

SituationUse
Parameter required but unusedDummy
Need specific input to test a branchStub
Need to verify a side effect happenedMock (sparingly)
Need to inspect calls after the factSpy
Need real behavior without external dependenciesFake

The over-mocking smell: if your test setup is longer than your test assertion, and most of the setup is configuring mock return values and expectations, the test is probably mocking too much. Consider whether a fake or a real (but lighter) implementation would be cleaner.

The Boundary Question

The best rule for when to use test doubles: use them at architectural boundaries - the seam between your application code and external systems (database, email, HTTP APIs, message queues). Don’t use them to replace internal components within your application logic.

If you’re mocking a service that calls a repository that queries a database, you’ve mocked too deeply. Use a fake repository, use a real service, use a real test database. The test will be slower but it’ll catch real integration bugs instead of verifying that your mock returns what you configured it to return.

Test doubles exist to keep tests fast, deterministic, and independent of infrastructure. They don’t exist to avoid the difficulty of testing real behavior.



Read more