backend / testing / pytest / 09_test_doubles_taxonomy.md

Test doubles taxonomy

4 min read source

Test doubles taxonomy

“Mock” is colloquially used for anything fake in a test. Gerard Meszaros formalized five distinct kinds, each with its own purpose. Knowing which one you actually need clarifies the design.

The five kinds

Kind Purpose Example
Dummy Fills a parameter, never used User(audit_log=DummyLogger()) where audit isn’t called
Stub Returns canned answers repo.get.return_value = User(id=1)
Spy Stub + records calls for inspection mock.assert_called_with(...) after the test
Mock Pre-programmed with expectations; fails if violated expect(repo).to_receive(:save) (RSpec-style)
Fake Working implementation, simpler than real In-memory database, fakeredis

The Python unittest.mock library calls everything Mock regardless of role — confusing nomenclature but practical.

Dummy

A placeholder. The test path doesn’t actually invoke it.

class DummyLogger:
    def info(self, *args, **kwargs): pass
    def error(self, *args, **kwargs): pass

def test_user_signup():
    service = SignupService(logger=DummyLogger())
    user = service.signup("alice@example.com")
    assert user.email == "alice@example.com"

Use when a constructor demands an argument the test doesn’t care about. Real codebases often use Mock() instead — works, but loses the “this is intentionally never called” signal.

Stub

Returns canned values. No verification.

def test_get_user_returns_profile():
    repo = Mock()
    repo.find.return_value = User(id=1, name="alice")
    service = UserService(repo)

    profile = service.get_profile(1)

    assert profile.name == "alice"

The test’s interest is in service.get_profile’s output. The stub provides input. Don’t assert on repo.find.called — that’s testing the implementation, not the behavior.

Spy

Stub + recorded calls. The test asserts on what was called.

def test_save_audit_log():
    audit = Mock()
    service = OrderService(audit_log=audit)

    service.place_order(items=[...], user_id=42)

    audit.record.assert_called_once_with(
        event="order_placed", user_id=42, item_count=...
    )

The test cares that the audit log received the call. Spy = behavior verification.

Mock (in the strict sense)

Pre-programmed expectations: declare what calls are expected before running, and the framework fails if they don’t happen.

# Strict-mock style (jMock, rspec)
expect(repo).to_receive(:save).with_arg(user).once
service.create_user(user)
# Framework verifies on teardown

Python’s unittest.mock doesn’t enforce this strictly — you have to call assert_called_* manually. So “mock” in Python usually means “spy.”

Fake

A working implementation, simpler than the real one. Behaves correctly for the operations the test exercises.

class InMemoryUserRepo:
    def __init__(self):
        self._users = {}
    def save(self, user):
        self._users[user.id] = user
    def find(self, id):
        return self._users.get(id)

def test_create_and_find():
    repo = InMemoryUserRepo()
    service = UserService(repo)
    user = service.create("alice")
    assert service.get(user.id).name == "alice"

Fakes pay off when:

  • Multiple tests exercise the same component.
  • The interactions are complex (multiple calls, ordering matters).
  • The real dependency is slow or hard to set up (DB, network).

The cost: you have to maintain the fake’s correctness. When the real component changes, the fake can lie. Mitigate with contract tests (see below).

Famous fakes: fakeredis, moto (AWS), httpretty / responses (HTTP), SQLite-as-Postgres (sometimes risky — different SQL dialects).

“Don’t mock what you don’t own”

Rule of thumb attributed to Steve Freeman: only mock interfaces you control.

Bad:

@patch("requests.get")
def test_fetch(mock_get):
    mock_get.return_value.json.return_value = {"ok": True}
    ...

Why bad: requests is third-party. If it changes return semantics or adds a header you don’t anticipate, your mock disagrees with reality. Tests pass; production breaks.

Good: wrap third-party libs behind your own interface, then mock the interface.

# myapp/http.py
class HttpClient(Protocol):
    def get(self, url: str) -> dict: ...

class RealHttpClient:
    def get(self, url: str) -> dict:
        return requests.get(url).json()

# In tests:
class FakeHttpClient:
    def get(self, url: str) -> dict:
        return {"ok": True}

Now the mock matches an interface you defined. Changes to requests are localized to RealHttpClient.

Contract tests

If you use a fake to substitute for a real service, write a contract test that runs the same scenarios against both. If the real service changes, the contract test fails first — long before a bug ships.

@pytest.fixture(params=[InMemoryUserRepo, PostgresUserRepo])
def repo(request):
    return request.param()

def test_save_and_retrieve(repo):
    user = User(id=1, name="alice")
    repo.save(user)
    assert repo.find(1).name == "alice"

Run the test against both implementations.

testcontainers — real dependencies in CI

For when fakes are too lossy and you want a real dependency without managing containers manually:

from testcontainers.postgres import PostgresContainer

@pytest.fixture(scope="session")
def postgres():
    with PostgresContainer("postgres:16") as pg:
        yield pg.get_connection_url()

Pros: real Postgres, real driver, real SQL behavior. Cons: slower (container startup), needs Docker in CI.

Use for the small set of integration tests where SQL semantics matter (locks, isolation levels, dialect-specific features). Use fakes everywhere else.

When to use which

  • Pure unit test — stub or fake. Avoid spies unless behavior verification is the point of the test.
  • Component test of A’s interaction with B — spy on B (verify A calls B correctly).
  • Component test of A’s behavior — fake B (verify A’s output, not A’s calls).
  • Integration test — testcontainers, real DB.
  • End-to-end — real services, smoke-test only.

Interview angle

  • Q: “What’s the difference between a stub and a mock?” — stub returns values; mock verifies calls.
  • Q: “What’s a fake and when do you prefer it over a mock?” — working implementation; better for complex interaction tests, single source of truth.
  • Follow-up: “What’s the ‘don’t mock what you don’t own’ rule?” — wrap third-party libs in your own interface; mock the interface.
  • Follow-up: “How do you keep a fake honest?” — contract tests against the real implementation.

See 05_mock_vs_magicmock_patch.md, 01_mocks_external_apis.md.