Tests como documentación viva: una exploración sobre cómo especificar sistemas para la era de los LLMs
El punto de partida
Todo empezó con una pregunta aparentemente sencilla: ¿se puede usar Gherkin como lenguaje de especificación a nivel de arquitectura de negocio? La intuición era que, si Gherkin captura reglas de negocio en formato estructurado, debería servir como nexo entre lo que el negocio necesita y lo que el sistema tiene que hacer.
Y sí, Gherkin cumple parte de ese papel. Pero al tirar del hilo, nos dimos cuenta de que el problema real era otro más profundo y más interesante.
El problema de fondo
Cuando trabajamos con LLMs para generar código, nos encontramos con un problema recurrente: ¿cómo le damos contexto suficiente al modelo para que genere algo útil sin pasarle demasiada información que lo confunda?
El enfoque clásico es decirle al modelo “hazme una app de X” y esperar que interprete bien lo que queremos. Pero eso es ambiguo, impredecible, y el resultado rara vez coincide con lo que teníamos en mente.
Existe una corriente que propone darle la vuelta al proceso: en lugar de pedirle al LLM que genere código directamente, primero generas una especificación completa en forma de tests, y luego el modelo genera el código que pase esos tests. Es lo que algunos llaman Spec-Driven Development con LLMs. La idea es potente porque tienes un criterio objetivo y automatizable para saber si el código generado es correcto.
Pero aquí es donde las cosas se ponen interesantes.
¿Realmente necesitamos Gherkin?
Gherkin es un lenguaje fantástico cuando necesitas que personas no técnicas validen las reglas de negocio. Esa es su razón de ser: un puente entre negocio y desarrollo.
Pero si quien está especificando es una persona técnica — un desarrollador, un tech lead, un arquitecto que escribe código — Gherkin introduce una capa de indirección que quizás no necesitas. Pasas de Gherkin a step definitions, de step definitions a tests, de tests a código… cuando podrías ir directo.
¿Y si usamos el framework de testing nativo del lenguaje (JUnit, pytest, o lo que sea) pero tratándolo como herramienta de especificación? Al fin y al cabo, un test bien escrito ya captura una regla de negocio. La diferencia es que, además, define implícitamente las interfaces que el sistema necesita.
Cuando escribes un test como:
resultado = cliente.evaluar_nuevo_pedido(Pedido(monto=3_000))
assert resultado.estado == "RECHAZADO_POR_CREDITO"Ahí dentro ya hay una decisión de diseño: existe un Cliente con un método evaluar_nuevo_pedido que recibe un Pedido y devuelve un Resultado con un estado. Eso es un contrato. Y no necesitaste Gherkin para llegar a él.
La pieza que falta: documentación dentro de los tests
Los tests por sí solos capturan el qué pero no siempre el por qué. Un test te dice que si el crédito disponible es menor que el pedido, se rechaza. Pero no te dice el contexto de negocio detrás de esa regla, ni las precondiciones que se asumen, ni cómo se relaciona con otros dominios del sistema.
Y aquí es donde la cosa se pone realmente interesante: ¿qué pasa si enriquecemos los tests con documentación en formato Markdown dentro de los docstrings y comentarios?
La idea es que cada test no solo verifique un comportamiento, sino que lleve consigo toda la información de contexto que un arquitecto de negocio pondría en un documento de requisitos:
- El contexto de negocio y por qué existe esa regla
- Las precondiciones y postcondiciones
- Las entidades del dominio involucradas
- Las interfaces que se esperan
- Las dependencias con otros dominios
De esta forma, el test se convierte en un artefacto que es simultáneamente documentación de requisitos, especificación de interfaces y prueba automatizada. Todo en uno. Y lo más importante: no se desincroniza, porque si cambia el requisito, cambia el test, y la documentación se actualiza porque son lo mismo.
La estructura jerárquica: el test como documento
Cuando escribimos documentación en Confluence o en cualquier herramienta de documentación, seguimos un patrón natural: un índice general que se va desglosando en secciones cada vez más específicas. Vas de lo general a lo particular.
Los tests tradicionalmente no tienen esa jerarquía. Son archivos sueltos con funciones dentro. Y eso es un problema si quieres que funcionen como documentación navegable.
La solución que exploramos es usar los mecanismos que ya existen en los lenguajes de programación para crear esa jerarquía:
En Java (JUnit 5)
- La estructura de paquetes define la jerarquía de dominios
- Los
package-info.javaactúan como el índice y contexto de cada nivel (normalmente nadie los usa, pero resulta que son perfectos para esto) - Las clases de test con
@Nestedpermiten sub-jerarquías dentro de una feature cuando tiene sentido - Los Javadoc en formato Markdown documentan el contexto de negocio
En Python (pytest)
- La estructura de directorios define la jerarquía
- Los
__init__.pycon docstrings hacen el papel delpackage-info.java - Las clases de test agrupan escenarios relacionados
- Los docstrings en cada clase y método contienen la documentación en Markdown
Ejemplo de estructura
specs/
__init__.py ← PRD del sistema completo
gestion_clientes/
__init__.py ← contexto del dominio, entidades, dependencias
test_registro.py ← feature: registro de clientes
test_evaluacion_credito.py ← feature: evaluación crediticia
ciclo_vida/
__init__.py
test_activacion.py
test_suspension.py
pedidos/
__init__.py
test_crear_pedido.py
test_cancelacion.py
El __init__.py raíz es literalmente el PRD del sistema traducido a código:
"""
# PRD: Sistema de Gestión Comercial
## Visión
Sistema para gestión integral del ciclo comercial,
desde captación de clientes hasta facturación.
## Dominios
1. gestion_clientes/ - Registro, crédito, ciclo de vida
2. pedidos/ - Creación, aprobación, seguimiento
3. facturacion/ - Facturas y cobros
## Dependencias entre dominios
- Pedidos depende de Clientes (evaluación crédito)
- Facturación depende de Pedidos (pedidos aprobados)
## Glosario
- **Crédito disponible**: límite - sum(pendientes)
- **Pedido firme**: aprobado por crédito y stock
"""Y cada archivo de test es un capítulo de ese documento, con todo el detalle:
"""
## Evaluación de Crédito
### Contexto de negocio
Protección contra riesgo crediticio. Se evalúa
antes de aceptar cualquier nuevo pedido.
### Reglas
- Crédito disponible = límite - sum(pedidos pendientes)
- Si nuevo pedido excede disponible → rechazar
- Rechazo genera notificación al ejecutivo
### Interfaz esperada
- Cliente.evaluar_nuevo_pedido(pedido) → ResultadoEvaluacion
- ResultadoEvaluacion: estado, motivo, notificaciones
"""
class TestRechazoPorCredito:
"""
### Rechazo por crédito insuficiente
**Precondición:** crédito disponible < monto nuevo pedido
**Postcondición:** pedido rechazado + notificación
"""
def test_rechaza_pedido_sin_credito(self):
"""
**Escenario:** Cliente con $2,000 disponibles
intenta pedido por $3,000
**Resultado:**
- Estado: RECHAZADO_POR_CREDITO
- Se notifica al ejecutivo de cuenta
"""
cliente = Cliente("Acme", limite_credito=10_000)
cliente.registrar_pedido(Pedido(monto=8_000))
resultado = cliente.evaluar_nuevo_pedido(Pedido(monto=3_000))
assert resultado.estado == "RECHAZADO_POR_CREDITO"
assert any(
n.destino == "EJECUTIVO_CUENTA"
for n in resultado.notificaciones
)El flujo de trabajo con el LLM
Aquí es donde todo cobra sentido. El flujo sería el siguiente:
Paso 1: Especificar el PRD como __init__.py raíz
Escribes (o generas con el LLM) el documento general del sistema. Dominios, entidades principales, dependencias, glosario. Esto es lo que normalmente viviría en Confluence, pero ahora vive en el proyecto.
Paso 2: Desglosar en dominios
Cada dominio tiene su directorio con su __init__.py que detalla las entidades, reglas e interfaces de ese dominio.
Paso 3: Escribir los tests-spec de cada feature
Cada archivo de test especifica una feature completa con su contexto de negocio en los docstrings y los escenarios como tests. Los tests definen implícitamente las interfaces.
Paso 4: Pasar al LLM solo lo que necesita
Y aquí está la clave para el tema del contexto y los tokens. En lugar de pasarle todo el proyecto al LLM, le pasas:
- El
__init__.pyraíz → para que entienda el sistema completo (pocos tokens) - El
__init__.pydel dominio que vas a trabajar → para el contexto específico - El archivo de test concreto que necesitas que implemente → con las interfaces y reglas
El modelo recibe exactamente la información que necesita. No más, no menos. No tiene que cargar todo el contexto del sistema para implementar una feature específica, pero sí tiene la visión general si la necesita.
Paso 5: Verificar
Ejecutas los tests. Si pasan, el código cumple la especificación. Si no pasan, el LLM tiene un feedback concreto de qué falta o qué está mal. Le pasas el error y el test que falla, y tiene todo lo que necesita para corregirlo.
Por qué esto funciona mejor que los enfoques tradicionales
Frente a documentación clásica (Confluence, Word, etc.)
La documentación tradicional se desincroniza del código. Siempre. Por muy disciplinado que sea el equipo, llega un punto en que el documento dice una cosa y el sistema hace otra. Con este enfoque, la documentación es el test. Si el test pasa, la documentación es correcta. Si la documentación cambia, el test falla.
Frente a Gherkin/BDD tradicional
Gherkin añade una capa intermedia que tiene sentido cuando necesitas validación de personas no técnicas. Pero si el flujo es técnico de principio a fin, el framework de testing nativo te da lo mismo sin esa indirección. Y además, defines las interfaces directamente en el test, algo que Gherkin no hace.
Frente a pasarle prosa al LLM
Un documento en prosa es ambiguo. Un test es preciso. El LLM puede interpretar de muchas formas “el sistema debe validar el crédito del cliente”, pero no puede interpretar mal un test que dice assert resultado.estado == "RECHAZADO_POR_CREDITO". La especificación es verificable.
Frente a pasarle todo el código al LLM
Cuando le pasas todo el codebase a un LLM, estás consumiendo tokens en código de implementación que quizás no es relevante para lo que necesitas en ese momento. Con este enfoque, le pasas solo los tests (que son más concisos) y el modelo genera la implementación. Menos tokens, más foco, mejor resultado.
Lo que queda por explorar
Esto es una exploración, no un framework cerrado. Hay cosas que quedan abiertas:
-
Tooling para generar documentación navegable: los docstrings están ahí, pero falta una herramienta que los renderice como un documento tipo Confluence navegable. Algo que recorra la estructura de paquetes y genere una vista bonita.
-
Convenciones: ¿qué secciones debería tener siempre un docstring de módulo? ¿Y el de una clase de test? ¿Y el de un test individual? Esto necesita madurar con la práctica.
-
Escalabilidad: ¿cómo se comporta esto en un proyecto con cientos de features? La jerarquía de directorios ayuda, pero falta validarlo en proyectos grandes.
-
Modelado del dominio: los tests capturan bien las reglas y las interfaces, pero el modelo estructural del dominio (entidades, relaciones, cardinalidades) sigue necesitando algún tipo de representación visual o declarativa complementaria. Los tests lo capturan de forma implícita, pero no es suficiente para tener una visión global.
Conclusión: la tesis que emerge
Lo que hemos ido construyendo a lo largo de esta conversación es una idea que se puede resumir así:
Los tests, enriquecidos con documentación de negocio en formato Markdown dentro de sus docstrings, y organizados jerárquicamente siguiendo la estructura natural de un documento de requisitos, pueden funcionar como el artefacto central de especificación de un sistema. Este artefacto es simultáneamente documentación de arquitectura de negocio, especificación de interfaces y prueba automatizada. Y resulta especialmente valioso en el contexto actual porque proporciona a los LLMs exactamente el nivel de detalle y estructura que necesitan para generar código verificable, sin sobrecargar el contexto con información innecesaria.
Es como hacer BDD pero sin Gherkin, usando el framework nativo de testing del lenguaje, y amplificándolo con Markdown en los docstrings para que los tests cuenten la historia completa del negocio.
No pretende ser la solución definitiva. Es muy posible que la comunidad encuentre enfoques mejores, o que las herramientas evolucionen en direcciones que hagan esto innecesario. Pero a día de hoy, con las capacidades actuales de los LLMs y la necesidad de darles contexto preciso y verificable, nos parece que esta es una vía que merece la pena explorar.
Al final, la idea de que los tests deberían ser documentación no es nueva. Se ha dicho durante años. Lo que es nuevo es que ahora hay un consumidor muy concreto y muy exigente de esa documentación — los LLMs — que hace que invertir el esfuerzo en tener tests bien documentados tenga un retorno inmediato y tangible.