Módulo 2: Unit Tests con Claude Code
Generar Unit Tests con Claude Code
Generar Unit Tests con Claude Code
Descripción de la cápsula
Claude Code puede generar tests. Eso ya lo sabes. Pero "genera tests" es como decir "escribe código" — el resultado depende completamente de cómo lo pidas. Un prompt genérico produce tests genéricos. Un prompt específico produce tests que cubren happy path, edge cases, boundary conditions, y error handling.
Esta cápsula es prompt engineering aplicado a testing. Vas a aprender los prompts que producen tests de calidad profesional con Claude Code, y los vas a contrastar con los prompts que producen tests triviales. La diferencia entre ambos es la diferencia entre una test suite que da confianza real y una que da falsa seguridad.
Al final, tendrás un repertorio de prompts para generar tests en diferentes contextos — funciones puras, funciones con side effects, validaciones, transformaciones de datos — y sabrás cuándo cada prompt es apropiado.
El Problema: "Escribe Tests" No Es Suficiente
Prompt genérico → Tests genéricos
Imagina que tienes esta función y le pides a Claude Code "escribe tests":
# user_validator.py
def validate_email(email: str) -> bool:
"""Check if email is valid."""
if not isinstance(email, str):
raise TypeError("Email must be a string")
if not email:
return False
parts = email.split("@")
if len(parts) != 2:
return False
local, domain = parts
if not local or not domain:
return False
if "." not in domain:
return False
return True
Prompt genérico:
Escribe tests para validate_email
Resultado típico de Claude Code (tests genéricos):
def test_valid_email():
assert validate_email("user@example.com") == True
def test_invalid_email():
assert validate_email("invalid") == False
def test_empty_email():
assert validate_email("") == False
3 tests. Cubren lo mínimo. Pero hay al menos 10 escenarios que NO cubren:
- ¿Qué pasa con
"@example.com"(sin local part)? - ¿Qué pasa con
"user@"(sin domain)? - ¿Qué pasa con
"user@domain"(sin punto en domain)? - ¿Qué pasa con
"user@@example.com"(doble @)? - ¿Qué pasa con
Nonecomo input? - ¿Qué pasa con
123como input? - ¿Qué pasa con
"user@sub.domain.com"(subdomains)? - ¿Qué pasa con espacios en el email?
Prompt específico → Tests profesionales
Prompt específico:
Escribe unit tests para validate_email que cubran:
1. Happy path: emails válidos comunes
2. Edge cases: email vacío, sin @, sin domain, sin local part
3. Boundary conditions: multiple @, dots en domain y local
4. Error handling: inputs no-string (None, int, list)
5. Nombra cada test describiendo el comportamiento que verifica
Resultado de Claude Code (tests profesionales):
import pytest
from user_validator import validate_email
class TestValidateEmail:
# Happy path
def test_standard_email_is_valid(self):
assert validate_email("user@example.com") == True
def test_email_with_subdomain_is_valid(self):
assert validate_email("user@sub.domain.com") == True
def test_email_with_dots_in_local_is_valid(self):
assert validate_email("first.last@example.com") == True
# Edge cases
def test_empty_string_is_invalid(self):
assert validate_email("") == False
def test_missing_at_sign_is_invalid(self):
assert validate_email("userexample.com") == False
def test_missing_domain_is_invalid(self):
assert validate_email("user@") == False
def test_missing_local_part_is_invalid(self):
assert validate_email("@example.com") == False
def test_domain_without_dot_is_invalid(self):
assert validate_email("user@domain") == False
# Boundary conditions
def test_multiple_at_signs_is_invalid(self):
assert validate_email("user@@example.com") == False
def test_at_sign_only_is_invalid(self):
assert validate_email("@") == False
# Error handling
def test_none_input_raises_type_error(self):
with pytest.raises(TypeError, match="Email must be a string"):
validate_email(None)
def test_integer_input_raises_type_error(self):
with pytest.raises(TypeError, match="Email must be a string"):
validate_email(123)
def test_list_input_raises_type_error(self):
with pytest.raises(TypeError, match="Email must be a string"):
validate_email(["user@example.com"])
13 tests vs 3. Misma función, pero el prompt específico produjo tests que realmente validan el comportamiento completo.
Los 5 Prompts para Generar Tests de Calidad
Prompt 1: El Prompt Completo (funciones puras)
Para funciones que reciben inputs y retornan outputs sin side effects:
Escribe unit tests para [función] que cubran:
1. Happy path: casos normales de uso
2. Edge cases: inputs vacíos, None, strings vacíos, listas vacías
3. Boundary conditions: valores límite, 0, -1, MAX_INT
4. Error handling: tipos incorrectos, inputs inválidos
5. Nombra cada test con: test_[qué_hace]_[condición]_[resultado_esperado]
6. Usa arrange-act-assert en cada test
7. Un assert por test (enfocado)
Cuándo usar: Funciones como calculate_discount, validate_email, parse_date, format_currency.
Prompt 2: El Prompt de Contexto (función dentro de un sistema)
Cuando la función tiene contexto que Claude Code necesita entender:
Aquí está [función] que forma parte de un sistema de [dominio].
El contrato es: [describir inputs/outputs esperados].
Reglas de negocio:
- [Regla 1]
- [Regla 2]
Genera unit tests que verifiquen:
- Cada regla de negocio se cumple
- Violaciones de reglas se manejan apropiadamente
- Edge cases del dominio: [listar si los conoces]
Cuándo usar: Funciones con reglas de negocio como apply_discount, calculate_shipping, determine_user_tier.
Prompt 3: El Prompt de Expansión (tests existentes)
Cuando ya tienes tests pero quieres expandir coverage:
Aquí están los tests existentes para [función]:
[pegar tests]
Y aquí está la implementación:
[pegar función]
¿Qué escenarios NO están cubiertos? Genera tests adicionales
para: edge cases faltantes, boundary conditions, y error paths
que los tests actuales no cubren.
Cuándo usar: Cuando ya tienes una base de tests (como los que escribiste en spec-first) y quieres que Claude Code la expanda.
Prompt 4: El Prompt Adversarial (descubrir bugs)
Cuando quieres que Claude Code intente romper tu código:
Aquí está la implementación de [función]:
[pegar función]
Actúa como un tester adversarial. Tu objetivo es encontrar inputs
que hagan fallar esta función o produzcan resultados incorrectos.
Genera tests que expongan:
- Inputs que causan excepciones no manejadas
- Inputs que producen resultados silenciosamente incorrectos
- Combinaciones de parámetros problemáticas
Cuándo usar: Antes de considerar una función "terminada." Es especialmente útil para encontrar bugs que no viste.
Prompt 5: El Prompt de Documentación (tests como docs)
Cuando quieres tests que sirvan como documentación del comportamiento:
Genera tests para [función] organizados como documentación viva:
class TestNombreFuncion:
# --- Comportamiento principal ---
# Tests que documentan qué hace la función normalmente
# --- Reglas de validación ---
# Tests que documentan qué inputs rechaza y por qué
# --- Casos límite ---
# Tests que documentan comportamiento en bordes
Cada nombre de test debe leerse como una frase en inglés que
describe el contrato de la función.
Cuándo usar: Cuando quieres que pytest -v genere output que cualquiera pueda leer y entender el sistema.
Prompt Engineering para Tests: Reglas Prácticas
Regla 1: Especifica las categorías de tests
# ❌ Vago:
"Escribe tests"
# ✅ Específico:
"Escribe tests que cubran: happy path, edge cases, error handling"
Regla 2: Pide naming descriptivo
# ❌ Sin guidance:
"Genera tests para parse_date"
# ✅ Con naming convention:
"Genera tests con nombres que describan comportamiento:
test_parse_date_iso_format_returns_datetime
test_parse_date_invalid_string_raises_value_error"
Regla 3: Da ejemplos de edge cases del dominio
# ❌ Genérico:
"Incluye edge cases"
# ✅ Específico al dominio:
"Edge cases para un price calculator:
- price = 0
- price negativo
- discount > 100%
- discount = 0%
- currency con más de 2 decimales"
Regla 4: Pide un assert por test
# ❌ Tests desenfocados:
"Escribe tests para el user model"
# ✅ Tests enfocados:
"Escribe tests con un solo assert por test.
Cada test verifica UN comportamiento específico."
Regla 5: Indica el patrón deseado
# ❌ Sin estructura:
"Genera tests para la función"
# ✅ Con estructura:
"Usa el patrón arrange-act-assert en cada test.
Organiza en una clase TestNombreFuncion."
Ejemplo Completo: De Función a Test Suite
Veamos el workflow completo con una función más sustancial.
La función a testear
# pricing.py
from datetime import datetime, time
def calculate_price(
base_price: float,
quantity: int,
discount_percent: float = 0,
is_member: bool = False,
order_time: time = None,
) -> dict:
"""
Calculate final price with discounts and surcharges.
Rules:
- Base discount applied to subtotal
- Members get additional 5% off
- Orders between 22:00-06:00 get 10% night surcharge
- Minimum final price is 0 (no negative prices)
"""
if base_price < 0:
raise ValueError("Base price cannot be negative")
if quantity < 1:
raise ValueError("Quantity must be at least 1")
if not 0 <= discount_percent <= 100:
raise ValueError("Discount must be between 0 and 100")
subtotal = base_price * quantity
discount_amount = subtotal * (discount_percent / 100)
after_discount = subtotal - discount_amount
if is_member:
member_discount = after_discount * 0.05
after_discount -= member_discount
if order_time:
night_start = time(22, 0)
night_end = time(6, 0)
if order_time >= night_start or order_time < night_end:
night_surcharge = after_discount * 0.10
after_discount += night_surcharge
final_price = max(0, after_discount)
return {
"subtotal": round(subtotal, 2),
"discount_applied": round(discount_amount, 2),
"final_price": round(final_price, 2),
"is_member": is_member,
}
El prompt a Claude Code
Genera unit tests profesionales para calculate_price en pricing.py.
Reglas de negocio a verificar:
1. Descuento base se aplica al subtotal
2. Miembros obtienen 5% adicional
3. Pedidos entre 22:00-06:00 tienen 10% de recargo nocturno
4. Precio final mínimo es 0
Categorías de tests:
- Happy path: cálculos básicos sin descuentos ni membership
- Discounts: descuentos correctos (10%, 50%, 100%)
- Membership: descuento adicional de 5% para miembros
- Night surcharge: recargo nocturno entre 22:00-06:00
- Validation: errores para inputs inválidos
- Edge cases: precio 0, quantity 1, discount 0% y 100%
Patrón: arrange-act-assert. Un assert por test.
Naming: test_[descripción_del_comportamiento]
Lo que Claude Code genera
import pytest
from datetime import time
from pricing import calculate_price
class TestCalculatePriceBasic:
"""Happy path: cálculos básicos"""
def test_single_item_no_discount(self):
result = calculate_price(base_price=100, quantity=1)
assert result["final_price"] == 100.0
def test_multiple_items_no_discount(self):
result = calculate_price(base_price=50, quantity=3)
assert result["subtotal"] == 150.0
def test_subtotal_equals_price_times_quantity(self):
result = calculate_price(base_price=25, quantity=4)
assert result["final_price"] == 100.0
class TestCalculatePriceDiscount:
"""Descuentos aplicados al subtotal"""
def test_ten_percent_discount(self):
result = calculate_price(base_price=100, quantity=1, discount_percent=10)
assert result["final_price"] == 90.0
def test_fifty_percent_discount(self):
result = calculate_price(base_price=100, quantity=1, discount_percent=50)
assert result["final_price"] == 50.0
def test_hundred_percent_discount_gives_zero(self):
result = calculate_price(base_price=100, quantity=1, discount_percent=100)
assert result["final_price"] == 0.0
def test_zero_percent_discount_no_change(self):
result = calculate_price(base_price=100, quantity=1, discount_percent=0)
assert result["final_price"] == 100.0
def test_discount_applied_to_subtotal_not_unit_price(self):
result = calculate_price(base_price=100, quantity=2, discount_percent=10)
assert result["discount_applied"] == 20.0
class TestCalculatePriceMembership:
"""Descuento adicional de 5% para miembros"""
def test_member_gets_additional_five_percent(self):
result = calculate_price(base_price=100, quantity=1, is_member=True)
assert result["final_price"] == 95.0
def test_member_discount_stacks_with_base_discount(self):
result = calculate_price(base_price=100, quantity=1, discount_percent=10, is_member=True)
assert result["final_price"] == pytest.approx(85.5)
def test_non_member_no_extra_discount(self):
result = calculate_price(base_price=100, quantity=1, is_member=False)
assert result["final_price"] == 100.0
def test_result_includes_membership_status(self):
result = calculate_price(base_price=100, quantity=1, is_member=True)
assert result["is_member"] == True
class TestCalculatePriceNightSurcharge:
"""Recargo nocturno 10% entre 22:00-06:00"""
def test_order_at_23_gets_surcharge(self):
result = calculate_price(base_price=100, quantity=1, order_time=time(23, 0))
assert result["final_price"] == 110.0
def test_order_at_3am_gets_surcharge(self):
result = calculate_price(base_price=100, quantity=1, order_time=time(3, 0))
assert result["final_price"] == 110.0
def test_order_at_10am_no_surcharge(self):
result = calculate_price(base_price=100, quantity=1, order_time=time(10, 0))
assert result["final_price"] == 100.0
def test_boundary_22_00_gets_surcharge(self):
result = calculate_price(base_price=100, quantity=1, order_time=time(22, 0))
assert result["final_price"] == 110.0
def test_boundary_06_00_no_surcharge(self):
result = calculate_price(base_price=100, quantity=1, order_time=time(6, 0))
assert result["final_price"] == 100.0
def test_no_order_time_means_no_surcharge(self):
result = calculate_price(base_price=100, quantity=1, order_time=None)
assert result["final_price"] == 100.0
class TestCalculatePriceValidation:
"""Validación de inputs"""
def test_negative_price_raises_error(self):
with pytest.raises(ValueError, match="Base price cannot be negative"):
calculate_price(base_price=-10, quantity=1)
def test_zero_quantity_raises_error(self):
with pytest.raises(ValueError, match="Quantity must be at least 1"):
calculate_price(base_price=100, quantity=0)
def test_negative_quantity_raises_error(self):
with pytest.raises(ValueError, match="Quantity must be at least 1"):
calculate_price(base_price=100, quantity=-1)
def test_discount_over_100_raises_error(self):
with pytest.raises(ValueError, match="Discount must be between 0 and 100"):
calculate_price(base_price=100, quantity=1, discount_percent=150)
def test_negative_discount_raises_error(self):
with pytest.raises(ValueError, match="Discount must be between 0 and 100"):
calculate_price(base_price=100, quantity=1, discount_percent=-10)
class TestCalculatePriceEdgeCases:
"""Casos límite"""
def test_zero_base_price(self):
result = calculate_price(base_price=0, quantity=5)
assert result["final_price"] == 0.0
def test_minimum_final_price_is_zero(self):
result = calculate_price(base_price=0, quantity=1, discount_percent=100)
assert result["final_price"] == 0.0
def test_all_modifiers_combined(self):
result = calculate_price(
base_price=100, quantity=2,
discount_percent=10, is_member=True,
order_time=time(23, 0)
)
assert result["final_price"] == pytest.approx(188.1)
30 tests organizados por comportamiento. Compara esto con los 3 tests del prompt genérico.
Conexión con Proyecto
En el Unit test suite generada (proyecto de este módulo):
- Usarás estos prompts para generar tests para un módulo de utilidades completo
- Iterarás con Claude Code: primer prompt → evalúas → prompt de expansión → evalúas → suite profesional
- Aplicarás el prompt adversarial para descubrir edge cases que ni tú ni Claude Code vieron en la primera pasada
El prompt es la herramienta — la calidad del prompt determina la calidad de los tests.
Troubleshooting
Problema 1: Claude Code genera tests demasiado simples
Causa: El prompt es genérico ("escribe tests para X").
Solución: Usa el Prompt 1 (Completo) especificando categorías: happy path, edge cases, boundary conditions, error handling.
Problema 2: Claude Code genera tests que no ejecutan
Causa: Claude Code no tiene contexto de imports o dependencias.
Solución: Siempre incluye el archivo completo de la función (con imports) como contexto. Si la función depende de otros módulos, incluye las interfaces relevantes.
Problema 3: Claude Code genera tests con asserts incorrectos
Causa: Claude Code calculó mal el resultado esperado. Ocurre con lógica compleja.
Solución: Ejecuta los tests. Si fallan por assert incorrecto (no por bug en el código), dale el output a Claude Code: "Este test tiene el valor esperado incorrecto. El código retorna X pero el test espera Y. ¿Cuál es el valor correcto según la lógica de negocio?"
Problema 4: Todos los tests pasan al primer intento
Causa: Si todos los tests pasan inmediatamente, puede ser señal de tests triviales.
Solución: Usa el Prompt 4 (Adversarial) para intentar romper el código. Si Claude Code no encuentra cómo romperlo, probablemente la implementación es robusta. Si encuentra inputs que causan fallos, tienes tests nuevos que agregar.
Ejercicios
Ejercicio 1: Prompt genérico vs específico (Fácil)
Dale esta función a Claude Code con un prompt genérico ("escribe tests") y después con el Prompt 1 (Completo). Compara los resultados.
def clamp(value: float, min_val: float, max_val: float) -> float:
"""Clamp value between min and max."""
if min_val > max_val:
raise ValueError("min_val must be <= max_val")
return max(min_val, min(max_val, value))
Ver solución
Prompt genérico genera ~3 tests:
def test_clamp_within_range():
assert clamp(5, 0, 10) == 5
def test_clamp_below_min():
assert clamp(-5, 0, 10) == 0
def test_clamp_above_max():
assert clamp(15, 0, 10) == 10
Prompt 1 (Completo) genera ~10 tests:
def test_value_within_range_unchanged(self):
assert clamp(5, 0, 10) == 5
def test_value_below_min_clamped_to_min(self):
assert clamp(-5, 0, 10) == 0
def test_value_above_max_clamped_to_max(self):
assert clamp(15, 0, 10) == 10
def test_value_equals_min(self):
assert clamp(0, 0, 10) == 0
def test_value_equals_max(self):
assert clamp(10, 0, 10) == 10
def test_min_equals_max(self):
assert clamp(5, 5, 5) == 5
def test_negative_range(self):
assert clamp(0, -10, -1) == -1
def test_float_values(self):
assert clamp(0.5, 0.0, 1.0) == 0.5
def test_min_greater_than_max_raises_error(self):
with pytest.raises(ValueError, match="min_val must be <= max_val"):
clamp(5, 10, 0)
def test_very_large_values(self):
assert clamp(1e15, 0, 1e10) == 1e10
Explicación: El prompt específico produjo boundary conditions (value == min, value == max, min == max), rangos negativos, floats, y el error case — todos faltantes en la versión genérica.
Ejercicio 2: Prompt adversarial (Fácil)
Usa el Prompt 4 (Adversarial) con Claude Code sobre esta función. ¿Qué encuentra?
def safe_divide(a, b, default=0):
"""Divide a by b, return default if b is zero."""
if b == 0:
return default
return a / b
Ver solución
El prompt adversarial debería revelar:
def test_string_inputs_cause_type_error():
# No hay validación de tipos
with pytest.raises(TypeError):
safe_divide("10", "2")
def test_none_divisor_not_caught():
# b=None no es 0, pero causa TypeError en la división
with pytest.raises(TypeError):
safe_divide(10, None)
def test_infinity_divisor():
# Dividir por infinity da 0.0 (¿es el comportamiento deseado?)
assert safe_divide(10, float('inf')) == 0.0
def test_nan_divisor():
# NaN != 0, así que no usa default, pero retorna NaN
import math
result = safe_divide(10, float('nan'))
assert math.isnan(result) # ¿Es esto lo que queremos?
def test_zero_divided_by_zero_uses_default():
assert safe_divide(0, 0) == 0 # Usa default, pero ¿es correcto?
def test_default_can_be_any_type():
# default no se valida — puede ser string, None, list...
assert safe_divide(10, 0, "N/A") == "N/A"
Explicación: El prompt adversarial revela que safe_divide no valida tipos, no maneja None, infinity, ni NaN, y el default puede ser cualquier tipo. Cada hallazgo es un potencial bug en producción.
Ejercicio 3: Escribir un prompt para tu función (Medio)
Tienes una función format_phone(number: str) -> str que formatea números de teléfono. Escribe el prompt que darías a Claude Code para generar tests profesionales. Incluye las 5 categorías y al menos 3 edge cases específicos del dominio.
Ver solución
Genera unit tests profesionales para format_phone(number: str) -> str.
La función formatea números de teléfono al formato (XXX) XXX-XXXX.
Acepta formatos: "1234567890", "123-456-7890", "(123) 456-7890", "123.456.7890"
Categorías:
1. Happy path: formatos de input válidos comunes
2. Edge cases:
- String vacío
- Número con espacios extra
- Número con código de país (+1)
- Número con extensión (x1234)
3. Boundary conditions:
- Exactamente 10 dígitos
- 9 dígitos (inválido)
- 11 dígitos (inválido o con código de país)
4. Error handling:
- Input None
- Input no-string (int)
- Letras en el número
5. Formato de output:
- Siempre retorna "(XXX) XXX-XXXX"
- Whitespace se elimina antes de procesar
Naming: test_format_phone_[condición]_[resultado]
Patrón: arrange-act-assert
Un assert por test
Explicación: Este prompt da contexto del dominio (formatos aceptados, formato de output), edge cases específicos del dominio telefónico (códigos de país, extensiones), y estructura clara.
Ejercicio 4: Iterar con Claude Code (Medio)
Genera tests para calculate_price (la función de esta cápsula) usando el Prompt 1. Después, evalúa los tests generados y escribe un prompt de expansión (Prompt 3) para los gaps que encuentres.
Ver solución
Paso 1: Usas Prompt 1, Claude Code genera ~15 tests.
Paso 2: Evalúas y encuentras gaps:
- No testea combinación de membership + discount + night surcharge
- No testea que
round()funciona correctamente con valores que tienen muchos decimales - No testea
base_price = 0.01(precio mínimo práctico)
Paso 3: Prompt de expansión:
Tests existentes cubren: básico, descuento, membership, nocturno, validación.
Gaps encontrados:
1. No hay test que combine los 3 modificadores (discount + member + night)
2. No hay test de precisión con decimales (0.01 * 3 con descuento)
3. No hay test de precio base muy bajo (0.01)
Genera tests adicionales SOLO para estos 3 gaps.
Paso 4: Claude Code genera exactamente los 3 tests faltantes.
Explicación: El workflow de iteración (generar → evaluar → expandir) produce suites más completas que intentar generar todo de golpe.
Resumen
En esta cápsula aprendiste:
- ✅ "Escribe tests" produce tests genéricos — los prompts específicos producen tests profesionales
- ✅ 5 prompts para diferentes contextos: Completo, Contexto, Expansión, Adversarial, Documentación
- ✅ 5 reglas de prompt engineering para tests: categorías, naming, edge cases, asserts enfocados, patrón deseado
- ✅ El workflow de iteración: generar → evaluar → expandir → suite profesional
- ✅ El prompt adversarial descubre bugs que ni tú ni Claude Code vieron en la primera pasada
- ✅ La calidad del prompt determina directamente la calidad de los tests
Próxima cápsula: Pytest patterns — Arrange-Act-Assert en profundidad.
Recursos Adicionales
- pytest: Writing Tests - Guía oficial sobre assertions en pytest
- Anthropic: Prompt Engineering Guide - Principios de prompt engineering aplicables a testing
- Test Categories by Martin Fowler - Categorización de tipos de tests
- Google Testing Blog - Prácticas de testing de Google
- Property-Based Testing Overview - Hypothesis para descubrimiento automático de edge cases (Módulo 5)
Módulo 2, Cápsula 02 — Testing with Claude Code Guide El prompt determina la calidad del test