Isolation & Contracts

Spies, Fakes & Hand-Rolled Test Doubles

unittest.mock is powerful enough that it becomes the answer to every isolation problem, and the cost only shows up later: a test file where fifteen lines configure return values, a suite that passes after an interface changes because nothing checked the mock still matched reality, and failures that report "expected call not found" without saying what the collaborator was supposed to do. A hand-written double solves those by encoding behaviour once, in one place, in ordinary Python.

Prerequisites

  • Python 3.9+ and pytest >= 8.0.
  • typing.Protocol for structural interfaces, plus mypy or pyright if the protocol is to be enforced statically.
  • Familiarity with unittest.mock's configuration API, since the comparison here assumes it: see deep dive into unittest.mock.
  • Code that accepts its collaborators rather than importing them, which is the precondition for substituting anything — see dependency injection for testability.

Core concept: five doubles, distinguished by what they do

The vocabulary is worth being precise about, because the choice between them is a design decision rather than a stylistic one.

A dummy is a value passed to satisfy a signature and never used. A stub returns canned answers with no logic. A spy records how it was called; a partial spy wraps a real object and delegates, so behaviour is preserved. A mock is a stub with expectations attached — it fails the test when the interaction does not match. A fake has a working implementation, simplified: an in-memory repository, a fake clock, a queue backed by a list.

Five kinds of test double by behaviour and assertion style Five cards arranged from least to most behaviour. A dummy has none and is never called. A stub returns canned values. A spy records calls and may delegate to a real object. A mock adds expectations that fail the test. A fake has a working simplified implementation and supports assertions on resulting state. More behaviour to the right; different assertions at each step dummy never called fills a signature assert: nothing object() will do stub canned answers no logic assert: on output Mock(return_value=…) spy records calls may delegate assert: on calls Mock(wraps=real) mock stub + expectations fails on mismatch assert: on protocol couples to call shape fake working, simplified in-memory state assert: on state survives refactors The further right, the more the test asserts on outcomes rather than on the sequence of calls that produced them.
Asserting on state rather than on interactions is what makes a test survive an internal refactor, which is the strongest practical argument for fakes over mocks.

Step-by-step implementation

1. Name the contract

Python
from typing import Protocol

from myapp.models import Invoice


class InvoiceRepository(Protocol):
    """What the billing service actually depends on — nothing more."""

    def get(self, invoice_id: str) -> Invoice | None: ...

    def save(self, invoice: Invoice) -> None: ...

    def list_open(self, *, limit: int = 100) -> list[Invoice]: ...

A Protocol rather than an abstract base class keeps the real implementation free of an inheritance relationship it does not need, and gives the type checker enough to reject a fake that drifts from the interface.

2. Implement the fake

Python
from myapp.models import Invoice
from myapp.errors import DuplicateInvoice


class FakeInvoiceRepository:
    """In-memory implementation with the same observable behaviour."""

    def __init__(self) -> None:
        self._items: dict[str, Invoice] = {}

    def get(self, invoice_id: str) -> Invoice | None:
        return self._items.get(invoice_id)

    def save(self, invoice: Invoice) -> None:
        existing = self._items.get(invoice.id)
        # The real repository has a unique constraint; the fake must too,
        # or tests will pass against behaviour production does not have.
        if existing is not None and existing.version != invoice.version:
            raise DuplicateInvoice(invoice.id)
        self._items[invoice.id] = invoice

    def list_open(self, *, limit: int = 100) -> list[Invoice]:
        # Same ordering guarantee as the SQL implementation: newest first.
        items = [i for i in self._items.values() if i.status == "open"]
        return sorted(items, key=lambda i: i.created_at, reverse=True)[:limit]

Reproducing the error cases is the part most fakes skip and the part that matters most. A fake that never raises DuplicateInvoice means every test of the duplicate-handling path is testing nothing.

3. Prove the fake agrees with the real thing

Python
import pytest


class RepositoryContract:
    """Behaviours every implementation must satisfy. Subclassed per impl."""

    def test_get_returns_none_for_unknown_id(self, repo):
        assert repo.get("nope") is None

    def test_saved_invoice_is_retrievable(self, repo, invoice):
        repo.save(invoice)
        assert repo.get(invoice.id) == invoice

    def test_conflicting_version_raises(self, repo, invoice):
        repo.save(invoice)
        with pytest.raises(DuplicateInvoice):
            repo.save(invoice.evolve(version=invoice.version + 1, id=invoice.id))

    def test_list_open_is_newest_first(self, repo, invoice_factory):
        older, newer = invoice_factory(days_ago=2), invoice_factory(days_ago=1)
        repo.save(older)
        repo.save(newer)
        assert [i.id for i in repo.list_open()] == [newer.id, older.id]


class TestFakeRepository(RepositoryContract):
    @pytest.fixture
    def repo(self):
        return FakeInvoiceRepository()


@pytest.mark.integration          # only this half needs a database
class TestSqlRepository(RepositoryContract):
    @pytest.fixture
    def repo(self, db_session):
        return SqlInvoiceRepository(db_session)

This is the mechanism that makes fakes safe. The contract suite runs twice: once in milliseconds against the fake on every push, once against the real repository in the integration stage. A divergence fails immediately and points at the exact behaviour, which is the guarantee a mock can never provide.

4. Use a spy where behaviour must be preserved

Python
from unittest.mock import Mock


def test_cache_is_consulted_before_the_repository(billing_service, repo, invoice):
    repo.save(invoice)
    # wraps: calls still reach the real object, and are also recorded.
    spy = Mock(wraps=repo)
    billing_service.repository = spy

    billing_service.get_invoice(invoice.id)
    billing_service.get_invoice(invoice.id)      # second call should hit the cache

    assert spy.get.call_count == 1               # behaviour unchanged, calls counted

wraps is the least intrusive double available: nothing about the system's behaviour changes, so any failure is about the interaction rather than about a stub returning the wrong thing.

Verification

A fake earns trust by failing when it should. Break it deliberately once — remove the DuplicateInvoice check — and confirm the contract suite goes red for the fake and stays green for the real repository. If both stay green, the contract test is not covering the behaviour the code depends on, which is a more valuable discovery than the fake itself.

Bash
pytest tests/contracts -q -k Fake              # milliseconds
pytest tests/contracts -q -k Sql -m integration # seconds, needs the container
Plain text
tests/contracts/test_repository.py::TestFakeRepository::test_conflicting_version_raises FAILED
tests/contracts/test_repository.py::TestSqlRepository::test_conflicting_version_raises PASSED

One red and one green is exactly the signal the arrangement exists to produce.

Troubleshooting

SymptomRoot causeFix
Tests pass; production breaks on the same pathFake lacks an error case the real one hasAdd it to the contract suite, then to the fake
Fake and real disagree on orderingImplicit SQL ordering never specifiedMake ordering part of the contract and assert it
Type checker accepts a drifted fakeNo Protocol, or fake not annotatedAnnotate the fake's use sites with the protocol type
Contract suite duplicated per implementationCopy-paste instead of a shared base classOne base class, one fixture per implementation
Spy changes behaviourMock() used instead of Mock(wraps=real)Wrap rather than replace
Fake grows a query languageIt is reimplementing the databaseMove those tests to the integration layer

When mocks are still the right answer

None of this is an argument against unittest.mock. Three situations favour it clearly.

One-off collaborators. A single test needs a function to raise ConnectionError; a Mock(side_effect=ConnectionError) says that in one line and nothing else in the suite cares. Writing a fake for it would be ceremony.

Asserting that something did not happen. mock.assert_not_called() has no clean fake equivalent, and "no email was sent" is a real requirement. A fake can grow a sent list to support it, but for a single assertion the mock is simpler.

Third-party interfaces you do not control and barely use. Faking a cloud SDK's client is a large job with little payoff if the code touches two methods. create_autospec on those two methods gives signature checking without the implementation — and the strictness argument for autospec over a bare MagicMock is made in autospec and strict mocking.

The dividing line is repetition. The first test that needs a collaborator gets a mock; the fourth test that needs the same collaborator configured the same way is telling you to write a fake.

Fakes that other people can use

A fake shipped alongside the real implementation is one of the highest-leverage things a library can provide, and the pattern is worth copying for internal packages too.

Python
# myapp/clients/__init__.py
from myapp.clients.http import HttpBillingClient
from myapp.clients.fake import FakeBillingClient   # shipped, tested, versioned

__all__ = ["HttpBillingClient", "FakeBillingClient"]

Shipping the fake means every consumer of the package gets a fast, correct double without writing one, and it means the fake is covered by the package's own contract tests rather than by each consumer's guesses. freezegun, moto, fakeredis and pyfakefs are all this idea applied at ecosystem scale — the last of which is covered in faking a whole filesystem with pyfakefs.

The obligation that comes with shipping a fake is versioning it in step. A behaviour added to the real client and not to the fake is a silent divergence for every downstream consumer, which is why the contract suite belongs in the package's own CI rather than in a consumer's.

One contract suite, two implementations, many consumers A package publishes both a real HTTP client and a fake client, with a single contract suite verifying both in the package's own pipeline. Three downstream consumers import the fake for their fast tests and the real client for production, so a divergence is caught once rather than by each consumer. The fake is part of the package's contract HttpBillingClient the real implementation FakeBillingClient shipped in the same wheel one contract suite runs in the package's CI checkout service tests reporting job tests admin tooling tests Three consumers, zero hand-written doubles, one place where divergence is caught.
Each consumer would otherwise write its own approximation of the client, and each approximation would be wrong in a different way.

Fakes for time, randomness and identity

Three collaborators appear in almost every codebase and are almost always handled badly: the clock, the random number generator, and whatever produces identifiers. Patching them globally works and has a long tail of surprises; faking them is simpler and composes better.

Python
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone


class FakeClock:
    """A clock the test drives, rather than a patched module."""

    def __init__(self, start: datetime | None = None) -> None:
        self._now = start or datetime(2026, 1, 1, tzinfo=timezone.utc)

    def now(self) -> datetime:
        return self._now

    def advance(self, **kwargs) -> None:
        # Explicit: the test says when time passes, so nothing depends on duration.
        self._now += timedelta(**kwargs)


def test_token_expires_after_an_hour(clock=FakeClock()):
    token = issue_token(clock=clock, ttl=timedelta(hours=1))
    assert token.is_valid(clock.now())

    clock.advance(hours=1, seconds=1)
    assert not token.is_valid(clock.now())

The test above runs in microseconds, states the passage of time explicitly, and cannot be affected by the machine's timezone, a leap second or a slow CI runner. The equivalent with freezegun is shorter to write and patches datetime process-wide, which affects logging timestamps, database defaults and any library that samples the clock — a trade-off examined in freezing time with freezegun vs monkeypatch.

Identifiers follow the same pattern. A FakeIdGenerator yielding "id-1", "id-2" makes assertions readable and failures reproducible, where a patched uuid4 returning a fixed value breaks the moment two objects are created in one test. Randomness is the one case where seeding the real generator is usually enough, because the standard library's Random is already injectable — pass an instance rather than calling the module-level functions.

Keeping doubles out of the assertions

A last discipline decides whether a suite of fakes reads well: the double should appear in the arrangement and in the final assertion, never in between.

Python
def test_publishing_an_invoice_notifies_the_customer(clock, repo, notifier):
    invoice = InvoiceFactory(status="draft")
    repo.save(invoice)
    service = BillingService(repository=repo, notifier=notifier, clock=clock)

    service.publish(invoice.id)                      # the action under test

    assert repo.get(invoice.id).status == "published"    # state, from the fake
    assert notifier.sent == [("customer@example.test", "invoice_published")]

Both assertions read state the fakes accumulated, not calls they recorded. That difference is what lets BillingService be refactored — the notification moved behind an outbox, the save reordered — without touching the test, as long as the observable outcome is unchanged. A test asserting notifier.send.assert_called_once_with(...) is coupled to the call signature and breaks on a refactor that changed nothing a user could see.

When a fake needs an accumulator to support this, give it one deliberately: a sent list, a published list, a deleted set. These are part of the fake's test-facing API and should be as carefully designed as the contract itself, because every test in the suite will read them.

Reading a suite's double usage

An existing suite can be assessed quickly, and the numbers usually tell a clear story.

Bash
# How much configuration is going into doubles?
grep -rc "return_value\|side_effect\|assert_called" tests/ | sort -t: -k2 -rn | head
# Which collaborators are mocked most often?
grep -rho 'patch("[^"]*"' tests/ | sort | uniq -c | sort -rn | head

A collaborator patched in forty tests is a fake waiting to be written. A single test file with sixty return_value assignments is usually one where the double has become a second implementation, configured inline, with no name. And a high ratio of assert_called_with to ordinary assertions suggests the suite is testing call sequences rather than outcomes, which is the pattern that makes refactoring expensive.

A fourth signal is worth watching: how often a mock's configuration is copied between test files. Duplicated setup is the cheapest possible evidence that a shared double is missing, and unlike the counts above it points at the exact behaviour to encode, because the duplicated lines are the contract.

None of these numbers is a target. They are a way of finding the three or four places where the effort of writing a proper double pays back immediately, rather than converting a suite wholesale on principle.

Migrating a mock-heavy suite, incrementally

Replacing mocks with fakes wholesale is a bad trade; doing it where the pain is concentrated is a good one. The sequence that works has four steps and can stop after any of them.

Find the collaborator that is mocked most. The grep above gives it in a second. It is usually a repository, an HTTP client or a message publisher.

Write the contract suite before the fake. Derive the behaviours from what the existing mocks were configured to do — every return_value in the suite is somebody's belief about the real object, and collecting them is the fastest way to discover what the contract actually is. Run that suite against the real implementation first; it will fail in one or two places, and those are genuine bugs in the tests.

Write the fake, and switch one module's tests to it. Not the whole suite. One module is enough to reveal whether the fake's shape is right, and cheap to throw away if it is not.

Delete the mocks as tests are touched. A migration with a deadline becomes a large, risky change nobody reviews properly; one that happens as files are edited anyway finishes in a few months with no dedicated effort.

Four steps from a mock-heavy suite to a fake A left-to-right sequence. First find the most-mocked collaborator. Second, derive a contract suite from the existing mock configurations and run it against the real implementation. Third, write the fake and switch one module. Fourth, remove remaining mocks opportunistically as files are edited. Each step is independently valuable. Each step is useful on its own 1 · find most-patched target one grep tells you where 2 · contract derive from existing return_value settings finds wrong beliefs 3 · one module write the fake switch a single file cheap to revert 4 · drift replace as files are edited no deadline Stopping after step two still leaves the suite better: the contract is now written down and verified.
Step two is the surprising one. Collecting what the mocks were configured to return usually reveals two or three places where the tests believed something the real implementation never did.

Frequently Asked Questions

When is a hand-written fake better than MagicMock? Whenever more than two or three tests need the same collaborator to behave consistently. A fake encodes the collaborator's contract once, so a change to that contract fails in one place; the equivalent MagicMock configuration is repeated per test and drifts silently. Fakes also make tests readable, because the setup says what the world is rather than which methods return what.

What is the difference between a spy and a mock? A spy wraps a real object and records calls while still delegating to it, so behaviour is unchanged and the recording is additive. A mock replaces the object entirely and returns configured values. Use a spy when the real behaviour is wanted and only the interaction needs asserting.

How do I keep a fake honest as the real implementation changes? Run the same test suite against both. A shared contract test, parametrised over the real object and the fake, proves they agree on the behaviours the code depends on. Without it a fake drifts and the tests keep passing against a collaborator that no longer exists.

Is it acceptable for a fake to be simpler than the real thing? Yes, and it should be. A fake reproduces the behaviours the code under test depends on, not the implementation. An in-memory repository needs get, save and list with the same semantics; it does not need connection pooling, retries or SQL. The contract test defines where the line is.

Should fakes live with the tests or with the production code? With the production code, next to the interface they implement, when other packages' tests also need them. A fake shipped beside the real client is a documented, versioned part of the contract, which is how most well-designed SDKs provide one. Keep it in the test tree only while it is used by a single package.

← Back to Advanced Mocking & Test Doubles in Python