Sometimes a test needs the real collaborator — its real computation, its real side effects — and also needs to know how it was used. A cache test wants the real cache but must confirm that the second lookup did not reach the backing store. A retry test wants the real HTTP client against a local server but must count the attempts. Replacing the collaborator with a stub would lose the behaviour; not replacing it would lose the observation. A spy does both: it wraps the real object, forwards every call, and records each one.
unittest.mock supports spies directly through the wraps argument, and they are 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 something unrealistic.
That property makes spies particularly well suited to a category of test that is otherwise awkward to write: assertions about efficiency. Caching, batching, deduplication, retry limits and connection reuse are all claims about how often something happens, and none of them can be checked by looking at return values alone. A spy lets the test observe the frequency while every value in the system remains real, which is why the examples below are all of that shape.
Prerequisites
- Python 3.8+;
unittest.mockin the standard library. pytest >= 8.0.- The target resolution rules for
patchin where to patch.
Solution
Wrap the one method whose use must be observed, and let it keep running.
from unittest.mock import patch
def test_second_lookup_is_served_from_the_cache(cached_prices, backing_store):
# Spy on the backing store's fetch: real behaviour, recorded calls.
with patch.object(backing_store, "fetch", wraps=backing_store.fetch) as fetch:
first = cached_prices.get("SKU-1")
second = cached_prices.get("SKU-1")
assert first == second # real values from the real store
assert fetch.call_count == 1 # the cache served the second lookup
fetch.assert_called_once_with("SKU-1")
from unittest.mock import Mock
def test_client_retries_three_times_against_a_flaky_server(local_server, client_factory):
local_server.fail_next(2) # real server, scripted failures
real = client_factory(local_server.url)
spy = Mock(wraps=real) # whole-object spy
result = retrying_fetch(spy, "/prices")
assert result.status_code == 200
assert spy.get.call_count == 3 # two failures, one success
Why this works
A Mock with wraps set does two things on every call: it records the call in mock_calls and call_args_list exactly as any mock would, and then — because no return_value or side_effect is configured — it calls the wrapped object with the same arguments and returns whatever that returns. Attribute access is wrapped recursively, so spy.get is itself a spy around real.get.
The forwarding is literal: arguments, keyword arguments and the return value pass through unchanged, and an exception raised by the real method propagates through the spy to the caller exactly as it would without it. That makes a spy safe to insert into any test that already passes — the only possible change in outcome is a new assertion on the recorded calls.
patch.object(target, name, wraps=original) applies the same idea to one attribute of a live object, which is usually preferable. It leaves the rest of the object untouched, it restores the original when the context exits, and it makes the test's intent explicit: only this method's use is under observation.
Edge cases and failure modes
- Configuring
return_valueon a spy. It silently stops forwarding for that attribute, turning the spy into a stub. If the real result is needed, leave it unset. - Wrapping a property. Properties are resolved on the class, so wrapping the instance attribute does nothing. Patch the property on the class with
new_callable=PropertyMockand a wrapped getter. - Identity checks. Code that checks
isinstance(obj, RealClient)fails against aMock(wraps=...). Spy on a method withpatch.objectinstead of replacing the object. - Async methods. Wrapping an async method with a plain
Mockreturns the coroutine without recording the await. UseAsyncMock(wraps=...)so awaits are recorded too. - Spies that outlive the test. A spy assigned without a context manager or
monkeypatchstays in place for later tests. Always patch through a mechanism that restores.
When a spy is the right double
A spy is the right choice in a narrow but common set of situations, and recognising them avoids reaching for a stub out of habit.
The claim is about efficiency, not correctness. "The second lookup is served from cache", "the batch makes one request rather than ten", "the retry stops after three attempts" — these are statements about how often something happens, and the only way to test them without changing the behaviour is to count calls to the real thing. A stub would make the count trivially correct and the values meaningless.
The real collaborator is cheap and deterministic. A local server, an in-process cache, a pure computation — spying on these costs nothing and keeps the test honest. Spying on a slow or non-deterministic collaborator inherits its slowness and flakiness, and there a fake is usually better.
The behaviour under test depends on the collaborator's real output. A parser that feeds its result to a validator, where the test must confirm the validator was consulted and that the parser's real output passed validation, needs both halves. A stubbed validator would pass anything.
Outside those situations, a spy tends to be a hesitation between two better options: if the interaction does not matter, use the real object without a spy; if the behaviour does not matter, use a stub or a fake. Being explicit about which of the three the test actually needs usually makes the test shorter as well as clearer, because the double that fits requires the least configuration.
Spying on async collaborators
Async code needs AsyncMock for the same reason it needs it everywhere else: a plain Mock wrapping a coroutine function returns the coroutine without recording that it was awaited, so a test can confirm a call happened while the await never did. AsyncMock(wraps=...) records both and forwards the await to the real coroutine.
from unittest.mock import AsyncMock, patch
async def test_prefetch_warms_the_cache_once(prices, backing_store):
with patch.object(backing_store, "fetch",
new=AsyncMock(wraps=backing_store.fetch)) as fetch:
await prices.prefetch(["SKU-1", "SKU-2"])
await prices.get("SKU-1") # served from the warmed cache
assert fetch.await_count == 2 # one per SKU, none for the get
assert [c.args[0] for c in fetch.await_args_list] == ["SKU-1", "SKU-2"]
The assertion on the argument order also earns its place: prefetching in a different order than requested is harmless here, but in code where order carries meaning — a queue, a ledger, a sequence of writes — the same await_args_list check is how that meaning gets tested. The assertion on await_count rather than call_count is the one that matters most. A coroutine that was created but never awaited would still increment call_count, and the test would pass while the fetch never ran — the silent failure mode described in patching async code and coroutines.
Spies and autospec together
A plain spy checks nothing about how it is called until the real method runs, at which point a wrong argument raises from inside the real code — correct, but with a traceback pointing into the collaborator rather than at the caller's mistake. Combining the spy with autospec moves that check to the boundary.
patch.object(target, "fetch", autospec=True, side_effect=target.fetch) produces a spy whose signature matches the real method exactly. A call with a misspelt keyword fails at the spy with a clear TypeError naming the method, and a correct call is forwarded to the real implementation through side_effect. The small cost is that autospec on a bound method receives the instance as its first argument in some configurations, which is worth one quick check the first time; the benefit is that the spy now catches interface drift as well as recording calls, which is exactly the property the autospec guides on this site argue every mock should have.
Frequently Asked Questions
Does wraps change what the real object returns?
No. A Mock created with wraps=real forwards every call to the real object and returns its real result, while recording the call. Setting return_value or side_effect on the spy overrides that forwarding for the configured attribute only.
Can a spy wrap a single method rather than a whole object?
Yes, and it is usually cleaner. patch.object(target, "method", wraps=target.method) replaces just that method with a recording wrapper, leaving the rest of the object untouched.
Do spies enforce the real signature?
A plain Mock(wraps=...) does not check arguments until the real method is called, at which point a wrong signature fails naturally. Combining autospec with wraps checks arguments at the spy itself, which gives a clearer failure.
Related
- Spies, Fakes & Hand-Rolled Test Doubles — where spies sit among the other doubles.
- Patching Class Attributes with patch.object — the mechanism used to install a method spy.
- assert_called_with vs call_args_list — reading what the spy recorded.
- Asserting Await Order with AsyncMock — the async counterpart.
← Back to Spies, Fakes & Hand-Rolled Test Doubles