Módulo 1: Spec-First Methodology y TDD con AI

Spec-First Methodology: Tests como Especificación

Spec-First Methodology: Tests como Especificación

Descripción de la cápsula

En la cápsula anterior entendiste POR QUÉ TDD importa más con AI. Ahora vas a aprender el CÓMO: la metodología spec-first. La idea central es una inversión de control radical — en vez de decirle a Claude Code "implementa esto" en lenguaje natural (ambiguo), le dices "aquí están los tests que definen qué debe pasar" (preciso, ejecutable, verificable).

Spec-first no es una invención de esta guía. Está basado en el trabajo de Tweag con desarrollo asistido por LLMs, donde descubrieron que los tests son el lenguaje más efectivo para comunicar requisitos a un agente AI. Esta cápsula te enseña la metodología concreta: cómo invertir el control, qué cambia en tu forma de pensar, y cómo estructurar el workflow con Claude Code.

Al final, tendrás un framework mental claro: tú eres el arquitecto de comportamiento (defines qué debe pasar), Claude Code es el constructor (implementa cómo pasa). Los tests son el plano que conecta ambos roles.


La Inversión de Control

TDD clásico vs Agentic TDD

En TDD clásico, el developer hace todo:

TDD Clásico (sin AI):
┌────────────┐
│ Developer  │
│            │
│ 1. Escribe │─── test ──→ [Falla] RED
│    test    │
│            │
│ 2. Escribe │─── implementación ──→ [Pasa] GREEN
│    código  │
│            │
│ 3. Mejora  │─── refactor ──→ [Sigue pasando] REFACTOR
│    código  │
└────────────┘

En agentic TDD, los roles se dividen:

Agentic TDD (con Claude Code):
┌────────────┐          ┌─────────────┐
│ Developer  │          │ Claude Code │
│            │          │             │
│ 1. Define  │── test ─→│ 2. Implementa ──→ [Pasa] GREEN
│    spec    │          │    código     │
│    (test)  │          │             │
│            │          │ 3. Refactoriza ──→ [Sigue pasando]
│ 4. Valida  │←─ pytest─│             │
│            │          │             │
│ 5. Ajusta  │── test ─→│ 6. Itera     │
│    spec    │          │             │
└────────────┘          └─────────────┘

La inversión: En TDD clásico, escribir la implementación es TU trabajo más laborioso. En agentic TDD, ese trabajo lo hace Claude Code. Tu trabajo más importante pasa a ser definir la spec (los tests).

¿Qué significa "tests como especificación"?

Una especificación es un documento que define qué debe hacer un sistema. Tradicionalmente, se escribe en lenguaje natural:

Especificación en lenguaje natural (ambigua):
"El sistema debe calcular descuentos. Los descuentos válidos están
entre 0% y 100%. El sistema debe manejar errores apropiadamente."

Problemas:

  • ¿"Manejar errores apropiadamente" significa retornar None? ¿Lanzar excepción? ¿Retornar 0?
  • ¿"Entre 0% y 100%" incluye 0 y 100, o los excluye?
  • ¿Qué pasa con inputs no numéricos?

Ahora compara con una especificación como tests:

def test_discount_basic():
    assert calculate_discount(100, 10) == 90.0

def test_discount_zero_percent():
    assert calculate_discount(100, 0) == 100.0

def test_discount_hundred_percent():
    assert calculate_discount(100, 100) == 0.0

def test_discount_negative_percent_raises():
    with pytest.raises(ValueError, match="between 0 and 100"):
        calculate_discount(100, -5)

def test_discount_over_hundred_raises():
    with pytest.raises(ValueError, match="between 0 and 100"):
        calculate_discount(100, 150)

def test_discount_negative_price_raises():
    with pytest.raises(ValueError, match="price must be positive"):
        calculate_discount(-50, 10)

Esta especificación:

  • ✅ No es ambigua — cada test tiene un resultado exacto esperado
  • ✅ Es ejecutable — pytest la valida automáticamente
  • ✅ Es verificable — pasa o no pasa, sin interpretación
  • ✅ Documenta edge cases explícitamente (0%, 100%, negativos)
  • ✅ Define cómo se manejan errores (ValueError con mensaje específico)

El Framework Spec-First

Paso 1: Define el comportamiento como tests

Antes de tocar Claude Code, piensa en QUÉ debe pasar. No en CÓMO se implementa.

# Piensa en comportamiento, no en implementación:

# ❌ "Necesito una función que use regex para validar emails"
# (esto es implementación, no comportamiento)

# ✅ "Necesito una función donde:"
def test_valid_email():
    assert is_valid_email("user@example.com") == True

def test_missing_at():
    assert is_valid_email("userexample.com") == False

def test_missing_domain():
    assert is_valid_email("user@") == False

def test_empty_string():
    assert is_valid_email("") == False

def test_multiple_at_signs():
    assert is_valid_email("user@@example.com") == False

Regla: Si estás pensando en regex, algoritmos, o estructuras de datos, estás pensando en implementación. Piensa en inputs y outputs.

Paso 2: Da los tests como contexto a Claude Code

No le digas "implementa un validador de email." Dale los tests:

Prompt a Claude Code:

"Tengo estos tests en test_email.py:

[pegar tests]

Implementa la función is_valid_email en email_validator.py 
para que todos los tests pasen."

¿Por qué funciona? Claude Code recibe:

  • ✅ El nombre de la función (is_valid_email)
  • ✅ La firma de la función (email: str -> bool)
  • ✅ Casos concretos de input/output
  • ✅ Edge cases que DEBEN manejarse
  • ✅ Comportamiento de error esperado

No hay ambigüedad. Claude Code tiene toda la información que necesita para implementar correctamente.

Paso 3: Ejecuta los tests

pytest test_email.py -v
test_email.py::test_valid_email PASSED
test_email.py::test_missing_at PASSED
test_email.py::test_missing_domain PASSED
test_email.py::test_empty_string PASSED
test_email.py::test_multiple_at_signs PASSED

========================= 5 passed in 0.01s =========================

Si todos pasan: la implementación cumple tu especificación. Si alguno falla: Claude Code itera.

Paso 4: Itera si es necesario

pytest test_email.py -v

test_email.py::test_valid_email PASSED
test_email.py::test_missing_at PASSED
test_email.py::test_missing_domain FAILED  ← Este falla
test_email.py::test_empty_string PASSED
test_email.py::test_multiple_at_signs PASSED

Le das el output a Claude Code:

"test_missing_domain falló. Aquí está el output:
AssertionError: assert True == False
La función retorna True para 'user@' pero debería retornar False.
Corrige la implementación."

Claude Code lee el feedback preciso del test y corrige. No necesitas explicar el problema en lenguaje natural — pytest lo explica por ti.


La Analogía del Arquitecto y el Constructor

Tú eres el arquitecto. Defines los planos: cuántas habitaciones, qué dimensiones, dónde van las ventanas, qué materiales. No colocas ladrillos — defines qué debe existir.

Claude Code es el constructor. Recibe los planos y construye. Sabe colocar ladrillos, mezclar concreto, instalar tuberías. Pero sin planos, construye lo que le parece — y puede no ser lo que querías.

Los tests son los planos. Son precisos, verificables, y no ambiguos. Cada test es una instrucción: "esta habitación debe medir 4x5 metros" = "esta función debe retornar 90 cuando recibe 100 y 10."

Sin planos (sin tests):
Arquitecto: "Quiero una casa bonita"
Constructor: [construye algo]
Arquitecto: "No, eso no era lo que quería"
Constructor: [rehacer desde cero]

Con planos (con tests):
Arquitecto: [entrega planos detallados]
Constructor: [construye según planos]
Inspector: [verifica contra planos] ← pytest
✅ "Todo cumple con las especificaciones"

Spec-First en Práctica: Ejemplo Completo

Vamos a ver el ciclo spec-first completo para una función real. Imagina que necesitas un password strength checker.

1. Define la spec (tú escribes los tests)

# test_password_checker.py
import pytest
from password_checker import check_password_strength


class TestPasswordStrength:
    """Spec: Password strength checker"""
    
    def test_strong_password(self):
        """Una password con 8+ chars, mayúscula, minúscula, número y símbolo es 'strong'"""
        result = check_password_strength("MyP@ss1!")
        assert result["strength"] == "strong"
    
    def test_medium_password(self):
        """Una password con 8+ chars y 3 de 4 criterios es 'medium'"""
        result = check_password_strength("MyPass12")
        assert result["strength"] == "medium"
    
    def test_weak_password(self):
        """Una password con menos de 8 chars es siempre 'weak'"""
        result = check_password_strength("abc")
        assert result["strength"] == "weak"
    
    def test_empty_password(self):
        """Una password vacía es 'weak'"""
        result = check_password_strength("")
        assert result["strength"] == "weak"
    
    def test_returns_criteria_met(self):
        """El resultado incluye qué criterios se cumplieron"""
        result = check_password_strength("MyP@ss1!")
        assert result["has_uppercase"] == True
        assert result["has_lowercase"] == True
        assert result["has_number"] == True
        assert result["has_special"] == True
        assert result["min_length"] == True
    
    def test_only_lowercase_criteria(self):
        """Una password de solo minúsculas reporta correctamente"""
        result = check_password_strength("abcdefgh")
        assert result["has_uppercase"] == False
        assert result["has_lowercase"] == True
        assert result["has_number"] == False
        assert result["has_special"] == False
    
    def test_length_boundary_7_chars(self):
        """7 caracteres no cumple el mínimo de 8"""
        result = check_password_strength("Aa1!567")
        assert result["min_length"] == False
        assert result["strength"] == "weak"
    
    def test_length_boundary_8_chars(self):
        """8 caracteres cumple el mínimo"""
        result = check_password_strength("Aa1!5678")
        assert result["min_length"] == True

Observa: No sabes (ni te importa) cómo se implementa. Solo defines qué debe retornar para cada input.

2. Da la spec a Claude Code

"Tengo esta spec en test_password_checker.py:

[tests de arriba]

Crea password_checker.py con la función check_password_strength
que haga pasar todos los tests. La función recibe un string y
retorna un dict con 'strength' y los criterios individuales."

3. Claude Code implementa

Claude Code genera algo como:

# password_checker.py
import re


def check_password_strength(password: str) -> dict:
    criteria = {
        "has_uppercase": bool(re.search(r"[A-Z]", password)),
        "has_lowercase": bool(re.search(r"[a-z]", password)),
        "has_number": bool(re.search(r"\d", password)),
        "has_special": bool(re.search(r"[!@#$%^&*(),.?\":{}|<>]", password)),
        "min_length": len(password) >= 8,
    }
    
    criteria_count = sum([
        criteria["has_uppercase"],
        criteria["has_lowercase"],
        criteria["has_number"],
        criteria["has_special"],
    ])
    
    if not criteria["min_length"]:
        strength = "weak"
    elif criteria_count >= 4:
        strength = "strong"
    elif criteria_count >= 3:
        strength = "medium"
    else:
        strength = "weak"
    
    return {"strength": strength, **criteria}

4. Valida con pytest

pytest test_password_checker.py -v
test_password_checker.py::TestPasswordStrength::test_strong_password PASSED
test_password_checker.py::TestPasswordStrength::test_medium_password PASSED
test_password_checker.py::TestPasswordStrength::test_weak_password PASSED
test_password_checker.py::TestPasswordStrength::test_empty_password PASSED
test_password_checker.py::TestPasswordStrength::test_returns_criteria_met PASSED
test_password_checker.py::TestPasswordStrength::test_only_lowercase_criteria PASSED
test_password_checker.py::TestPasswordStrength::test_length_boundary_7_chars PASSED
test_password_checker.py::TestPasswordStrength::test_length_boundary_8_chars PASSED

========================= 8 passed in 0.02s =========================

8/8 tests pasan. La implementación cumple tu especificación exactamente.


Comparación: Lenguaje Natural vs Tests como Spec

AspectoSpec en lenguaje naturalSpec como tests
Precisión"Manejar errores apropiadamente"pytest.raises(ValueError, match="...")
VerificabilidadAlguien lee y opinapytest dice PASS/FAIL
Edge casesFácil olvidarlosCada test es un edge case explícito
Ambigüedad"El password debe ser fuerte"assert strength == "strong" para input específico
MantenimientoSe desactualiza silenciosamenteSi el código cambia y tests fallan, lo sabes
Comunicación con AIClaude Code interpreta (puede errar)Claude Code ejecuta (preciso)

¿Cuándo usar lenguaje natural? Para dar contexto general y motivación. "Necesito un password checker porque los usuarios eligen passwords débiles." Esto ayuda a Claude Code a entender el dominio.

¿Cuándo usar tests? Para definir comportamiento específico. Qué retorna la función, cómo maneja errores, qué edge cases cubre. Esto es lo que Claude Code implementa.

Best practice: Combina ambos. Lenguaje natural para contexto + tests para especificación.


Conexión con Proyecto

En el Spec-first mini-app (proyecto de este módulo):

  • Aplicarás exactamente este framework: defines tests para una calculator, Claude Code implementa
  • Experimentarás la diferencia entre darle instrucciones en lenguaje natural vs darle tests como spec
  • Verás cómo Claude Code produce implementaciones más precisas cuando tiene tests como contexto

El patrón spec-first que aprendes aquí se repite en todos los módulos de la guía.


Troubleshooting

Problema 1: "No sé qué tests escribir — no conozco el dominio"

Causa: Confundir "no conozco la implementación" con "no conozco el comportamiento." No necesitas saber cómo se implementa un password checker para saber que "abc" es una password débil.

Solución: Piensa como usuario, no como developer. ¿Qué inputs darías? ¿Qué outputs esperarías? Si aún no estás seguro, empieza con el caso más simple y expande:

# Empieza aquí (el caso más obvio):
def test_basic():
    assert add(2, 3) == 5

# Luego expande:
def test_zero():
    assert add(0, 5) == 5

# Luego edge cases:
def test_negative():
    assert add(-1, 1) == 0

Problema 2: "Mis tests son demasiado específicos — limitan la implementación"

Causa: Testear implementación en vez de comportamiento.

Solución: Testea QUÉ retorna, no CÓMO lo calcula:

# ❌ Demasiado específico (testea implementación):
def test_uses_regex():
    import re
    assert re.search(r"[A-Z]", "Hello")  # Forzas regex

# ✅ Testea comportamiento:
def test_detects_uppercase():
    result = check_password_strength("Hello")
    assert result["has_uppercase"] == True

Problema 3: "Claude Code no entiende mis tests"

Causa: Tests que dependen de imports o setup que no diste como contexto.

Solución: Incluye todo lo que Claude Code necesita para entender los tests:

  • El archivo de tests completo (incluyendo imports)
  • El nombre del archivo donde debe implementar
  • Cualquier dependencia o constraint ("usa solo la standard library")

Ejercicios

Ejercicio 1: Convertir spec natural a tests (Fácil)

Convierte esta especificación en lenguaje natural a tests:

"Necesito una función celsius_to_fahrenheit que convierta temperatura de Celsius a Fahrenheit. La fórmula es F = C * 9/5 + 32. Debe funcionar con negativos y cero."

Ver solución
import pytest
from temperature import celsius_to_fahrenheit

def test_freezing_point():
    assert celsius_to_fahrenheit(0) == 32.0

def test_boiling_point():
    assert celsius_to_fahrenheit(100) == 212.0

def test_body_temperature():
    assert celsius_to_fahrenheit(37) == 98.6

def test_negative_temperature():
    assert celsius_to_fahrenheit(-40) == -40.0

def test_absolute_zero():
    assert celsius_to_fahrenheit(-273.15) == pytest.approx(-459.67)

Explicación: Cada test usa valores conocidos (punto de congelación, ebullición, temperatura corporal) para verificar la conversión. pytest.approx maneja imprecisiones de punto flotante. Nota que no necesitas saber la fórmula para escribir estos tests — solo necesitas conocer los puntos de referencia.

Ejercicio 2: Identificar ambigüedad (Fácil)

Lee esta especificación y lista 3 ambigüedades que tests resolverían:

"Implementa una función truncate_text que acorte texto largo. Si el texto es más largo que el límite, córtalo y agrega '...' al final."

Ver solución

Ambigüedades:

  1. ¿El límite incluye los "..."? Si el límite es 10, ¿el resultado tiene 10 chars (incluyendo "...") o 13 chars (10 + "...")?
  2. ¿Qué pasa si el texto es exactamente del largo del límite? ¿Se trunca o no?
  3. ¿Qué pasa con texto más corto que el límite? ¿Se retorna igual?
  4. ¿Qué pasa con texto vacío? ¿Retorna ""? ¿Retorna "..."?
  5. ¿Qué pasa con límite de 0 o negativo?

Tests que resolverían las ambigüedades:

def test_long_text_truncated_with_ellipsis():
    assert truncate_text("Hello World!", 5) == "Hello..."

def test_text_at_limit_not_truncated():
    assert truncate_text("Hello", 5) == "Hello"

def test_short_text_unchanged():
    assert truncate_text("Hi", 5) == "Hi"

def test_empty_text():
    assert truncate_text("", 5) == ""

def test_zero_limit():
    assert truncate_text("Hello", 0) == "..."

Explicación: Cada test resuelve una ambigüedad. Ahora Claude Code sabe exactamente qué comportamiento implementar.

Ejercicio 3: Spec-first para un caso real (Medio)

Necesitas una función parse_duration(text: str) -> int que convierta duración en texto a segundos. Ejemplos: "2h" → 7200, "30m" → 1800, "45s" → 45.

Escribe 6+ tests como spec. No implementes la función.

Ver solución
import pytest
from duration_parser import parse_duration

def test_hours():
    assert parse_duration("2h") == 7200

def test_minutes():
    assert parse_duration("30m") == 1800

def test_seconds():
    assert parse_duration("45s") == 45

def test_one_hour():
    assert parse_duration("1h") == 3600

def test_zero():
    assert parse_duration("0s") == 0

def test_invalid_unit_raises():
    with pytest.raises(ValueError, match="Invalid duration format"):
        parse_duration("5x")

def test_no_number_raises():
    with pytest.raises(ValueError, match="Invalid duration format"):
        parse_duration("h")

def test_empty_string_raises():
    with pytest.raises(ValueError, match="Invalid duration format"):
        parse_duration("")

def test_negative_raises():
    with pytest.raises(ValueError, match="must be non-negative"):
        parse_duration("-5m")

Explicación: Estos tests definen completamente el contrato de parse_duration: qué inputs acepta, qué outputs produce, y cómo maneja errores. Claude Code puede implementar esto sin ambigüedad.

Ejercicio 4: Detectar spec incompleta (Medio)

Estos tests son para una función divide(a, b). ¿Qué edge cases faltan?

def test_basic_division():
    assert divide(10, 2) == 5.0

def test_division_with_remainder():
    assert divide(7, 2) == 3.5

def test_divide_by_zero():
    with pytest.raises(ZeroDivisionError):
        divide(10, 0)
Ver solución

Edge cases faltantes:

def test_divide_negative_numbers():
    assert divide(-10, 2) == -5.0

def test_divide_two_negatives():
    assert divide(-10, -2) == 5.0

def test_divide_zero_by_number():
    assert divide(0, 5) == 0.0

def test_divide_very_large_numbers():
    assert divide(1e15, 1e10) == 1e5

def test_divide_very_small_result():
    assert divide(1, 3) == pytest.approx(0.333333, rel=1e-4)

def test_divide_float_inputs():
    assert divide(1.5, 0.5) == 3.0

Explicación: La spec original solo cubría el happy path y un error case. Una spec completa incluye negativos, cero como numerador, números grandes, precision de floats, y inputs float. Estos son los edge cases que Claude Code podría no manejar sin tests que los requieran.


Resumen

En esta cápsula aprendiste:

  • ✅ La inversión de control en agentic TDD: tú defines specs (tests), Claude Code implementa
  • ✅ Tests como especificación: precisos, ejecutables, verificables — superiores al lenguaje natural
  • ✅ El framework spec-first en 4 pasos: define tests → da contexto → ejecuta → itera
  • ✅ La analogía arquitecto/constructor: tú diseñas los planos, Claude Code construye
  • ✅ Spec-first completo en práctica: password checker desde tests hasta implementación validada
  • ✅ Combinar lenguaje natural (contexto) con tests (especificación) para mejores resultados

Próxima cápsula: Anatomía de un buen test-spec — qué hace que un test sea una buena especificación.


Recursos Adicionales

  1. Tweag Blog - Artículos sobre desarrollo con LLMs y metodologías spec-first
  2. pytest: Writing Tests - Documentación oficial sobre assertions en pytest
  3. Specification by Example (Gojko Adzic) - El concepto de "especificación mediante ejemplos" aplicado a software
  4. BDD vs TDD - Comparación entre Behavior-Driven y Test-Driven Development
  5. Test Desiderata (Kent Beck) - Propiedades que hacen un buen test

Módulo 1, Cápsula 03 — Testing with Claude Code Guide Tú defines el qué, Claude Code implementa el cómo