Python: spy on function calls with unittest.mock’s wraps

Testing terminology distinguishes between different kinds of test doubles, including:
- mocks, which replace the real behaviour of a function.
- spies, which wrap a function, recording calls for later assertions.
Despite its name, unittest.mock is not just for mocks: it can also create spies, through the wraps argument of its Mock classes. Calls to such a Mock pass through to the wrapped object and return its real results, while the mock records the calls for later assertions.
For example, say we wanted to test the caching in this module, which computes the sound that an owl makes based on its name:
sound_cache: dict[str, str] = {}
def owl_sound(name: str) -> str:
if name not in sound_cache:
sound_cache[name] = compute_owl_sound(name)
return sound_cache[name]
def compute_owl_sound(name: str) -> str:
firsts = ("Hoo", "Hoot", "Toot", "Hooo")
seconds = ("hoo", "hoot", "toot", "hooo")
first = firsts[len(name) % len(firsts)]
second = seconds[ord(name[0]) % len(seconds)]
return f"{first}-{second}!"
owl_sound() stores results in the module-level sound_cache dictionary, calling compute_owl_sound() only for names it hasn’t seen before. To test that caching, we can spy on compute_owl_sound() and check how many times it gets called:
from unittest import TestCase, mock
import example
from example import owl_sound
class OwlSoundTests(TestCase):
def setUp(self):
example.sound_cache.clear()
def test_owl_sound_caches(self):
with (
mock.patch.object(
example,
"compute_owl_sound",
autospec=True,
wraps=example.compute_owl_sound,
) as spy,
):
first = owl_sound("athena")
second = owl_sound("athena")
assert first == "Toot-hoot!"
assert second == "Toot-hoot!"
assert spy.mock_calls == [mock.call("athena")]
mock.patch.object() replaces compute_owl_sound in the example module with a Mock. patch.object(), like plain patch(), passes extra keyword arguments to the mock that it creates, so wraps=example.compute_owl_sound makes that mock wrap the real function.
The test asserts on the two results from calling owl_sound(), then makes an assertion with the “spy” mock to check that compute_owl_sound() was called only once.
autospec=True is optional for spying on functions, but required for spying on methods, so it’s best to pass it. And indeed, it’s generally a good idea to use this feature, known as “autospeccing”, whenever you create a mock, because it makes the mock copy the signature of the wrapped object and thus fail when invalid arguments are passed.
Configured behaviour beats wraps
wraps only provides the mock’s default behaviour, so other configuration takes precedence. If you set return_value, the wrapped object is not called at all:
>>> spy = mock.Mock(wraps=compute_owl_sound, return_value="dude, whatever")
>>> spy("athena")
'dude, whatever'
And if you set side_effect, it runs instead of the wrapped object, unless it returns mock.DEFAULT, in which case the call passes through:
>>> def noisy(name):
... print(f"🦉 compute_owl_sound called with {name!r}")
... return mock.DEFAULT
...
>>> spy = mock.Mock(wraps=compute_owl_sound, side_effect=noisy)
>>> spy("athena")
🦉 compute_owl_sound called with 'athena'
'Toot-hoot!'
This combination lets you intercept calls while still delegating to the real object, at least sometimes.
In pytest
If you use pytest to run your tests, the above patterns all work. The pytest-mock plugin also provides mocker.spy(), a convenient shortcut built on wraps and autospec:
import example
from example import owl_sound
def test_owl_sound_caches(mocker):
mocker.patch.dict(example.sound_cache, clear=True)
spy = mocker.spy(example, "compute_owl_sound")
first = owl_sound("athena")
second = owl_sound("athena")
assert first == "Toot-hoot!"
assert second == "Toot-hoot!"
assert spy.mock_calls == [mocker.call("athena")]
It additionally records the latest return value on spy.spy_return, all return values on spy.spy_return_list, and any exception raised on spy.spy_exception.
Simplify with a spy() helper
mocker.spy() is neat, but if you’re not using pytest or pytest fixtures, try the below helper instead. This spy() context manager reduces the boilerplate of calling mock.patch.object() with the same wraps and autospec arguments each time:
from contextlib import contextmanager
from typing import Any, Generator
from unittest import mock
@contextmanager
def spy(obj: Any, name: str) -> Generator[Any]:
"""
Spy on the named function or method of obj: calls pass through to
the real object whilst being recorded on the yielded mock.
Source: https://adamj.eu/tech/2026/07/25/python-spy-unittest-mock-wraps/
"""
with mock.patch.object(
obj, name, autospec=True, wraps=getattr(obj, name)
) as spy_mock:
yield spy_mock
Use it like so (here it has been placed in a testing module):
from unittest import TestCase, mock
import example
from example import owl_sound
from testing import spy
class OwlSoundTests(TestCase):
def setUp(self):
example.sound_cache.clear()
def test_owl_sound_caches(self):
with spy(example, "compute_owl_sound") as compute_spy:
first = owl_sound("athena")
second = owl_sound("athena")
assert first == "Toot-hoot!"
assert second == "Toot-hoot!"
assert compute_spy.mock_calls == [mock.call("athena")]
😸😸😸 Check out my new book on using GitHub effectively, Boost Your GitHub DX! 😸😸😸
One summary email a week, no spam, I pinky promise.
Related posts: