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.
Estas señales suelen indicar que conviene refactorizar:
patch para ejecutar un caso simple.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.
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.
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.
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"
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.
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.
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.
Una buena regla es separar el código de infraestructura del código de negocio:
Los mocks suelen ser más simples cuando las reglas de negocio no están mezcladas con infraestructura.
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.
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.
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.
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.
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.
No hace falta reescribir todo de una vez. Puedes avanzar así:
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.
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.
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.