28. Refactorizar código difícil de mockear para mejorar su diseño

28.1 Objetivo del tema

Cuando una prueba necesita muchos parches, mocks anidados o preparación complicada, a veces el problema no está en la prueba sino en el diseño del código. El mocking puede revelar dependencias ocultas, responsabilidades mezcladas y efectos secundarios difíciles de controlar.

En este tema veremos refactorizaciones pequeñas para transformar código difícil de mockear en código más claro, modular y testeable.

Objetivo práctico: mejorar el diseño del código para que las pruebas necesiten menos trucos y expresen mejor el comportamiento.

28.2 Señales de código difícil de mockear

Estas señales suelen indicar que conviene refactorizar:

  • La función crea conexiones, clientes HTTP o servicios dentro de su cuerpo.
  • La prueba necesita varios patch para ejecutar un caso simple.
  • El constructor de una clase hace llamadas externas.
  • La lógica de negocio está mezclada con lectura de archivos, red o base de datos.
  • La prueba debe conocer demasiados detalles internos para preparar el escenario.

28.3 Caso inicial difícil de probar

Supongamos este código:

import requests
from uuid import uuid4
from datetime import datetime


def registrar_pedido(cliente_id, items):
    respuesta = requests.get(
        f"https://api.example.com/clientes/{cliente_id}",
        timeout=5,
    )
    respuesta.raise_for_status()
    cliente = respuesta.json()

    total = sum(item["precio"] * item["cantidad"] for item in items)
    pedido = {
        "id": str(uuid4()),
        "cliente_id": cliente_id,
        "email": cliente["email"],
        "items": items,
        "total": total,
        "creado_en": datetime.now().isoformat(),
    }

    requests.post(
        "https://api.example.com/pedidos",
        json=pedido,
        timeout=5,
    ).raise_for_status()

    return pedido

Esta función calcula, consulta HTTP, genera identificadores, usa la hora actual y guarda por HTTP. Todo está mezclado.

28.4 Prueba posible pero incómoda

Para probarla sin red real habría que parchear varias cosas:

with patch("pedidos.requests.get") as get_mock, \
     patch("pedidos.requests.post") as post_mock, \
     patch("pedidos.uuid4") as uuid4_mock, \
     patch("pedidos.datetime") as datetime_mock:
    ...

La prueba puede funcionar, pero queda muy acoplada a la implementación. Refactorizar puede dar una prueba más clara.

28.5 Primer paso: separar cálculo puro

Extraemos el cálculo del total:

def calcular_total(items):
    return sum(item["precio"] * item["cantidad"] for item in items)

Ahora esa parte puede probarse sin mocks:

def test_calcular_total():
    items = [
        {"precio": 100, "cantidad": 2},
        {"precio": 50, "cantidad": 1},
    ]

    assert calcular_total(items) == 250

Una función pura no necesita dobles de prueba.

28.6 Segundo paso: separar construcción del pedido

Extraemos la creación del diccionario:

def construir_pedido(cliente_id, email, items, pedido_id, creado_en):
    return {
        "id": pedido_id,
        "cliente_id": cliente_id,
        "email": email,
        "items": items,
        "total": calcular_total(items),
        "creado_en": creado_en,
    }

La prueba controla todos los datos variables:

def test_construir_pedido():
    items = [{"precio": 100, "cantidad": 2}]

    pedido = construir_pedido(
        cliente_id="CLI-1",
        email="ana@example.com",
        items=items,
        pedido_id="PED-1",
        creado_en="2026-05-15T10:30:00",
    )

    assert pedido["total"] == 200
    assert pedido["id"] == "PED-1"

28.7 Tercer paso: crear un cliente externo

Encapsulamos las llamadas HTTP en una clase:

class ClientePedidosApi:
    def obtener_cliente(self, cliente_id):
        respuesta = requests.get(
            f"https://api.example.com/clientes/{cliente_id}",
            timeout=5,
        )
        respuesta.raise_for_status()
        return respuesta.json()

    def guardar_pedido(self, pedido):
        respuesta = requests.post(
            "https://api.example.com/pedidos",
            json=pedido,
            timeout=5,
        )
        respuesta.raise_for_status()

Ahora la lógica de negocio no necesita conocer requests.

28.8 Cuarto paso: inyectar dependencias

El servicio recibe el cliente externo, el generador de ids y el reloj:

class ServicioPedidos:
    def __init__(self, api, generar_id, obtener_ahora):
        self.api = api
        self.generar_id = generar_id
        self.obtener_ahora = obtener_ahora

    def registrar_pedido(self, cliente_id, items):
        cliente = self.api.obtener_cliente(cliente_id)
        pedido = construir_pedido(
            cliente_id=cliente_id,
            email=cliente["email"],
            items=items,
            pedido_id=self.generar_id(),
            creado_en=self.obtener_ahora().isoformat(),
        )
        self.api.guardar_pedido(pedido)
        return pedido

El comportamiento es el mismo, pero las dependencias ahora son explícitas.

28.9 Prueba con stub y spy

Podemos probar el servicio sin patch:

from datetime import datetime


class PedidosApiSpyStub:
    def __init__(self, cliente):
        self.cliente = cliente
        self.pedidos_guardados = []

    def obtener_cliente(self, cliente_id):
        return self.cliente

    def guardar_pedido(self, pedido):
        self.pedidos_guardados.append(pedido)


def test_registrar_pedido():
    api = PedidosApiSpyStub({"email": "ana@example.com"})
    servicio = ServicioPedidos(
        api=api,
        generar_id=lambda: "PED-1",
        obtener_ahora=lambda: datetime(2026, 5, 15, 10, 30, 0),
    )

    pedido = servicio.registrar_pedido(
        "CLI-1",
        [{"precio": 100, "cantidad": 2}],
    )

    assert pedido["id"] == "PED-1"
    assert pedido["total"] == 200
    assert api.pedidos_guardados == [pedido]

La prueba muestra el escenario sin conocer detalles de requests, uuid4 ni datetime.now.

28.10 Separar infraestructura de negocio

Una buena regla es separar el código de infraestructura del código de negocio:

  • Infraestructura: HTTP, base de datos, archivos, colas, frameworks.
  • Negocio: reglas, cálculos, decisiones, estados y validaciones.

Los mocks suelen ser más simples cuando las reglas de negocio no están mezcladas con infraestructura.

Si para probar una regla de negocio necesitas simular red, reloj, uuid y archivos, probablemente hay responsabilidades mezcladas.

28.11 Constructores sin efectos externos

Evita que un constructor abra conexiones o haga llamadas externas:

class ServicioReportes:
    def __init__(self):
        self.conexion = conectar_base_de_datos()
        self.email = ServicioEmail()

Es más testeable recibir dependencias:

class ServicioReportes:
    def __init__(self, repositorio, email):
        self.repositorio = repositorio
        self.email = email

La creación real puede quedar en una función de armado.

28.12 Función de armado para producción

Ejemplo:

def crear_servicio_reportes():
    repositorio = RepositorioReportesPostgres(conectar_base_de_datos())
    email = ServicioEmailSmtp()
    return ServicioReportes(repositorio, email)

Las pruebas de ServicioReportes no usan esta función. Usan stubs, fakes o mocks.

28.13 Reemplazar globales por parámetros

El estado global dificulta las pruebas:

CONFIG = {"iva": 0.21}


def calcular_total(total):
    return total + total * CONFIG["iva"]

Una versión más explícita:

def calcular_total(total, iva):
    return total + total * iva

La prueba ya no necesita parchear configuración global.

28.14 Extraer adaptadores

Si una librería externa tiene una API incómoda para probar, crea un adaptador propio pequeño:

class EmailAdapter:
    def __init__(self, cliente_smtp):
        self.cliente_smtp = cliente_smtp

    def enviar_bienvenida(self, email):
        self.cliente_smtp.send(
            to=email,
            subject="Bienvenido",
            body="Tu cuenta fue creada",
        )

El resto del sistema depende de EmailAdapter o de una interfaz equivalente, no de todos los detalles de SMTP.

28.15 Evitar patch como diseño permanente

patch es útil para trabajar con código existente. Pero si todas las pruebas nuevas requieren parches complejos, conviene revisar el diseño.

Un objetivo razonable es que el código nuevo permita reemplazar dependencias por argumentos, constructores o adaptadores simples.

28.16 Refactorizar en pasos pequeños

No hace falta reescribir todo de una vez. Puedes avanzar así:

  • Extraer funciones puras para cálculos y validaciones.
  • Inyectar una dependencia en lugar de crearla internamente.
  • Separar construcción de mensajes o eventos.
  • Crear adaptadores para librerías externas.
  • Agregar fakes o stubs para probar la lógica sin infraestructura.

28.17 Ejercicio práctico

Refactoriza este código para que sea más fácil de probar:

import requests


def enviar_recordatorio(usuario_id):
    respuesta = requests.get(
        f"https://api.example.com/usuarios/{usuario_id}",
        timeout=5,
    )
    usuario = respuesta.json()

    requests.post(
        "https://api.example.com/emails",
        json={
            "destino": usuario["email"],
            "asunto": "Recordatorio",
        },
        timeout=5,
    )
    return True

La idea es separar la lógica de la comunicación HTTP y permitir stubs en la prueba.

28.18 Solución posible del ejercicio

Una refactorización posible:

class ServicioRecordatorios:
    def __init__(self, usuarios_api, email_api):
        self.usuarios_api = usuarios_api
        self.email_api = email_api

    def enviar_recordatorio(self, usuario_id):
        usuario = self.usuarios_api.obtener_usuario(usuario_id)
        self.email_api.enviar(
            destino=usuario["email"],
            asunto="Recordatorio",
        )
        return True

Prueba:

class UsuariosApiStub:
    def obtener_usuario(self, usuario_id):
        return {"id": usuario_id, "email": "ana@example.com"}


class EmailApiSpy:
    def __init__(self):
        self.enviados = []

    def enviar(self, destino, asunto):
        self.enviados.append({
            "destino": destino,
            "asunto": asunto,
        })


def test_enviar_recordatorio():
    usuarios_api = UsuariosApiStub()
    email_api = EmailApiSpy()
    servicio = ServicioRecordatorios(usuarios_api, email_api)

    resultado = servicio.enviar_recordatorio(1)

    assert resultado is True
    assert email_api.enviados == [
        {"destino": "ana@example.com", "asunto": "Recordatorio"}
    ]

La prueba ya no necesita red ni parches sobre requests.

28.19 Conclusión

El código difícil de mockear suele revelar dependencias ocultas o responsabilidades mezcladas. Refactorizar hacia funciones puras, dependencias explícitas, adaptadores y separación entre negocio e infraestructura mejora tanto el diseño como las pruebas.

En el próximo tema veremos una estrategia práctica para combinar pruebas reales, fakes, stubs y mocks.