Espiar sin reemplazar con mocker.spy
A veces querés verificar que una función fue llamada con los argumentos correctos sin reemplazarla por un fake. Para eso existe mocker.spy.
Donde mocker.patch reemplaza la función real por un MagicMock, mocker.spy envuelve la función real para que siga ejecutándose normalmente — pero el wrapper registra cada llamada, argumento y valor de retorno.
1. Ejemplo de código
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 hace from converter import celsius_to_fahrenheit, lo que crea un name binding local dentro del módulo weather.
2. ¿Qué se debe espiar?
La misma regla de “parchear donde se usa” aplica a los espías.
mocker.spy(obj, name) funciona con setattr — reemplaza obj.name con un wrapper que delega a la función original. Si el módulo que llama importó la función con from X import Y, el name binding vive en el namespace del módulo que llama, no en el módulo original.
Correcto — espiar donde la función se usa:
mocker.spy(weather, "celsius_to_fahrenheit")
Incorrecto — espiar donde la función se define:
mocker.spy(converter, "celsius_to_fahrenheit")
El objetivo incorrecto reemplaza el atributo en converter, pero weather.celsius_to_fahrenheit sigue apuntando directamente a la función sin envolver — el espía no ve nada.
3. Pruebas
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
Salida de pruebas
============================= 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 ===============================
La primera prueba espía sobre el objetivo correcto y verifica que la llamada fue registrada. La segunda muestra que espiar sobre el sitio de definición no detecta la llamada — spy.call_count queda en cero.
4. Atributos específicos del espía
Además de los asserts estándar de MagicMock (assert_called_once_with, call_args, call_count, …), un espía agrega:
spy.spy_return— el valor de retorno de la última llamada real.spy.spy_exception— la excepción lanzada por la última llamada real (oNone).
Estos permiten inspeccionar qué hizo la función real, no solo que fue llamada.