Muchas funciones de negocio necesitan datos almacenados en una base de datos. Para una prueba unitaria, no siempre queremos conectarnos a esa base real. En este tema veremos cómo reemplazar una consulta por un stub simple.
La clave será separar la lógica de negocio del mecanismo de persistencia. La prueba controlará qué devuelve el repositorio y verificará la decisión que toma el código.
Supongamos que una tienda aplica un descuento según la categoría del cliente. La función recibe un cliente_id, un total de compra y un repositorio capaz de buscar clientes:
def calcular_descuento(cliente_id, total, repositorio_clientes):
cliente = repositorio_clientes.buscar_por_id(cliente_id)
if cliente is None:
return 0
if cliente["categoria"] == "vip":
return total * 0.20
if cliente["categoria"] == "frecuente":
return total * 0.10
return 0
La lógica que queremos probar es el descuento. No necesitamos una base de datos para saber qué ocurre si el cliente es VIP, frecuente, común o inexistente.
Un repositorio real podría consultar una base de datos:
class RepositorioClientesPostgres:
def __init__(self, conexion):
self.conexion = conexion
def buscar_por_id(self, cliente_id):
cursor = self.conexion.cursor()
cursor.execute(
"SELECT id, nombre, categoria FROM clientes WHERE id = %s",
(cliente_id,),
)
fila = cursor.fetchone()
if fila is None:
return None
return {
"id": fila[0],
"nombre": fila[1],
"categoria": fila[2],
}
Esta clase puede necesitar una base configurada, tablas, conexión y datos cargados. Para una prueba unitaria de calcular_descuento, eso es demasiado.
El stub puede devolver un cliente preparado:
class RepositorioClientesStub:
def __init__(self, cliente):
self.cliente = cliente
def buscar_por_id(self, cliente_id):
return self.cliente
Este stub no sabe nada de SQL. Solo cumple el contrato que la función necesita: tener un método buscar_por_id.
Ahora podemos probar el descuento VIP:
from tienda.descuentos import calcular_descuento
class RepositorioClientesStub:
def __init__(self, cliente):
self.cliente = cliente
def buscar_por_id(self, cliente_id):
return self.cliente
def test_cliente_vip_recibe_descuento_del_20_por_ciento():
repositorio = RepositorioClientesStub({
"id": 1,
"nombre": "Ana",
"categoria": "vip",
})
descuento = calcular_descuento(1, 1000, repositorio)
assert descuento == 200
La prueba es corta y el escenario queda visible: el cliente es VIP y la compra total es 1000.
El mismo stub permite probar otros casos:
def test_cliente_frecuente_recibe_descuento_del_10_por_ciento():
repositorio = RepositorioClientesStub({
"id": 2,
"nombre": "Luis",
"categoria": "frecuente",
})
descuento = calcular_descuento(2, 1000, repositorio)
assert descuento == 100
def test_cliente_comun_no_recibe_descuento():
repositorio = RepositorioClientesStub({
"id": 3,
"nombre": "Marta",
"categoria": "comun",
})
descuento = calcular_descuento(3, 1000, repositorio)
assert descuento == 0
La base de datos no aporta valor a estas pruebas. Lo importante es controlar el cliente devuelto.
También podemos simular que el repositorio no encontró al cliente:
def test_cliente_inexistente_no_recibe_descuento():
repositorio = RepositorioClientesStub(None)
descuento = calcular_descuento(99, 1000, repositorio)
assert descuento == 0
Este caso suele ser importante porque muchas funciones deben comportarse correctamente cuando la consulta no devuelve resultados.
Si una prueba necesita manejar varios clientes, el stub puede usar un diccionario interno:
class RepositorioClientesPorIdStub:
def __init__(self, clientes):
self.clientes = clientes
def buscar_por_id(self, cliente_id):
return self.clientes.get(cliente_id)
Esto permite que el resultado dependa del cliente_id recibido, sin llegar a implementar una base de datos completa.
Ejemplo de uso:
def test_calcula_descuento_segun_cliente_consultado():
repositorio = RepositorioClientesPorIdStub({
1: {"id": 1, "nombre": "Ana", "categoria": "vip"},
2: {"id": 2, "nombre": "Luis", "categoria": "comun"},
})
assert calcular_descuento(1, 1000, repositorio) == 200
assert calcular_descuento(2, 1000, repositorio) == 0
assert calcular_descuento(99, 1000, repositorio) == 0
Este stub sigue siendo simple, pero ya permite representar un conjunto pequeño de datos.
Un stub de repositorio devuelve respuestas preparadas. Un fake de repositorio suele tener más comportamiento: guardar, actualizar, eliminar y consultar datos en memoria.
Para el ejemplo de calcular_descuento, alcanza con un stub porque solo necesitamos controlar una consulta. Si el código necesitara guardar cambios y luego consultarlos, probablemente sería mejor un fake.
Un stub no debería convertirse en una mini base de datos compleja. Si empieza a interpretar consultas, simular transacciones o reproducir reglas de SQL, la prueba puede volverse más difícil de mantener que el código real.
Conviene mantener los stubs en el nivel del contrato de aplicación. Por ejemplo: buscar_por_id, buscar_activos, obtener_total_comprado. No conviene hacer que las pruebas dependan de cadenas SQL internas.
Cuando reemplazamos la base de datos por un stub, no estamos probando:
Eso se verifica con pruebas de integración. La prueba con stub se concentra en la lógica que usa los datos.
Una estrategia práctica es escribir pruebas unitarias con stubs para cubrir reglas de negocio y algunas pruebas de integración para verificar el repositorio real.
Por ejemplo:
calcular_descuento usando RepositorioClientesStub.RepositorioClientesPostgres contra una base de prueba.Así cada prueba tiene una responsabilidad clara.
También podemos simular que la consulta falla:
class RepositorioClientesConErrorStub:
def buscar_por_id(self, cliente_id):
raise TimeoutError("La base de datos no respondió")
Esto permite probar cómo reacciona nuestra lógica ante un problema de persistencia.
Podemos decidir que la función traduzca el error técnico a una excepción propia:
class ClientesNoDisponiblesError(Exception):
pass
def calcular_descuento(cliente_id, total, repositorio_clientes):
try:
cliente = repositorio_clientes.buscar_por_id(cliente_id)
except TimeoutError as error:
raise ClientesNoDisponiblesError(
"No se pudieron consultar los clientes"
) from error
if cliente is None:
return 0
if cliente["categoria"] == "vip":
return total * 0.20
if cliente["categoria"] == "frecuente":
return total * 0.10
return 0
Con un stub que lanza TimeoutError, podemos probar este camino sin provocar un fallo real de base de datos.
La prueba puede ser:
import pytest
from tienda.descuentos import ClientesNoDisponiblesError
def test_si_la_base_no_responde_lanza_error_de_clientes_no_disponibles():
repositorio = RepositorioClientesConErrorStub()
with pytest.raises(ClientesNoDisponiblesError):
calcular_descuento(1, 1000, repositorio)
Otra vez, el stub permite reproducir un escenario que sería incómodo de generar con una base real.
Implementa pruebas para esta función:
def puede_publicar_articulo(usuario_id, repositorio_usuarios):
usuario = repositorio_usuarios.buscar_por_id(usuario_id)
if usuario is None:
return False
return usuario["rol"] in ["editor", "admin"]
Escribe pruebas para usuario editor, usuario administrador, usuario lector y usuario inexistente. Usa un stub de repositorio.
Una solución con un stub configurable:
from blog.permisos import puede_publicar_articulo
class RepositorioUsuariosStub:
def __init__(self, usuario):
self.usuario = usuario
def buscar_por_id(self, usuario_id):
return self.usuario
def test_editor_puede_publicar():
repositorio = RepositorioUsuariosStub({"rol": "editor"})
assert puede_publicar_articulo(1, repositorio) is True
def test_admin_puede_publicar():
repositorio = RepositorioUsuariosStub({"rol": "admin"})
assert puede_publicar_articulo(2, repositorio) is True
def test_lector_no_puede_publicar():
repositorio = RepositorioUsuariosStub({"rol": "lector"})
assert puede_publicar_articulo(3, repositorio) is False
def test_usuario_inexistente_no_puede_publicar():
repositorio = RepositorioUsuariosStub(None)
assert puede_publicar_articulo(99, repositorio) is False
Las pruebas cubren la regla de permisos sin conectarse a una base de datos.
Reemplazar una consulta a base de datos por un stub simple permite probar reglas de negocio de forma rápida y controlada. El stub devuelve datos preparados y evita depender de conexiones, tablas o registros reales.
En el próximo tema avanzaremos hacia fakes en memoria, útiles cuando necesitamos una dependencia con más comportamiento y estado interno.