Spying Without Replacing with mocker.spy

Published: February 12, 2026


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 (or None).

These let you inspect what the real function did, not just that it was called.