Algunos objetos en Python no se usan solo llamando métodos comunes. También pueden participar en operaciones especiales como len(objeto), iteración con for, acceso con [] o bloques with.
Para estos casos, MagicMock resulta más conveniente que Mock, porque ya trae soporte para muchos métodos mágicos.
Los métodos mágicos son métodos especiales con doble guion bajo, como __len__, __iter__, __getitem__, __enter__ y __exit__.
Python los usa detrás de sintaxis común:
len(objeto) llama a objeto.__len__().for item in objeto usa objeto.__iter__().objeto[0] usa objeto.__getitem__(0).with objeto as x usa __enter__ y __exit__.MagicMock se importa desde unittest.mock:
from unittest.mock import MagicMock
Se comporta como un Mock, pero incluye métodos mágicos configurables.
Supongamos esta función:
def tiene_items(coleccion):
return len(coleccion) > 0
Podemos probarla configurando __len__:
def test_tiene_items_si_len_es_mayor_a_cero():
coleccion = MagicMock()
coleccion.__len__.return_value = 3
assert tiene_items(coleccion) is True
Y el caso vacío:
def test_tiene_items_si_len_es_cero():
coleccion = MagicMock()
coleccion.__len__.return_value = 0
assert tiene_items(coleccion) is False
Función que suma importes:
def sumar_importes(pagos):
total = 0
for pago in pagos:
total += pago["importe"]
return total
Prueba con MagicMock:
def test_sumar_importes_con_iterador_mockeado():
pagos = MagicMock()
pagos.__iter__.return_value = iter([
{"importe": 100},
{"importe": 250},
])
total = sumar_importes(pagos)
assert total == 350
El mock se comporta como un iterable.
En el ejemplo anterior, una lista real sería más simple:
def test_sumar_importes_con_lista_real():
pagos = [
{"importe": 100},
{"importe": 250},
]
assert sumar_importes(pagos) == 350
Si no necesitas verificar interacciones especiales con el iterable, usa una lista real. Es más clara.
Función:
def obtener_primer_item(contenedor):
return contenedor[0]
Prueba con __getitem__:
def test_obtener_primer_item():
contenedor = MagicMock()
contenedor.__getitem__.return_value = "primer valor"
resultado = obtener_primer_item(contenedor)
assert resultado == "primer valor"
contenedor.__getitem__.assert_called_once_with(0)
Esto permite controlar y verificar el acceso por índice.
Un context manager es un objeto usado con with. Por ejemplo:
def leer_configuracion(abrir_archivo):
with abrir_archivo("config.txt") as archivo:
return archivo.read()
La función recibe una dependencia abrir_archivo, que se comporta como open.
Podemos crear un context manager con MagicMock:
from unittest.mock import MagicMock, Mock
def test_leer_configuracion():
archivo = Mock()
archivo.read.return_value = "modo=debug"
context_manager = MagicMock()
context_manager.__enter__.return_value = archivo
context_manager.__exit__.return_value = None
abrir_archivo = Mock(return_value=context_manager)
contenido = leer_configuracion(abrir_archivo)
assert contenido == "modo=debug"
abrir_archivo.assert_called_once_with("config.txt")
archivo.read.assert_called_once_with()
__enter__ devuelve el objeto que aparece después de as.
Para archivos, la biblioteca estándar ofrece mock_open, que suele ser más claro que configurar __enter__ manualmente:
from unittest.mock import mock_open
def test_leer_configuracion_con_mock_open():
abrir_archivo = mock_open(read_data="modo=debug")
contenido = leer_configuracion(abrir_archivo)
assert contenido == "modo=debug"
abrir_archivo.assert_called_once_with("config.txt")
Más adelante veremos pruebas con archivos con más detalle.
Función:
def guardar_reporte(abrir_archivo, contenido):
with abrir_archivo("reporte.txt", "w") as archivo:
archivo.write(contenido)
Prueba:
def test_guardar_reporte():
abrir_archivo = mock_open()
guardar_reporte(abrir_archivo, "resultado final")
abrir_archivo.assert_called_once_with("reporte.txt", "w")
archivo = abrir_archivo()
archivo.write.assert_called_once_with("resultado final")
mock_open ya prepara el comportamiento de context manager.
Algunos objetos archivo se recorren línea por línea:
def contar_lineas_con_error(archivo):
total = 0
for linea in archivo:
if "ERROR" in linea:
total += 1
return total
Podemos probarlo con una lista real:
def test_contar_lineas_con_error():
archivo = [
"INFO Inicio",
"ERROR Fallo de conexión",
"ERROR Timeout",
]
assert contar_lineas_con_error(archivo) == 2
No hace falta mockear si un iterable real comunica mejor el escenario.
A veces una dependencia se usa así:
def obtener_total_desde_cliente(cliente):
with cliente.conectar() as conexion:
return conexion.consultar_total()
Prueba:
def test_obtener_total_desde_cliente():
cliente = Mock()
conexion = Mock()
conexion.consultar_total.return_value = 500
context_manager = MagicMock()
context_manager.__enter__.return_value = conexion
cliente.conectar.return_value = context_manager
total = obtener_total_desde_cliente(cliente)
assert total == 500
cliente.conectar.assert_called_once_with()
conexion.consultar_total.assert_called_once_with()
La configuración es más compleja porque hay una llamada que devuelve un context manager.
Cuando la configuración tiene muchos niveles, la prueba puede volverse difícil de leer. En ese caso, una clase fake pequeña puede ser mejor.
class ConexionFake:
def __init__(self, total):
self.total = total
def consultar_total(self):
return self.total
class ClienteFake:
def __init__(self, total):
self.conexion = ConexionFake(total)
def conectar(self):
return self
def __enter__(self):
return self.conexion
def __exit__(self, exc_type, exc, traceback):
return None
El fake tiene más líneas, pero puede expresar mejor el comportamiento cuando se reutiliza en varias pruebas.
Uso del fake:
def test_obtener_total_desde_cliente_con_fake():
cliente = ClienteFake(total=500)
total = obtener_total_desde_cliente(cliente)
assert total == 500
La prueba queda muy simple. La decisión entre MagicMock y fake depende de cuál comunique mejor el escenario.
__len__: usado por len(objeto).__iter__: usado en ciclos for.__getitem__: usado por objeto[indice].__contains__: usado por valor in objeto.__enter__ y __exit__: usados por with.Prueba esta función usando MagicMock:
def procesar_archivo(abrir_archivo):
with abrir_archivo("datos.txt") as archivo:
lineas = archivo.readlines()
return [linea.strip().upper() for linea in lineas]
Haz que el archivo devuelva las líneas "ana\n" y "luis\n", y verifica el resultado.
Una solución con MagicMock:
from unittest.mock import MagicMock, Mock
from archivos import procesar_archivo
def test_procesar_archivo():
archivo = Mock()
archivo.readlines.return_value = ["ana\n", "luis\n"]
context_manager = MagicMock()
context_manager.__enter__.return_value = archivo
context_manager.__exit__.return_value = None
abrir_archivo = Mock(return_value=context_manager)
resultado = procesar_archivo(abrir_archivo)
assert resultado == ["ANA", "LUIS"]
abrir_archivo.assert_called_once_with("datos.txt")
archivo.readlines.assert_called_once_with()
También podría resolverse con mock_open, pero aquí el objetivo es practicar __enter__ y __exit__.
MagicMock es útil para simular objetos que participan en operaciones especiales de Python: longitud, iteración, acceso por índice y context managers. Aun así, cuando una lista real, un diccionario o un fake pequeño expresan mejor el escenario, conviene usarlos.
En el próximo tema veremos mocking con pytest: monkeypatch, fixtures y parametrización.