En este tema veremos cómo usar pruebas de aceptación pequeñas para guiar el desarrollo con TDD. Una prueba de aceptación expresa un comportamiento valioso desde el punto de vista del usuario o del negocio, pero no necesariamente tiene que recorrer toda la aplicación real.
Vamos a construir una funcionalidad de registro de usuario usando una prueba de aceptación acotada como brújula y luego pruebas más pequeñas para completar el diseño.
Es una prueba que verifica que una funcionalidad cumple una regla de negocio completa, pero evita dependencias costosas o inestables cuando no son necesarias.
Una prueba end-to-end suele recorrer la interfaz, la red, la base de datos y otros sistemas reales. Una prueba de aceptación pequeña puede probar el mismo criterio de negocio desde un límite más cercano, como un caso de uso o una función de aplicación.
Esto permite usarla durante TDD sin volver lento cada ciclo rojo, verde y refactor.
Trabajaremos con esta historia:
Criterio de aceptación inicial: si los datos son válidos, el usuario queda guardado y se envía un correo de bienvenida.
La prueba usa el caso de uso completo, pero con dependencias en memoria para no depender de una base de datos ni de un servicio de correo real.
Archivo a crear: tests/test_registro_aceptacion.py
from registrar_usuario import RegistrarUsuario
class RepositorioUsuariosEnMemoria:
def __init__(self):
self.usuarios = []
def guardar(self, usuario):
self.usuarios.append(usuario)
class CorreoBienvenidaSpy:
def __init__(self):
self.enviados = []
def enviar(self, email):
self.enviados.append(email)
def test_visitante_se_registra_con_datos_validos():
repositorio = RepositorioUsuariosEnMemoria()
correo = CorreoBienvenidaSpy()
registrar = RegistrarUsuario(repositorio, correo)
resultado = registrar.ejecutar({
"nombre": "Ana",
"email": "ana@example.com",
"password": "secreto123",
})
assert resultado == {"estado": "registrado"}
assert repositorio.usuarios == [
{
"nombre": "Ana",
"email": "ana@example.com",
}
]
assert correo.enviados == ["ana@example.com"]
Ejecutamos python -m pytest. La prueba fallará porque todavía no existe el caso
de uso.
La prueba de aceptación nos da una dirección clara:
RegistrarUsuario.Todavía no decide todos los detalles internos. Solo fija el comportamiento esperado.
Escribimos el código más directo para pasar la prueba.
Archivo a crear: src/registrar_usuario.py
class RegistrarUsuario:
def __init__(self, repositorio, correo):
self.repositorio = repositorio
self.correo = correo
def ejecutar(self, datos):
usuario = {
"nombre": datos["nombre"],
"email": datos["email"],
}
self.repositorio.guardar(usuario)
self.correo.enviar(datos["email"])
return {"estado": "registrado"}
Si la prueba pasa, ya tenemos un flujo completo mínimo. A partir de ahí podemos agregar criterios de aceptación o bajar a pruebas unitarias para reglas más específicas.
Nuevo criterio: si el email ya existe, el registro debe rechazarse y no debe enviarse correo.
Archivo a modificar: tests/test_registro_aceptacion.py
class RepositorioUsuariosEnMemoria:
def __init__(self):
self.usuarios = []
def guardar(self, usuario):
self.usuarios.append(usuario)
def existe_email(self, email):
return any(
usuario["email"] == email
for usuario in self.usuarios
)
Primero ampliamos el fake porque el caso de uso necesitará consultar emails existentes.
Escribimos el comportamiento esperado.
Archivo a modificar: tests/test_registro_aceptacion.py
def test_no_permite_registrar_email_duplicado():
repositorio = RepositorioUsuariosEnMemoria()
correo = CorreoBienvenidaSpy()
repositorio.guardar({
"nombre": "Ana",
"email": "ana@example.com",
})
registrar = RegistrarUsuario(repositorio, correo)
resultado = registrar.ejecutar({
"nombre": "Ana",
"email": "ana@example.com",
"password": "secreto123",
})
assert resultado == {
"estado": "rechazado",
"errores": ["El email ya está registrado"],
}
assert correo.enviados == []
Esta prueba sigue siendo de aceptación pequeña: verifica un criterio completo sin base de datos real.
Agregamos la verificación al caso de uso.
Archivo a modificar: src/registrar_usuario.py
def ejecutar(self, datos):
if self.repositorio.existe_email(datos["email"]):
return {
"estado": "rechazado",
"errores": ["El email ya está registrado"],
}
usuario = {
"nombre": datos["nombre"],
"email": datos["email"],
}
self.repositorio.guardar(usuario)
self.correo.enviar(datos["email"])
return {"estado": "registrado"}
Ejecutamos toda la suite. Ambos criterios de aceptación deben quedar en verde.
Si la validación de datos empieza a crecer, no conviene hacer todos los casos desde la prueba de aceptación. Podemos extraer un validador y probar sus reglas de manera más directa.
Agregamos una regla específica: la contraseña debe tener al menos 8 caracteres.
Archivo a crear: tests/test_validar_registro.py
from validar_registro import validar_datos_registro
def test_password_debe_tener_al_menos_ocho_caracteres():
errores = validar_datos_registro({
"nombre": "Ana",
"email": "ana@example.com",
"password": "abc",
})
assert errores == ["La contraseña debe tener al menos 8 caracteres"]
Esta prueba es más pequeña que la aceptación. No necesita repositorio ni correo.
Creamos la función con la regla solicitada.
Archivo a crear: src/validar_registro.py
def validar_datos_registro(datos):
errores = []
if len(datos["password"]) < 8:
errores.append("La contraseña debe tener al menos 8 caracteres")
return errores
Ejecutamos python -m pytest. La regla queda protegida con una prueba enfocada.
Ahora conectamos el validador con la prueba de aceptación agregando un criterio completo.
Archivo a modificar: tests/test_registro_aceptacion.py
def test_no_registra_usuario_con_password_invalida():
repositorio = RepositorioUsuariosEnMemoria()
correo = CorreoBienvenidaSpy()
registrar = RegistrarUsuario(repositorio, correo)
resultado = registrar.ejecutar({
"nombre": "Ana",
"email": "ana@example.com",
"password": "abc",
})
assert resultado == {
"estado": "rechazado",
"errores": ["La contraseña debe tener al menos 8 caracteres"],
}
assert repositorio.usuarios == []
assert correo.enviados == []
Esta prueba asegura que el caso de uso respeta la validación y no genera efectos secundarios cuando los datos son inválidos.
Integramos el validador al inicio del flujo.
Archivo a modificar: src/registrar_usuario.py
from validar_registro import validar_datos_registro
class RegistrarUsuario:
def __init__(self, repositorio, correo):
self.repositorio = repositorio
self.correo = correo
def ejecutar(self, datos):
errores = validar_datos_registro(datos)
if errores:
return {
"estado": "rechazado",
"errores": errores,
}
if self.repositorio.existe_email(datos["email"]):
return {
"estado": "rechazado",
"errores": ["El email ya está registrado"],
}
usuario = {
"nombre": datos["nombre"],
"email": datos["email"],
}
self.repositorio.guardar(usuario)
self.correo.enviar(datos["email"])
return {"estado": "registrado"}
La prueba de aceptación sigue cubriendo el flujo; la prueba unitaria cubre el detalle de la regla.
Una prueba de aceptación puede volverse demasiado grande si intenta cubrir todos los casos posibles. Conviene reservarla para caminos importantes del negocio.
No conviene convertir la prueba de aceptación en una lista enorme de detalles internos.
Ejemplo a evitar:
assert repositorio.usuarios[0]["nombre"] == "Ana"
assert repositorio.usuarios[0]["email"] == "ana@example.com"
assert len(repositorio.usuarios) == 1
assert correo.enviados[0] == "ana@example.com"
assert resultado["estado"] == "registrado"
Algunas verificaciones pueden ser necesarias, pero si la prueba crece demasiado pierde su función de guía y se vuelve costosa de mantener.
Las pruebas de aceptación pequeñas combinan bien con TDD outside-in. Empezamos desde un comportamiento visible, dejamos que aparezcan colaboradores y luego bajamos a pruebas más específicas cuando el diseño lo necesita.
La diferencia es que mantenemos el alcance bajo control para que el ciclo de TDD siga siendo rápido.
Construí con TDD una funcionalidad de recuperación de contraseña.
Las pruebas de aceptación pequeñas ayudan a orientar el desarrollo sin pagar el costo de una prueba end-to-end completa en cada ciclo. Funcionan como una guía de negocio: marcan qué debe lograr el sistema y permiten completar el diseño con pruebas más pequeñas cuando hace falta.
En el próximo tema veremos cómo dividir una historia de usuario en pruebas ejecutables.