6. Reemplazar consultas a una base de datos por un stub simple

6.1 Objetivo del tema

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.

Objetivo práctico: probar lógica que depende de datos persistidos sin crear tablas, conexiones ni registros reales.

6.2 Caso de ejemplo

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.

6.3 Cómo sería una dependencia real

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.

6.4 Crear un stub de repositorio

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.

6.5 Prueba para cliente VIP

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.

6.6 Probar otras categorías

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.

6.7 Probar cliente inexistente

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.

6.8 Stub con datos por identificador

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.

6.9 Prueba con varios clientes

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.

6.10 Diferencia entre stub y fake en este contexto

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.

Usa un stub cuando la prueba solo necesita respuestas controladas. Usa un fake cuando necesita una implementación en memoria con estado.

6.11 Evitar stubs que imitan demasiado a la base

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.

6.12 Qué no estamos probando

Cuando reemplazamos la base de datos por un stub, no estamos probando:

  • Que la consulta SQL sea correcta.
  • Que la conexión funcione.
  • Que los nombres de columnas coincidan.
  • Que las migraciones hayan creado la estructura esperada.
  • Que el motor de base de datos responda como esperamos.

Eso se verifica con pruebas de integración. La prueba con stub se concentra en la lógica que usa los datos.

6.13 Combinar pruebas unitarias e integración

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:

  • Pruebas unitarias de calcular_descuento usando RepositorioClientesStub.
  • Pruebas de integración de RepositorioClientesPostgres contra una base de prueba.

Así cada prueba tiene una responsabilidad clara.

6.14 Manejar errores del repositorio

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.

6.15 Traducir errores técnicos

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.

6.16 Prueba del error traducido

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.

6.17 Ejercicio práctico

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.

6.18 Solución posible del ejercicio

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.

6.19 Conclusión

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.