Backend / Testing / pytest / 09_test_doubles_taxonomy.md

Test doubles taxonomy

Updated 4 min read source
On this page11
  1. The five kinds
  2. Dummy
  3. Stub
  4. Spy
  5. Mock (in the strict sense)
  6. Fake
  7. “Don’t mock what you don’t own”
  8. Contract tests
  9. testcontainers — real dependencies in CI
  10. When to use which
  11. Interview angle

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.

python
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.

python
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.

python
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.

python
# 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.

python
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:

python
@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.

python
# 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.

python
@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:

python
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 4

  • 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 Mock, MagicMock, patch, Mocking external APIs.