18. Mockear objetos con métodos mágicos, context managers e iteradores

18.1 Objetivo del tema

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.

Objetivo práctico: usar MagicMock para simular objetos que se comportan como colecciones, context managers o iteradores.

18.2 Qué son los 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__.

18.3 Importar MagicMock

MagicMock se importa desde unittest.mock:

from unittest.mock import MagicMock

Se comporta como un Mock, pero incluye métodos mágicos configurables.

18.4 Simular len

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

18.5 Simular iteración

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.

18.6 Cuándo usar una lista real

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.

No uses MagicMock para simular estructuras que Python ya ofrece de forma simple.

18.7 Simular acceso con corchetes

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.

18.8 Simular context managers

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.

18.9 Mockear __enter__ y __exit__

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.

18.10 Usar mock_open para archivos

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.

18.11 Context manager que escribe

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.

18.12 Simular iterador de líneas

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.

18.13 Simular llamadas encadenadas con context manager

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.

18.14 Cuidado con mocks demasiado anidados

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.

18.15 Prueba con fake de context manager

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.

18.16 Métodos mágicos frecuentes

  • __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.

18.17 Ejercicio práctico

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.

18.18 Solución posible del ejercicio

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__.

18.19 Conclusión

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.