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.
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.
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.
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.
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.
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ó.
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.
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.
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.
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.
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.
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.
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.
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.
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"
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.
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.
RepositorioTareasFake o ServicioEmailFake.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.
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}
]
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.