ㅤ

Testing terminology distinguishes between different kinds of test doubles, including:
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.
wrapswraps 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.
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.
spy() helpermocker.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")]
Read my book Boost Your Django DX, freshly updated in November 2024.
One summary email a week, no spam, I pinky promise.
Related posts:
| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | pytest-tia: Run Only the Tests Your Git Diff Actually Affects | 0 | 20 | 12-07-2026 |
| 2 | pytest-mrt: Catch Database Migration Rollback Failures | 0 | 26.67 | 02-07-2026 |
| 3 | django-orm-lens: Django Schema Review | 0 | 35 | 10-08-2026 |
| 4 | Find All Instances of a Class With gc.get_objects() | 0 | 10 | 02-08-2026 |
| 5 | 仮 | 0 | 0 | 16-02-2026 |
| 6 | 仮 | 0 | 0 | 16-02-2026 |
| 7 | 仮 | 0 | 0 | 16-02-2026 |
| 8 | Смычки для альтов | 0 | 10 | 10-07-2026 |
| 9 | crawlee 1.8.4b1 | 0 | 5 | 20-07-2026 |
| 10 | 0 | 0 | 02-04-2026 |