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.py tiene tests de happy path, edge cases, y error handling
  • ✅ Usas @pytest.mark.parametrize en al menos 3 tests
  • ✅ Usas fixtures en conftest.py para al menos 2 datos compartidos
  • ✅ Todos los tests siguen el patrón AAA
  • ✅ Naming descriptivo: puedes leer pytest -v y 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

  1. pytest Documentation - Referencia completa
  2. pytest Fixtures - Guía de fixtures
  3. pytest Parametrize - Referencia de parametrize
  4. Python re module - Para entender los regex en data_utils.py
  5. 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