return_value sirve cuando un mock debe devolver siempre el mismo valor. Pero hay pruebas que necesitan algo más: lanzar una excepción, devolver valores distintos en cada llamada o calcular la respuesta según los argumentos.
Para esos casos usamos side_effect. Es una herramienta potente de unittest.mock, y conviene conocerla bien para no complicar las pruebas innecesariamente.
La forma más directa de usar side_effect es indicar una excepción. Cuando el mock sea llamado, lanzará esa excepción:
from unittest.mock import Mock
servicio = Mock()
servicio.consultar.side_effect = TimeoutError("Tiempo agotado")
Si el código llama a servicio.consultar(), se lanzará TimeoutError.
Supongamos una función que consulta un servicio de cotización:
class CotizacionNoDisponibleError(Exception):
pass
def obtener_precio_en_pesos(precio_usd, servicio_cotizacion):
try:
cotizacion = servicio_cotizacion.obtener_cotizacion("USD", "ARS")
except TimeoutError as error:
raise CotizacionNoDisponibleError(
"No se pudo obtener la cotización"
) from error
return precio_usd * cotizacion
Con side_effect podemos simular el timeout:
import pytest
from unittest.mock import Mock
def test_obtener_precio_en_pesos_si_falla_cotizacion_lanza_error_propio():
servicio = Mock()
servicio.obtener_cotizacion.side_effect = TimeoutError("Tiempo agotado")
with pytest.raises(CotizacionNoDisponibleError):
obtener_precio_en_pesos(20, servicio)
No necesitamos provocar un fallo real de red. El mock reproduce el error de manera controlada.
Si asignamos una lista a side_effect, cada llamada devuelve el siguiente elemento:
generar_id = Mock()
generar_id.side_effect = [101, 102, 103]
assert generar_id() == 101
assert generar_id() == 102
assert generar_id() == 103
Esto es útil para probar código que llama varias veces a la misma dependencia.
Ejemplo:
def crear_codigos(cantidad, generar_codigo):
codigos = []
for _ in range(cantidad):
codigos.append(generar_codigo())
return codigos
La prueba:
def test_crear_codigos_usa_valores_secuenciales():
generar_codigo = Mock()
generar_codigo.side_effect = ["A1", "B2", "C3"]
codigos = crear_codigos(3, generar_codigo)
assert codigos == ["A1", "B2", "C3"]
assert generar_codigo.call_count == 3
Cada llamada recibe una respuesta distinta.
La lista también puede mezclar valores y excepciones:
servicio = Mock()
servicio.consultar.side_effect = [
TimeoutError("Primer intento fallido"),
{"estado": "ok"},
]
La primera llamada lanza una excepción y la segunda devuelve el diccionario. Esto sirve para probar lógica de reintentos.
Supongamos esta función:
def consultar_con_reintento(servicio):
try:
return servicio.consultar()
except TimeoutError:
return servicio.consultar()
Podemos probar que se recupera en el segundo intento:
def test_consultar_con_reintento_si_el_segundo_intento_funciona():
servicio = Mock()
servicio.consultar.side_effect = [
TimeoutError("Primer intento fallido"),
{"estado": "ok"},
]
resultado = consultar_con_reintento(servicio)
assert resultado == {"estado": "ok"}
assert servicio.consultar.call_count == 2
Si se llama al mock más veces que elementos tiene la lista, Python lanza StopIteration:
generar_codigo = Mock()
generar_codigo.side_effect = ["A1"]
assert generar_codigo() == "A1"
# Una segunda llamada lanzaría StopIteration.
Por eso conviene que la lista tenga tantos elementos como llamadas esperamos en la prueba.
También podemos asignar una función a side_effect. Esa función recibirá los mismos argumentos con los que se llama al mock y podrá calcular el resultado.
def buscar_cliente(cliente_id):
clientes = {
1: {"id": 1, "nombre": "Ana"},
2: {"id": 2, "nombre": "Luis"},
}
return clientes.get(cliente_id)
repositorio = Mock()
repositorio.buscar_por_id.side_effect = buscar_cliente
Ahora repositorio.buscar_por_id(1) devuelve Ana y repositorio.buscar_por_id(99) devuelve None.
Ejemplo completo:
def obtener_nombre_cliente(cliente_id, repositorio):
cliente = repositorio.buscar_por_id(cliente_id)
if cliente is None:
return "Cliente no encontrado"
return cliente["nombre"]
def test_obtener_nombre_cliente_con_side_effect_funcion():
def buscar_cliente(cliente_id):
clientes = {
1: {"id": 1, "nombre": "Ana"},
2: {"id": 2, "nombre": "Luis"},
}
return clientes.get(cliente_id)
repositorio = Mock()
repositorio.buscar_por_id.side_effect = buscar_cliente
assert obtener_nombre_cliente(1, repositorio) == "Ana"
assert obtener_nombre_cliente(99, repositorio) == "Cliente no encontrado"
La función usada como side_effect actúa como un stub dinámico.
Si la función dentro de side_effect empieza a crecer, quizás sea mejor escribir un fake explícito. Por ejemplo, si necesitamos guardar, actualizar y buscar datos, un fake en memoria será más claro.
Si un mock tiene side_effect configurado, side_effect tiene prioridad sobre return_value.
funcion = Mock(return_value="valor fijo")
funcion.side_effect = ["primero", "segundo"]
assert funcion() == "primero"
assert funcion() == "segundo"
Conviene no configurar ambos salvo que haya una razón clara. En la mayoría de pruebas, usa uno u otro.
Podemos eliminar el comportamiento especial asignando None:
funcion = Mock(return_value="ok")
funcion.side_effect = TimeoutError("fallo")
funcion.side_effect = None
assert funcion() == "ok"
Esto no suele ser necesario en pruebas pequeñas, pero ayuda a entender cómo se comporta el mock.
Una función como side_effect también puede validar argumentos y fallar si recibe algo inesperado:
def obtener_cotizacion(moneda_origen, moneda_destino):
if (moneda_origen, moneda_destino) != ("USD", "ARS"):
raise ValueError("Par de monedas no soportado")
return 1000
Uso en prueba:
servicio = Mock()
servicio.obtener_cotizacion.side_effect = obtener_cotizacion
Esto puede detectar errores en los argumentos enviados a la dependencia, aunque no reemplaza una verificación explícita con assert_called_once_with.
Una función más completa:
def consultar_con_reintentos(servicio, intentos=3):
ultimo_error = None
for _ in range(intentos):
try:
return servicio.consultar()
except TimeoutError as error:
ultimo_error = error
raise ultimo_error
Prueba donde todos los intentos fallan:
def test_consultar_con_reintentos_falla_luego_de_tres_intentos():
servicio = Mock()
servicio.consultar.side_effect = [
TimeoutError("fallo 1"),
TimeoutError("fallo 2"),
TimeoutError("fallo 3"),
]
with pytest.raises(TimeoutError):
consultar_con_reintentos(servicio, intentos=3)
assert servicio.consultar.call_count == 3
Si la función usada como side_effect tiene muchas ramas, validaciones y estado, la prueba se vuelve difícil de entender. En ese caso conviene extraer una clase con nombre claro o usar un fake.
La prueba debe seguir siendo más simple que el comportamiento real. Si el doble se vuelve tan complicado como la dependencia original, pierde valor.
Prueba esta función usando side_effect:
def obtener_datos_usuario(usuario_id, cliente_api):
try:
return cliente_api.obtener_usuario(usuario_id)
except TimeoutError:
return {"error": "servicio no disponible"}
Escribe una prueba para una respuesta exitosa y otra para un TimeoutError.
Una solución:
from unittest.mock import Mock
from usuarios import obtener_datos_usuario
def test_obtener_datos_usuario_exitoso():
cliente_api = Mock()
cliente_api.obtener_usuario.return_value = {
"id": 1,
"nombre": "Ana",
}
datos = obtener_datos_usuario(1, cliente_api)
assert datos == {"id": 1, "nombre": "Ana"}
def test_obtener_datos_usuario_si_hay_timeout():
cliente_api = Mock()
cliente_api.obtener_usuario.side_effect = TimeoutError("Tiempo agotado")
datos = obtener_datos_usuario(1, cliente_api)
assert datos == {"error": "servicio no disponible"}
En la primera prueba alcanza con return_value. En la segunda usamos side_effect porque queremos simular una excepción.
side_effect permite que un mock lance excepciones, devuelva una secuencia de valores o calcule resultados con una función. Es una herramienta muy útil para probar errores, reintentos y comportamientos dependientes de argumentos.
En el próximo tema veremos con más detalle cómo verificar llamadas usando assert_called, assert_called_once_with y call_args.