Test Doubles and When to Mock
Patching, fakes and seams. Mocking at the boundary rather than in the middle, and the over-mocked test that passes happily while the code it covers is broken.
Patching, fakes and seams. Mocking at the boundary rather than in the middle, and the over-mocked test that passes happily while the code it covers is broken.
The test suite is green. Ninety-four tests, every one passing, and the feature has been broken in production since Tuesday. The tests mock the API client, so they check that your code calls a fake correctly — and the fake has never been wrong about anything, because you wrote it to agree with you.
Mocking is the most powerful tool in testing and the easiest to misuse. By the end of this lesson you will know the kinds of test double, where to put them, and the specific failure mode that produces a green suite over broken software.
Some things cannot be in a unit test. A real HTTP call is slow, needs a network, fails when someone else's service is down, and may cost money or send an email to a real person. The system clock cannot be asked to be a Tuesday in March.
A test double stands in for one of those. The word covers several things that get called "mocks" and are not:
stub returns canned answers "the API says 200"
fake a real, simpler implementation an in-memory database
spy records how it was called "was send() called?"
mock a spy with expectations built in "send() must be called once"The distinction matters because the first two let you assert on results and the last two push you toward asserting on calls — which is the trap.
unittest.mock replaces something for the duration of a test:
from unittest.mock import patch
def test_uses_cached_result_when_available():
with patch("photo_tools.api.fetch_album") as fetch:
fetch.return_value = {"id": 7, "photos": []}
result = load_album(7)
assert result.id == 7The string is the part that goes wrong. Patch where the name is looked up, not where it is defined.
# photo_tools/loading.py
from photo_tools.api import fetch_album # a local name now
def load_album(album_id):
return fetch_album(album_id)patch("photo_tools.api.fetch_album") # patches the original
patch("photo_tools.loading.fetch_album") # patches what is USEDBecause loading.py imported the function into its own module,
it holds its own reference. Replacing the original does nothing
to that reference. The symptom is a patch that silently has no
effect — your test makes a real network call and passes anyway,
or hangs.
Two configurations you will use constantly:
fetch.return_value = {...} # always this
fetch.side_effect = [first, second] # different each call
fetch.side_effect = TimeoutError("slow") # raise insteadside_effect raising is how you test error handling for
failures you cannot conveniently cause.
Here is the failure mode, and it is worth seeing precisely.
Bad — a double that cannot disagree.
def test_photo_is_captioned():
photo = Mock()
photo.name = "sunrise_over_lisbon.jpg"
result = caption_for(photo)
assert result == "Sunrise Over Lisbon"
photo.mark_captioned.assert_called_once()Good — a real object, or a double constrained to the real interface.
def test_photo_is_captioned():
photo = Photo(name="sunrise_over_lisbon.jpg", city="Lisbon", size=1)
result = caption_for(photo)
assert result == "Sunrise Over Lisbon"
assert photo.is_captionedA bare Mock() invents any attribute you ask for. Rename
mark_captioned to set_captioned in the real class and this
test still passes, because the mock happily provides the old
name — so does photo.mark_captionedd, and so does
photo.anything_at_all. The test now asserts that your code
calls a method that no longer exists, and reports success.
Where a double is genuinely needed, autospec constrains it to
the real thing's interface:
with patch("photo_tools.loading.fetch_album", autospec=True) as fetch:
...Now a call with the wrong arguments, or to a method that does not exist, fails the test. That single keyword removes most of the danger, and it is off by default.
Where you patch matters more than how.
your CLI -> your logic -> your storage layer -> the network
^
patch HERE, at the edgePatching at the edge means everything you wrote still runs, and only the thing you do not own is replaced. Patching in the middle — a function calling the function under test — means the test exercises almost nothing.
The count is a useful smell. One or two patches is a test with a boundary. Six is a test where the thing being tested has been replaced by its own reflection, and it usually means the code has too many dependencies rather than that the test needs more mocks.
Often the better answer is a small real implementation.
Agrees with whatever you assert.
It returns what you told it to return, so a test using one can pass against code that calls it wrongly — you configured both sides of the conversation.
Right when there is nothing to implement: a payment provider, an email service.
A real implementation, in a dict.
Same interface, no database. Saving something and reading it back genuinely works, so a test can assert on the effect rather than on the call.
Written once and shared by every test that needs storage.
class InMemoryPhotoStore:
"""A real store, in a dict. Same interface, no database."""
def __init__(self):
self._photos: dict[str, Photo] = {}
def save(self, photo: Photo) -> None:
self._photos[photo.name] = photo
def get(self, name: str) -> Photo | None:
return self._photos.get(name)def test_processing_saves_each_photo():
store = InMemoryPhotoStore()
process_all([photo_a, photo_b], store=store)
assert store.get("dawn.jpg") is not NoneThat test asserts on a result — the photo is retrievable —
rather than on the fact that save was called. It survives
refactoring, it fails if the code saves the wrong thing, and it
reads as a description of the behaviour.
Note the shape that made it possible: process_all takes the
store as an argument rather than constructing one. Dependency
injection is the technique, and its main benefit is exactly
this — when a function receives its dependencies, testing it
needs no patching at all.
Standard fakes exist for common cases: sqlite3.connect(":memory:")
for a database, tmp_path for the filesystem, and libraries
like responses or respx for HTTP.
The other things tests cannot control.
def test_report_records_when_it_ran():
with patch("photo_tools.report.datetime") as clock:
clock.now.return_value = datetime(2026, 7, 29, 14, 30)
...That works and is fiddly. The better shape is to stop asking for the time in the middle of your logic:
def build_report(photos, *, now: datetime | None = None) -> Report:
now = now or datetime.now(timezone.utc)
...Now the test passes a fixed time and no patching is involved.
The same trick applies to randomness (pass a seeded Random),
to unique ids (pass a factory), and to configuration.
For environment variables, monkeypatch is cleaner than
patching and undoes itself:
def test_uses_configured_folder(monkeypatch):
monkeypatch.setenv("PHOTO_OUTPUT", "/tmp/out")
assert output_folder() == Path("/tmp/out")Sometimes the call genuinely is the behaviour — sending an email is not observable any other way. Then asserting on the call is right, and the useful assertion is about the arguments:
send.assert_called_once_with(
to="ana@example.com",
subject="Your photos are ready",
)assert send.call_args.kwargs["to"] == "ana@example.com"assert_called_once_with is stronger than assert_called_once,
because "we sent an email" is a much weaker claim than "we sent
that email to that person".
KINDS
stub canned answers fake a real, simpler version
spy records calls mock a spy with expectations
stubs and fakes let you assert on RESULTS
spies and mocks push you toward asserting on CALLS
PATCHING
patch("module.where.it.is.USED", ...) not where defined
a wrong target silently does nothing
always pass autospec=True
m.return_value = x always this
m.side_effect = [a, b] different per call
m.side_effect = TimeoutError raise instead
WHERE
at the boundary - the thing you do not own
one or two patches: fine. six: the test tests nothing
PREFER
a real object over a Mock()
a fake (in-memory store) over a mock
dependency injection over patching - pass it in
sqlite3 ":memory:", tmp_path, responses/respx
TIME AND RANDOMNESS
do not patch the clock - take `now` as an argument
seeded Random, injected id factory
monkeypatch.setenv for environment variables
WHEN THE CALL IS THE POINT
send.assert_called_once_with(to=..., subject=...)
assert the ARGUMENTS, not just that it happened
TRAPS
Mock() invents any attribute - renames never fail the test
a misspelled assert_* is a no-op that passes
autospec=True or seal() makes both of those errorsYou can now isolate a test from things it cannot control, and — more importantly — you know the cost. Every double is a claim that the real thing behaves this way, and that claim is not tested by the test that makes it. Preferring real objects, fakes and injected dependencies keeps the number of such claims small.
Next is Logging Instead of Print, which is what your code does when nobody is watching it run. Tests tell you about failures you anticipated; logs are how you find out about the ones you did not.
Before you move on, take a test that patches something and try to rewrite it without patching — by passing the dependency in, or by using a small fake. Sometimes it is not worth it. When it is, the resulting test is usually shorter than the one it replaced, which is a surprise worth having once.