Módulo 2: Unit Tests con Claude Code
Proyecto del Módulo: Unit Test Suite Generada
Proyecto del Módulo: Unit Test Suite Generada
Descripción del proyecto
Has aprendido a generar tests con Claude Code usando prompts específicos, dominas los patterns de pytest (AAA, parametrize, fixtures), y sabes evaluar críticamente la calidad de tests generados por AI. Ahora es momento de integrar todo en un proyecto real.
Vas a recibir un módulo de utilidades Python (data_utils.py) con funciones de validación, formateo, y transformación de datos. Tu trabajo es usar Claude Code para generar una suite completa de unit tests, evaluando la calidad de lo que genera, refinando con prompts específicos, y produciendo una test suite profesional que cumple los estándares aprendidos.
El foco no es la complejidad del código bajo test — es la calidad de los tests generados. Un test suite mediocre tiene 30 tests que verifican lo obvio. Un test suite profesional tiene 30 tests que cubren happy path, edge cases, boundary conditions, error handling, y documentan el comportamiento con nombres claros.
Este proyecto demuestra tu dominio del workflow completo: dar código a Claude Code → generar tests → evaluar calidad → refinar → producir suite profesional.
Objetivo del Proyecto
Generar una suite completa de unit tests de calidad profesional para un módulo de utilidades Python, usando Claude Code como generador y tu criterio como evaluador de calidad.
Al completar este proyecto:
- ✅ Habrás generado tests usando los 5 prompts aprendidos en este módulo
- ✅ Habrás evaluado y refinado tests generados por AI usando el checklist de 5 puntos
- ✅ Tendrás una suite de 30+ tests con coverage de comportamiento profesional
- ✅ Los tests usarán AAA, parametrize, fixtures, y naming descriptivo
Especificaciones Técnicas
Stack Tecnológico
- Lenguaje: Python 3.10+
- Testing: pytest
- AI: Claude Code como generador de tests
- Dependencias: Solo pytest
Setup Inicial
mkdir unit-test-project
cd unit-test-project
python -m venv venv
source venv/bin/activate
pip install pytest
Estructura del Proyecto
unit-test-project/
├── data_utils.py ← El código a testear (dado)
├── tests/
│ ├── __init__.py
│ ├── conftest.py ← Fixtures compartidas
│ ├── test_validators.py ← Tests de validación
│ ├── test_formatters.py ← Tests de formateo
│ └── test_transformers.py ← Tests de transformación
├── requirements.txt
└── venv/
El Código a Testear: data_utils.py
Copia este archivo en tu proyecto. Este es el módulo que vas a testear con Claude Code.
# data_utils.py
"""Data utilities for validation, formatting, and transformation."""
import re
from datetime import datetime
from typing import Any, Optional
# === VALIDATORS ===
def validate_email(email: str) -> bool:
"""Validate email format. Returns True if valid, False otherwise."""
if not isinstance(email, str):
raise TypeError("Email must be a string")
if not email or not email.strip():
return False
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
return bool(re.match(pattern, email))
def validate_age(age: Any) -> tuple[bool, str]:
"""
Validate age value. Returns (is_valid, message).
Valid age: integer between 0 and 150 inclusive.
"""
if not isinstance(age, int) or isinstance(age, bool):
return (False, "Age must be an integer")
if age < 0:
return (False, "Age cannot be negative")
if age > 150:
return (False, "Age cannot exceed 150")
return (True, "Valid")
def validate_password(password: str) -> dict:
"""
Validate password strength.
Returns dict with 'is_valid', 'strength', and 'issues'.
Rules:
- Minimum 8 characters
- At least one uppercase letter
- At least one lowercase letter
- At least one digit
- At least one special character (!@#$%^&*()_+-=)
"""
if not isinstance(password, str):
raise TypeError("Password must be a string")
issues = []
if len(password) < 8:
issues.append("Must be at least 8 characters")
if not re.search(r'[A-Z]', password):
issues.append("Must contain uppercase letter")
if not re.search(r'[a-z]', password):
issues.append("Must contain lowercase letter")
if not re.search(r'\d', password):
issues.append("Must contain digit")
if not re.search(r'[!@#$%^&*()_+\-=]', password):
issues.append("Must contain special character")
criteria_met = 5 - len(issues)
if criteria_met == 5:
strength = "strong"
elif criteria_met >= 3:
strength = "medium"
else:
strength = "weak"
return {
"is_valid": len(issues) == 0,
"strength": strength,
"issues": issues,
"criteria_met": criteria_met,
}
# === FORMATTERS ===
def format_currency(amount: float, currency: str = "USD") -> str:
"""
Format number as currency string.
Supports USD, EUR, GBP, MXN.
"""
if not isinstance(amount, (int, float)) or isinstance(amount, bool):
raise TypeError("Amount must be a number")
symbols = {"USD": "$", "EUR": "€", "GBP": "£", "MXN": "$"}
if currency not in symbols:
raise ValueError(f"Unsupported currency: {currency}")
symbol = symbols[currency]
if amount < 0:
return f"-{symbol}{abs(amount):,.2f} {currency}"
return f"{symbol}{amount:,.2f} {currency}"
def format_name(first: str, last: str, style: str = "full") -> str:
"""
Format name in different styles.
Styles: 'full', 'formal', 'initial', 'last_first'
"""
if not isinstance(first, str) or not isinstance(last, str):
raise TypeError("Names must be strings")
first = first.strip()
last = last.strip()
if not first or not last:
raise ValueError("Names cannot be empty")
styles = {
"full": f"{first.capitalize()} {last.capitalize()}",
"formal": f"{last.capitalize()}, {first.capitalize()}",
"initial": f"{first[0].upper()}. {last.capitalize()}",
"last_first": f"{last.upper()}, {first.capitalize()}",
}
if style not in styles:
raise ValueError(f"Unknown style: {style}. Use: {', '.join(styles.keys())}")
return styles[style]
def format_phone(number: str, country: str = "US") -> str:
"""
Format phone number.
US format: (XXX) XXX-XXXX
MX format: +52 XX XXXX XXXX
"""
if not isinstance(number, str):
raise TypeError("Phone number must be a string")
digits = re.sub(r'\D', '', number)
if country == "US":
if len(digits) == 11 and digits[0] == '1':
digits = digits[1:]
if len(digits) != 10:
raise ValueError("US phone number must have 10 digits")
return f"({digits[:3]}) {digits[3:6]}-{digits[6:]}"
elif country == "MX":
if len(digits) == 12 and digits[:2] == '52':
digits = digits[2:]
if len(digits) != 10:
raise ValueError("MX phone number must have 10 digits")
return f"+52 {digits[:2]} {digits[2:6]} {digits[6:]}"
else:
raise ValueError(f"Unsupported country: {country}")
# === TRANSFORMERS ===
def slugify(text: str) -> str:
"""Convert text to URL-friendly slug."""
if not isinstance(text, str):
raise TypeError("Text must be a string")
text = text.lower().strip()
text = re.sub(r'[áàäâ]', 'a', text)
text = re.sub(r'[éèëê]', 'e', text)
text = re.sub(r'[íìïî]', 'i', text)
text = re.sub(r'[óòöô]', 'o', text)
text = re.sub(r'[úùüû]', 'u', text)
text = re.sub(r'[ñ]', 'n', text)
text = re.sub(r'[^a-z0-9\s-]', '', text)
text = re.sub(r'[\s-]+', '-', text)
text = text.strip('-')
return text
def chunk_list(items: list, chunk_size: int) -> list[list]:
"""Split a list into chunks of specified size."""
if not isinstance(items, list):
raise TypeError("Items must be a list")
if not isinstance(chunk_size, int) or isinstance(chunk_size, bool):
raise TypeError("Chunk size must be an integer")
if chunk_size < 1:
raise ValueError("Chunk size must be at least 1")
return [items[i:i + chunk_size] for i in range(0, len(items), chunk_size)]
def flatten_dict(data: dict, parent_key: str = "", separator: str = ".") -> dict:
"""
Flatten nested dictionary.
{"a": {"b": 1}} -> {"a.b": 1}
"""
if not isinstance(data, dict):
raise TypeError("Data must be a dictionary")
items = {}
for key, value in data.items():
new_key = f"{parent_key}{separator}{key}" if parent_key else key
if isinstance(value, dict):
items.update(flatten_dict(value, new_key, separator))
else:
items[new_key] = value
return items
Funcionalidades a Testear
1. Validators (test_validators.py)
Funciones: validate_email, validate_age, validate_password
Qué testear:
- Happy path: inputs válidos comunes
- Edge cases: vacíos, None, tipos incorrectos, boundary values
- Error handling: TypeError para tipos inválidos
- Reglas de negocio: cada regla de validación se verifica individualmente
2. Formatters (test_formatters.py)
Funciones: format_currency, format_name, format_phone
Qué testear:
- Cada estilo/formato soportado
- Currencies soportadas vs no soportadas
- Nombres con espacios, capitalización
- Teléfonos con diferentes formatos de input
3. Transformers (test_transformers.py)
Funciones: slugify, chunk_list, flatten_dict
Qué testear:
- Caracteres especiales, acentos, ñ
- Listas vacías, chunk_size > largo de lista
- Dicts anidados a múltiples niveles
Proceso Paso a Paso
Fase 1: Generar tests con Prompt Completo
Para cada archivo de tests, usa el Prompt 1 (Completo) con Claude Code:
Genera unit tests para [función] en data_utils.py que cubran:
1. Happy path
2. Edge cases (vacíos, None, tipos incorrectos)
3. Boundary conditions
4. Error handling
5. Naming: test_[función]_[condición]_[resultado]
6. Patrón: arrange-act-assert
7. Usa @pytest.mark.parametrize cuando haya múltiples inputs similares
Ejecuta pytest y verifica que pasan:
pytest tests/ -v
Fase 2: Evaluar con el Checklist de 5 Puntos
Para cada test generado, evalúa:
□ ¿Testea comportamiento, no implementación?
□ ¿Cubre edge cases?
□ ¿Los asserts son significativos?
□ ¿Testea error paths?
□ ¿Una implementación incorrecta pasaría estos tests?
Identifica gaps y documéntelos.
Fase 3: Expandir con prompts específicos
Para cada gap encontrado, usa el Prompt 3 (Expansión):
Tests existentes para [función] cubren: [listar].
Gaps encontrados:
- [Gap 1]
- [Gap 2]
Genera tests adicionales SOLO para estos gaps.
Fase 4: Prompt adversarial
Para cada función, usa el Prompt 4 (Adversarial):
Actúa como tester adversarial para [función].
Intenta encontrar inputs que causen fallos o resultados incorrectos.
Fase 5: Organizar y refinar
- Agrupa tests en clases por comportamiento
- Usa parametrize donde hay tests repetitivos
- Crea fixtures en conftest.py para datos compartidos
- Verifica naming descriptivo en toda la suite
Criterios de Éxito
Tu proyecto está completo cuando:
- ✅ 30+ tests en total distribuidos entre los 3 archivos de test
- ✅ Cada función de
data_utils.pytiene tests de happy path, edge cases, y error handling - ✅ Usas
@pytest.mark.parametrizeen al menos 3 tests - ✅ Usas fixtures en
conftest.pypara al menos 2 datos compartidos - ✅ Todos los tests siguen el patrón AAA
- ✅ Naming descriptivo: puedes leer
pytest -vy entender el comportamiento del sistema - ✅ Evaluaste los tests generados con el checklist de 5 puntos
- ✅
pytest tests/ -v→ todos green
Rúbrica de Evaluación (100 puntos)
Cobertura de comportamiento (40 puntos)
- (15 pts) Validators: happy path, edge cases, error handling para las 3 funciones
- (15 pts) Formatters: todos los estilos, currencies, formatos de teléfono
- (10 pts) Transformers: acentos, listas vacías, dicts anidados
Calidad de tests (30 puntos)
- (10 pts) Tests son enfocados (un assert por test, un comportamiento por test)
- (5 pts) Naming descriptivo (pytest -v es documentación legible)
- (5 pts) Parametrize usado apropiadamente (3+ tests parametrizados)
- (5 pts) Fixtures en conftest.py (2+ fixtures compartidas)
- (5 pts) Patrón AAA consistente
Evaluación crítica (20 puntos)
- (10 pts) Documentaste la evaluación con checklist de 5 puntos
- (5 pts) Identificaste al menos 3 gaps en la generación inicial
- (5 pts) Usaste prompt adversarial y encontraste al menos 1 edge case nuevo
Organización (10 puntos)
- (5 pts) Tests organizados en archivos separados por categoría
- (5 pts) Estructura de directorio correcta (tests/, conftest.py)
Extra Credit (hasta +10 puntos)
- (+5 pts) Tests adicionales para combinaciones de parámetros no obvias
- (+5 pts) Test que demuestra un bug real encontrado con el prompt adversarial
Ejemplo: conftest.py
# tests/conftest.py
import pytest
@pytest.fixture
def valid_emails():
"""Collection of valid email addresses for testing."""
return [
"user@example.com",
"first.last@domain.org",
"user+tag@example.co.uk",
"test123@test.com",
]
@pytest.fixture
def invalid_emails():
"""Collection of invalid email addresses for testing."""
return [
"",
"not-an-email",
"@domain.com",
"user@",
"user@domain",
"user@@domain.com",
" ",
]
@pytest.fixture
def strong_password():
"""A password that meets all criteria."""
return "MyStr0ng!Pass"
@pytest.fixture
def sample_nested_dict():
"""Nested dictionary for flatten_dict testing."""
return {
"user": {
"name": {"first": "John", "last": "Smith"},
"age": 30,
},
"active": True,
}
Ejemplo: Fragmento de test_validators.py
# tests/test_validators.py
import pytest
from data_utils import validate_email, validate_age, validate_password
class TestValidateEmail:
def test_standard_email_is_valid(self):
assert validate_email("user@example.com") == True
@pytest.mark.parametrize("email", [
"",
" ",
"not-an-email",
"@domain.com",
"user@",
"user@domain",
])
def test_invalid_email_formats(self, email):
assert validate_email(email) == False
def test_non_string_raises_type_error(self):
with pytest.raises(TypeError, match="Email must be a string"):
validate_email(123)
class TestValidateAge:
@pytest.mark.parametrize("age,expected_valid", [
(0, True),
(25, True),
(150, True),
(-1, False),
(151, False),
])
def test_age_boundary_values(self, age, expected_valid):
is_valid, _ = validate_age(age)
assert is_valid == expected_valid
def test_boolean_is_rejected(self):
is_valid, message = validate_age(True)
assert is_valid == False
assert message == "Age must be an integer"
Este es un fragmento. Tu suite completa debe ser más extensa.
Errores Comunes
Error 1: Confiar en la primera generación
Causa: Dar el prompt y aceptar los tests sin evaluar.
Solución: SIEMPRE evalúa con el checklist de 5 puntos. La primera generación es el punto de partida, no el resultado final.
Error 2: Tests que testean la implementación del regex
Causa: Claude Code puede generar tests como assert re.match(pattern, email) en vez de assert validate_email(email) == True.
Solución: Los tests deben llamar a la función pública, no replicar la implementación interna.
Error 3: No usar parametrize donde corresponde
Causa: Escribir 10 tests separados para emails válidos en vez de usar parametrize.
Solución: Si la lógica del test es igual y solo cambia el input, usa parametrize.
Error 4: conftest.py con fixtures que solo usa un archivo
Causa: Crear fixtures para todo en conftest.py.
Solución: Fixtures en conftest.py solo si las usan 2+ archivos de test. Fixtures locales van en el archivo de test.
Error 5: No testear el mensaje de error
Causa: pytest.raises(ValueError) sin match.
Solución: Siempre incluye match para verificar que el mensaje de error es el correcto:
# ❌ Solo verifica que lanza ValueError
with pytest.raises(ValueError):
format_phone("123")
# ✅ Verifica tipo Y mensaje
with pytest.raises(ValueError, match="must have 10 digits"):
format_phone("123")
Recursos para el Proyecto
- pytest Documentation - Referencia completa
- pytest Fixtures - Guía de fixtures
- pytest Parametrize - Referencia de parametrize
- Python re module - Para entender los regex en data_utils.py
- Anthropic Claude Code - Documentación oficial
Conexión con Siguiente Módulo
Lo que construiste hoy se expande en el Módulo 3:
- En Módulo 3 expandirás a integration y E2E tests — los otros niveles de la test pyramid
- Los unit tests que generaste aquí son la base de la pyramid: rápidos, enfocados, confiables
- Los patterns de pytest (AAA, parametrize, fixtures) se usan en todos los niveles de testing
- La habilidad de evaluar tests generados por AI se aplica igual a integration y E2E tests
Tu skill de prompt engineering para tests es transferible a cualquier nivel de testing y cualquier proyecto.
Módulo 2, Cápsula 06 — Testing with Claude Code Guide De código a test suite profesional con Claude Code