Isolation & Contracts

Spying on a Real Object with wraps

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.mock in the standard library.
  • pytest >= 8.0.
  • The target resolution rules for patch in where to patch.

Solution

Wrap the one method whose use must be observed, and let it keep running.

Python
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")
Python
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
A spy between the caller and the real object The code under test calls what it believes is the backing store. The spy records the call and its arguments in call_args_list, then forwards the call to the real fetch method and returns the real result unchanged. The test later asserts on the recorded calls while the returned values came from real behaviour. Record on the way in, forward unchanged cache.get() code under test spy (wraps=real) call_args_list.append(…) then forward backing_store.fetch real behaviour, real result The result flows back untouched; only the record of the call is new.
Because the result is real, any assertion on values tests the actual system. The spy adds observation and nothing else.

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_value on 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=PropertyMock and a wrapped getter.
  • Identity checks. Code that checks isinstance(obj, RealClient) fails against a Mock(wraps=...). Spy on a method with patch.object instead of replacing the object.
  • Async methods. Wrapping an async method with a plain Mock returns the coroutine without recording the await. Use AsyncMock(wraps=...) so awaits are recorded too.
  • Spies that outlive the test. A spy assigned without a context manager or monkeypatch stays 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.

Choosing between real object, spy and stub A decision by two questions. If neither the collaborator's behaviour nor the interaction with it matters to the test, use the real object. If only the interaction matters, a stub or mock suffices. If both the real behaviour and the interaction matter, use a spy, provided the real collaborator is cheap and deterministic. Does the test need the real behaviour, the interaction, or both? behaviour only use the real object no double at all simplest and most honest interaction only stub, mock or fake real behaviour irrelevant fast, isolated both spy with wraps= if the real thing is cheap counts, caching, retries
The right-hand column is smaller than it looks: many tests that reach for a spy actually only need one of the other two.

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.

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

call_count versus await_count on an async spy Two counters on an AsyncMock spy. call_count increments when the coroutine function is called, even if the coroutine is never awaited. await_count increments only when the coroutine is actually awaited, which is when the real work runs. Asserting on await_count proves the real call happened. Called is not the same as awaited call_count increments when fetch(…) is called even if the coroutine is dropped can be 2 while no fetch ran await_count increments when it is awaited which is when the real work runs proves the fetch happened
For async spies, every count assertion should use the await-based attribute. The call-based ones are true but not sufficient.

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.

← Back to Spies, Fakes & Hand-Rolled Test Doubles