7. Usar fakes en memoria para probar repositorios y servicios

7.1 Objetivo del tema

En el tema anterior reemplazamos consultas a una base de datos por stubs simples. Ahora veremos un caso donde necesitamos algo más: una dependencia que conserve estado durante la prueba.

Para eso usaremos fakes en memoria. Un fake es una implementación funcional, pero simplificada, que permite probar servicios sin depender de infraestructura real.

Objetivo práctico: crear fakes en memoria para probar código que guarda, busca y modifica datos.

7.2 Cuándo un stub ya no alcanza

Un stub es ideal cuando solo necesitamos que una dependencia devuelva una respuesta preparada. Pero hay pruebas donde el código primero guarda datos y luego necesita consultarlos o verificar su estado final.

En esos casos, crear muchos stubs diferentes puede volverse incómodo. Un fake en memoria permite simular una pequeña parte del comportamiento real de la dependencia.

7.3 Servicio de ejemplo

Supongamos que tenemos un servicio para crear tareas:

class ServicioTareas:
    def __init__(self, repositorio_tareas):
        self.repositorio_tareas = repositorio_tareas

    def crear_tarea(self, titulo):
        if not titulo.strip():
            raise ValueError("El título es obligatorio")

        tarea = {
            "titulo": titulo,
            "completada": False,
        }

        return self.repositorio_tareas.guardar(tarea)

    def completar_tarea(self, tarea_id):
        tarea = self.repositorio_tareas.buscar_por_id(tarea_id)

        if tarea is None:
            return False

        tarea["completada"] = True
        self.repositorio_tareas.guardar(tarea)
        return True

Este servicio necesita un repositorio que pueda guardar y buscar tareas. Un fake en memoria encaja bien.

7.4 Crear un fake de repositorio

Una implementación simple en memoria puede ser:

class RepositorioTareasFake:
    def __init__(self):
        self.tareas = {}
        self.proximo_id = 1

    def guardar(self, tarea):
        if "id" not in tarea:
            tarea = tarea.copy()
            tarea["id"] = self.proximo_id
            self.proximo_id += 1

        self.tareas[tarea["id"]] = tarea
        return tarea["id"]

    def buscar_por_id(self, tarea_id):
        return self.tareas.get(tarea_id)

El fake no usa SQL ni archivos. Conserva datos en un diccionario y genera identificadores simples.

7.5 Prueba de creación con fake

Ahora podemos probar que el servicio guarda una tarea nueva:

from tareas import ServicioTareas


def test_crear_tarea_guarda_tarea_pendiente():
    repositorio = RepositorioTareasFake()
    servicio = ServicioTareas(repositorio)

    tarea_id = servicio.crear_tarea("Preparar informe")

    tarea_guardada = repositorio.buscar_por_id(tarea_id)
    assert tarea_guardada["titulo"] == "Preparar informe"
    assert tarea_guardada["completada"] is False

La prueba observa el estado guardado en el fake. No necesita una base de datos real.

7.6 Probar validaciones

También podemos probar que no se aceptan títulos vacíos:

import pytest


def test_crear_tarea_rechaza_titulo_vacio():
    repositorio = RepositorioTareasFake()
    servicio = ServicioTareas(repositorio)

    with pytest.raises(ValueError):
        servicio.crear_tarea("   ")

    assert repositorio.tareas == {}

El fake permite verificar que no se guardó nada cuando la validación falló.

7.7 Probar cambios de estado

El método completar_tarea necesita buscar una tarea, modificarla y guardarla. Este flujo es más natural con un fake:

def test_completar_tarea_existente():
    repositorio = RepositorioTareasFake()
    servicio = ServicioTareas(repositorio)
    tarea_id = repositorio.guardar({
        "titulo": "Enviar factura",
        "completada": False,
    })

    resultado = servicio.completar_tarea(tarea_id)

    assert resultado is True
    assert repositorio.buscar_por_id(tarea_id)["completada"] is True

El fake conserva el estado antes y después de ejecutar el método.

7.8 Probar entidad inexistente

También podemos probar qué ocurre si la tarea no existe:

def test_completar_tarea_inexistente_devuelve_false():
    repositorio = RepositorioTareasFake()
    servicio = ServicioTareas(repositorio)

    resultado = servicio.completar_tarea(999)

    assert resultado is False

El fake empieza vacío, por lo que cualquier identificador será inexistente salvo que la prueba lo haya guardado antes.

7.9 Evitar compartir fakes entre pruebas

Cada prueba debe crear su propio fake. Si varias pruebas comparten el mismo objeto, el estado de una puede afectar a otra.

Esto es peligroso porque las pruebas pueden pasar o fallar según el orden de ejecución.

Un fake con estado debe crearse de nuevo en cada prueba o reiniciarse de manera explícita.

7.10 Usar fixtures de pytest

Si muchas pruebas necesitan el mismo armado, podemos usar una fixture:

import pytest


@pytest.fixture
def repositorio_tareas():
    return RepositorioTareasFake()


@pytest.fixture
def servicio_tareas(repositorio_tareas):
    return ServicioTareas(repositorio_tareas)


def test_crear_tarea_con_fixture(servicio_tareas, repositorio_tareas):
    tarea_id = servicio_tareas.crear_tarea("Revisar contrato")

    assert repositorio_tareas.buscar_por_id(tarea_id)["titulo"] == "Revisar contrato"

Por defecto, pytest crea una nueva instancia de la fixture para cada prueba, lo que ayuda a evitar contaminación de estado.

7.11 Fake con datos iniciales

Podemos permitir que el fake reciba datos iniciales para preparar escenarios:

class RepositorioTareasFake:
    def __init__(self, tareas=None):
        self.tareas = {}
        self.proximo_id = 1

        for tarea in tareas or []:
            self.guardar(tarea)

    def guardar(self, tarea):
        if "id" not in tarea:
            tarea = tarea.copy()
            tarea["id"] = self.proximo_id
            self.proximo_id += 1

        self.tareas[tarea["id"]] = tarea
        return tarea["id"]

    def buscar_por_id(self, tarea_id):
        return self.tareas.get(tarea_id)

Esto puede hacer que algunas pruebas sean más expresivas.

7.12 Prueba con datos iniciales

Ejemplo:

def test_completar_tarea_cargada_inicialmente():
    repositorio = RepositorioTareasFake([
        {"id": 10, "titulo": "Publicar artículo", "completada": False}
    ])
    servicio = ServicioTareas(repositorio)

    resultado = servicio.completar_tarea(10)

    assert resultado is True
    assert repositorio.buscar_por_id(10)["completada"] is True

La prueba declara el estado inicial de forma directa, sin ejecutar SQL ni preparar una base.

7.13 Cuidado con copias y mutabilidad

Cuando un fake guarda diccionarios u objetos mutables, hay que decidir si devuelve la misma referencia o una copia. Devolver la misma referencia puede producir efectos laterales en pruebas complejas.

Una versión más defensiva puede copiar los datos:

class RepositorioTareasFake:
    def __init__(self):
        self.tareas = {}
        self.proximo_id = 1

    def guardar(self, tarea):
        tarea_guardada = tarea.copy()

        if "id" not in tarea_guardada:
            tarea_guardada["id"] = self.proximo_id
            self.proximo_id += 1

        self.tareas[tarea_guardada["id"]] = tarea_guardada
        return tarea_guardada["id"]

    def buscar_por_id(self, tarea_id):
        tarea = self.tareas.get(tarea_id)
        return None if tarea is None else tarea.copy()

Esta decisión depende del comportamiento que quieras simular y de la claridad de las pruebas.

7.14 Fake de servicio externo

Los fakes no son solo para repositorios. También podemos crear fakes de servicios externos cuando necesitamos conservar estado.

class ServicioEmailFake:
    def __init__(self):
        self.emails_enviados = []

    def enviar(self, destino, asunto, cuerpo):
        self.emails_enviados.append({
            "destino": destino,
            "asunto": asunto,
            "cuerpo": cuerpo,
        })
        return True

Este fake no envía correos reales, pero conserva los mensajes que el sistema intentó enviar.

7.15 Probar un servicio con repositorio y email fake

Ejemplo:

class ServicioUsuarios:
    def __init__(self, repositorio_usuarios, servicio_email):
        self.repositorio_usuarios = repositorio_usuarios
        self.servicio_email = servicio_email

    def registrar_usuario(self, email):
        usuario_id = self.repositorio_usuarios.guardar({"email": email})
        self.servicio_email.enviar(
            destino=email,
            asunto="Bienvenido",
            cuerpo="Tu cuenta fue creada",
        )
        return usuario_id

La prueba puede verificar estado guardado y correo registrado:

def test_registrar_usuario_guarda_y_envia_bienvenida():
    repositorio = RepositorioUsuariosFake()
    email = ServicioEmailFake()
    servicio = ServicioUsuarios(repositorio, email)

    usuario_id = servicio.registrar_usuario("ana@example.com")

    assert repositorio.buscar_por_id(usuario_id)["email"] == "ana@example.com"
    assert email.emails_enviados[0]["destino"] == "ana@example.com"
    assert email.emails_enviados[0]["asunto"] == "Bienvenido"

7.16 Implementar RepositorioUsuariosFake

El fake usado en el ejemplo anterior puede ser similar al de tareas:

class RepositorioUsuariosFake:
    def __init__(self):
        self.usuarios = {}
        self.proximo_id = 1

    def guardar(self, usuario):
        usuario = usuario.copy()

        if "id" not in usuario:
            usuario["id"] = self.proximo_id
            self.proximo_id += 1

        self.usuarios[usuario["id"]] = usuario
        return usuario["id"]

    def buscar_por_id(self, usuario_id):
        usuario = self.usuarios.get(usuario_id)
        return None if usuario is None else usuario.copy()

Este fake es útil en varias pruebas mientras conserve un contrato simple y claro.

7.17 Límites de los fakes

Un fake no reemplaza todas las pruebas contra la dependencia real. Un repositorio en memoria no se comporta exactamente igual que PostgreSQL, MySQL o SQLite. No valida SQL, índices, restricciones, transacciones ni diferencias propias del motor.

Por eso los fakes sirven para pruebas unitarias o de servicio, pero deben complementarse con pruebas de integración cuando la persistencia real es importante.

7.18 Buenas prácticas para fakes

  • Mantén el fake pequeño y enfocado en los métodos que las pruebas necesitan.
  • No implementes reglas de negocio dentro del fake.
  • Crea una instancia nueva por prueba.
  • Evita reproducir detalles internos de la infraestructura real.
  • Usa nombres explícitos como RepositorioTareasFake o ServicioEmailFake.

7.19 Ejercicio práctico

Crea un fake para probar este servicio:

class ServicioCarrito:
    def __init__(self, repositorio_carritos):
        self.repositorio_carritos = repositorio_carritos

    def agregar_producto(self, usuario_id, producto, cantidad):
        carrito = self.repositorio_carritos.buscar_por_usuario(usuario_id)

        if carrito is None:
            carrito = {"usuario_id": usuario_id, "items": []}

        carrito["items"].append({
            "producto": producto,
            "cantidad": cantidad,
        })
        self.repositorio_carritos.guardar(carrito)
        return carrito

El fake debe permitir buscar un carrito por usuario y guardar carritos en memoria.

7.20 Solución posible del ejercicio

Una solución posible:

class RepositorioCarritosFake:
    def __init__(self):
        self.carritos_por_usuario = {}

    def buscar_por_usuario(self, usuario_id):
        carrito = self.carritos_por_usuario.get(usuario_id)
        return None if carrito is None else {
            "usuario_id": carrito["usuario_id"],
            "items": list(carrito["items"]),
        }

    def guardar(self, carrito):
        self.carritos_por_usuario[carrito["usuario_id"]] = {
            "usuario_id": carrito["usuario_id"],
            "items": list(carrito["items"]),
        }

Y una prueba:

def test_agregar_producto_crea_carrito_si_no_existe():
    repositorio = RepositorioCarritosFake()
    servicio = ServicioCarrito(repositorio)

    carrito = servicio.agregar_producto("USR-1", "Teclado", 2)

    assert carrito["items"] == [
        {"producto": "Teclado", "cantidad": 2}
    ]
    assert repositorio.buscar_por_usuario("USR-1")["items"] == [
        {"producto": "Teclado", "cantidad": 2}
    ]

7.21 Conclusión

Un fake en memoria es útil cuando una prueba necesita una dependencia con estado y comportamiento simple. Permite probar servicios que guardan, consultan y modifican datos sin depender de una base real o un servicio externo.

En el próximo tema comenzaremos a usar unittest.mock, la biblioteca estándar de Python para crear mocks y objetos configurables de manera más rápida.