28. Pruebas de aceptación pequeñas como guía del desarrollo

28.1 Objetivo del tema

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.

28.2 Qué es una prueba de aceptación pequeña

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 de aceptación pequeña no prueba cada clase interna: prueba que un caso de uso cumple una necesidad observable.

28.3 Diferencia con una prueba end-to-end

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.

28.4 Historia de usuario del ejemplo

Trabajaremos con esta historia:

Como visitante, quiero registrarme con nombre, email y contraseña para poder crear una cuenta.

Criterio de aceptación inicial: si los datos son válidos, el usuario queda guardado y se envía un correo de bienvenida.

28.5 Primera prueba de aceptación

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.

28.6 Qué guía esta prueba

La prueba de aceptación nos da una dirección clara:

  • Debe existir un caso de uso RegistrarUsuario.
  • Debe recibir datos de registro.
  • Debe guardar un usuario sin exponer la contraseña.
  • Debe enviar un correo de bienvenida.
  • Debe devolver un resultado comprensible.

Todavía no decide todos los detalles internos. Solo fija el comportamiento esperado.

28.7 Implementación mínima para la aceptación

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.

28.8 Agregar un criterio de aceptación de error

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.

28.9 Prueba de email duplicado

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.

28.10 Implementar el criterio

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.

28.11 Cuándo bajar a pruebas unitarias

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.

La prueba de aceptación guía el flujo completo. Las pruebas unitarias detallan reglas específicas cuando el comportamiento empieza a ramificarse.

28.12 Prueba unitaria para validación

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.

28.13 Implementar el validador

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.

28.14 Integrar la validación al caso de uso

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.

28.15 Código del caso de uso con validación

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.

28.16 Mantener las pruebas de aceptación pequeñas

Una prueba de aceptación puede volverse demasiado grande si intenta cubrir todos los casos posibles. Conviene reservarla para caminos importantes del negocio.

  • Un camino feliz representativo.
  • Un error crítico del flujo.
  • Una regla que involucra varios colaboradores.
  • Un criterio que el negocio usaría para aceptar la historia.

28.17 Qué no conviene hacer

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.

28.18 Relación con outside-in

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.

28.19 Señales de una buena prueba de aceptación pequeña

  • Se entiende desde el lenguaje del usuario o del negocio.
  • Ejecuta un comportamiento completo pero acotado.
  • No depende de servicios externos reales si no es necesario.
  • Verifica efectos observables importantes.
  • Deja claro qué falta construir cuando falla.

28.20 Señales de exceso

  • La prueba tarda mucho para un ciclo de TDD cotidiano.
  • Necesita demasiada preparación técnica.
  • Falla por detalles de infraestructura y no por reglas de negocio.
  • Duplica todos los casos que ya están cubiertos por pruebas unitarias.
  • Es difícil leer cuál es el criterio de aceptación.

28.21 Ejercicio práctico

Construí con TDD una funcionalidad de recuperación de contraseña.

  1. Escribí una prueba de aceptación pequeña para solicitar recuperación con email existente.
  2. Usá un repositorio en memoria y un correo spy.
  3. Verificá que se genere un token y se envíe un correo.
  4. Agregá una prueba para email inexistente.
  5. Extraé pruebas unitarias si la generación de token o las validaciones crecen.

28.22 Checklist del tema

  • La prueba de aceptación guía una funcionalidad completa pero acotada.
  • Las dependencias externas pueden reemplazarse por fakes o spies.
  • Las reglas específicas pueden bajar a pruebas unitarias.
  • El ciclo TDD sigue siendo rápido.
  • La prueba expresa criterios de aceptación, no detalles internos.

28.23 Conclusión

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.