Spying Without Replacing with mocker.spy
Sometimes you want to verify that a function was called with the right arguments without replacing it with a fake. That’s what mocker.spy does.
Where mocker.patch swaps the real function for a MagicMock, mocker.spy wraps the real function so it still runs normally — but the wrapper records every call, argument, and return value.
1. Code Example
converter.py
def celsius_to_fahrenheit(c):
return round(c * 9 / 5 + 32, 2)
weather.py
from converter import celsius_to_fahrenheit
def format_forecast(city, temp_c):
temp_f = celsius_to_fahrenheit(temp_c)
return f"{city}: {temp_c}\u00b0C / {temp_f}\u00b0F"
weather.py does from converter import celsius_to_fahrenheit, which creates a local name binding inside the weather module.
2. What should be spied?
The same “patch where it’s used” rule applies to spies.
mocker.spy(obj, name) works via setattr — it replaces obj.name with a wrapper that delegates to the original function. If the calling module imported the function with from X import Y, the name binding lives in the caller’s namespace, not in the original module.
Correct — spy where the function is used:
mocker.spy(weather, "celsius_to_fahrenheit")
Wrong — spy where the function is defined:
mocker.spy(converter, "celsius_to_fahrenheit")
The wrong target replaces the attribute in converter, but weather.celsius_to_fahrenheit still points directly to the unwrapped function — the spy sees nothing.
3. Tests
test_weather.py
from weather import format_forecast
def test_spy_where_used(mocker):
"""Spy on the name inside the module that calls it."""
import weather
spy = mocker.spy(weather, "celsius_to_fahrenheit")
result = format_forecast("Buenos Aires", 25)
assert result == "Buenos Aires: 25\u00b0C / 77.0\u00b0F" # real function ran
spy.assert_called_once_with(25) # spy tracked the call
assert spy.spy_return == 77.0 # spy recorded return value
def test_spy_wrong_target(mocker):
"""Spy on the definition site \u2014 misses calls through weather module."""
import converter
spy = mocker.spy(converter, "celsius_to_fahrenheit")
result = format_forecast("Buenos Aires", 25)
assert result == "Buenos Aires: 25\u00b0C / 77.0\u00b0F" # function still works
assert spy.call_count == 0 # spy saw nothing
Test output
============================= test session starts ==============================
platform darwin -- Python 3.13.0, pytest-9.0.2, pluggy-1.6.0 -- /usr/local/opt/python@3.13/bin/python3.13
rootdir: /mocker-spy-python/sandbox
plugins: mock-3.15.1
collecting ... collected 2 items
test_weather.py::test_spy_where_used PASSED [ 50%]
test_weather.py::test_spy_wrong_target PASSED [100%]
============================== 2 passed in 0.03s ===============================
The first test spies on the correct target and verifies the call was tracked. The second test shows that spying on the definition site misses the call entirely — spy.call_count stays at zero.
4. Spy-specific attributes
Beyond the standard MagicMock assertions (assert_called_once_with, call_args, call_count, …), a spy adds:
spy.spy_return— the return value of the last real call.spy.spy_exception— the exception raised by the last real call (orNone).
These let you inspect what the real function did, not just that it was called.