Módulo 5: Coverage y Edge Cases

Proyecto del Módulo: Suite con 90%+ Coverage

Proyecto del Módulo: Suite con 90%+ Coverage

Descripción del proyecto

Recibes un módulo de Python con funcionalidad completa pero sin tests (o con tests mínimos). Tu trabajo es usar todo lo aprendido — pytest-cov, interpretación de reportes, edge case discovery con Claude Code, boundary testing, y property-based testing con hypothesis — para llevar la cobertura de 0% a ≥90% siguiendo un proceso iterativo.

No se trata de escribir tests mecánicamente hasta llegar al número. Se trata de usar el workflow: medir → identificar gaps críticos → generar tests con Claude Code → medir de nuevo → descubrir edge cases → medir de nuevo. Cada iteración cierra gaps específicos con tests significativos.


Objetivo del Proyecto

Llevar un módulo existente de 0% coverage a ≥90% usando Claude Code como generador de tests y tu criterio para priorizar qué cubrir.

Al completar este proyecto:

  • ✅ Habrás ejecutado el ciclo iterativo de coverage al menos 3 veces
  • ✅ Habrás usado Claude Code para generar tests que cierren gaps específicos
  • ✅ Habrás descubierto edge cases con prompts sistemáticos
  • ✅ Habrás implementado al menos 2 property-based tests con hypothesis
  • ✅ Tendrás una suite con ≥90% line coverage y ≥80% branch coverage

Especificaciones Técnicas

Stack Tecnológico

  • Lenguaje: Python 3.10+
  • Testing: pytest, pytest-cov, hypothesis
  • AI: Claude Code

Setup Inicial

mkdir coverage-project
cd coverage-project
python -m venv venv
source venv/bin/activate
pip install pytest pytest-cov hypothesis

Estructura del Proyecto

coverage-project/
├── data_processor/
│   ├── __init__.py
│   ├── cleaner.py        ← Limpieza de datos (dado)
│   ├── transformer.py    ← Transformaciones (dado)
│   ├── validator.py      ← Validaciones (dado)
│   └── aggregator.py     ← Agregaciones (dado)
├── tests/
│   ├── __init__.py
│   ├── conftest.py
│   ├── test_cleaner.py       ← Tú creas
│   ├── test_transformer.py   ← Tú creas
│   ├── test_validator.py     ← Tú creas
│   └── test_aggregator.py    ← Tú creas
├── pyproject.toml
└── requirements.txt

El Código a Testear

cleaner.py

"""Data cleaning utilities."""

import re
from typing import Optional


def clean_string(value: str) -> str:
    if not isinstance(value, str):
        raise TypeError(f"Expected string, got {type(value).__name__}")
    cleaned = value.strip()
    cleaned = re.sub(r'\s+', ' ', cleaned)
    return cleaned


def clean_email(email: str) -> str:
    cleaned = clean_string(email).lower()
    if '@' not in cleaned:
        raise ValueError(f"Invalid email format: {email}")
    local, domain = cleaned.rsplit('@', 1)
    if not local or not domain:
        raise ValueError(f"Invalid email format: {email}")
    if '.' not in domain:
        raise ValueError(f"Invalid email domain: {domain}")
    return f"{local}@{domain}"


def clean_phone(phone: str) -> str:
    digits = re.sub(r'\D', '', phone)
    if len(digits) < 7 or len(digits) > 15:
        raise ValueError(f"Invalid phone number: {phone}")
    if len(digits) == 10:
        return f"({digits[:3]}) {digits[3:6]}-{digits[6:]}"
    elif len(digits) == 11 and digits[0] == '1':
        return f"+1 ({digits[1:4]}) {digits[4:7]}-{digits[7:]}"
    return digits


def remove_duplicates(items: list, key: Optional[str] = None) -> list:
    if not items:
        return []
    if key:
        seen = set()
        result = []
        for item in items:
            val = item.get(key) if isinstance(item, dict) else getattr(item, key, None)
            if val is None:
                raise ValueError(f"Key '{key}' not found in item: {item}")
            if val not in seen:
                seen.add(val)
                result.append(item)
        return result
    return list(dict.fromkeys(items))

transformer.py

"""Data transformation utilities."""

from typing import Any
from datetime import datetime


def to_snake_case(name: str) -> str:
    if not name:
        return ""
    result = name[0].lower()
    for char in name[1:]:
        if char.isupper():
            result += '_' + char.lower()
        elif char == ' ' or char == '-':
            result += '_'
        else:
            result += char
    return result


def flatten_dict(data: dict, prefix: str = "", separator: str = ".") -> dict:
    items = {}
    for key, value in data.items():
        new_key = f"{prefix}{separator}{key}" if prefix else key
        if isinstance(value, dict):
            items.update(flatten_dict(value, new_key, separator))
        elif isinstance(value, list):
            for i, item in enumerate(value):
                if isinstance(item, dict):
                    items.update(flatten_dict(item, f"{new_key}[{i}]", separator))
                else:
                    items[f"{new_key}[{i}]"] = item
        else:
            items[new_key] = value
    return items


def convert_types(value: Any, target_type: str) -> Any:
    converters = {
        "int": int,
        "float": float,
        "str": str,
        "bool": lambda v: v.lower() in ('true', '1', 'yes') if isinstance(v, str) else bool(v),
        "datetime": lambda v: datetime.fromisoformat(v) if isinstance(v, str) else v,
    }
    if target_type not in converters:
        raise ValueError(f"Unsupported type: {target_type}")
    try:
        return converters[target_type](value)
    except (ValueError, TypeError, AttributeError) as e:
        raise ValueError(f"Cannot convert {value!r} to {target_type}: {e}")


def batch_transform(items: list[dict], transformations: dict[str, str]) -> list[dict]:
    results = []
    for item in items:
        transformed = {}
        for field, target_type in transformations.items():
            if field in item:
                transformed[field] = convert_types(item[field], target_type)
            else:
                transformed[field] = None
        for field in item:
            if field not in transformations:
                transformed[field] = item[field]
        results.append(transformed)
    return results

validator.py

"""Data validation utilities."""

import re
from typing import Any, Optional


class ValidationResult:
    def __init__(self):
        self.errors: list[str] = []
        self.warnings: list[str] = []
    
    @property
    def is_valid(self) -> bool:
        return len(self.errors) == 0
    
    def add_error(self, message: str):
        self.errors.append(message)
    
    def add_warning(self, message: str):
        self.warnings.append(message)
    
    def __repr__(self):
        return f"ValidationResult(valid={self.is_valid}, errors={len(self.errors)}, warnings={len(self.warnings)})"


def validate_schema(data: dict, schema: dict[str, dict]) -> ValidationResult:
    result = ValidationResult()
    for field, rules in schema.items():
        value = data.get(field)
        
        if rules.get("required") and value is None:
            result.add_error(f"Field '{field}' is required")
            continue
        
        if value is None:
            continue
        
        expected_type = rules.get("type")
        if expected_type and not isinstance(value, expected_type):
            result.add_error(f"Field '{field}' must be {expected_type.__name__}, got {type(value).__name__}")
        
        min_val = rules.get("min")
        if min_val is not None and isinstance(value, (int, float)) and value < min_val:
            result.add_error(f"Field '{field}' must be >= {min_val}")
        
        max_val = rules.get("max")
        if max_val is not None and isinstance(value, (int, float)) and value > max_val:
            result.add_error(f"Field '{field}' must be <= {max_val}")
        
        pattern = rules.get("pattern")
        if pattern and isinstance(value, str) and not re.match(pattern, value):
            result.add_error(f"Field '{field}' does not match pattern '{pattern}'")
        
        max_length = rules.get("max_length")
        if max_length and isinstance(value, str) and len(value) > max_length:
            result.add_warning(f"Field '{field}' exceeds max length {max_length}")
    
    for field in data:
        if field not in schema:
            result.add_warning(f"Unexpected field: '{field}'")
    
    return result


def validate_batch(items: list[dict], schema: dict[str, dict]) -> list[ValidationResult]:
    return [validate_schema(item, schema) for item in items]

aggregator.py

"""Data aggregation utilities."""

from typing import Any, Callable, Optional
from collections import defaultdict


def group_by(items: list[dict], key: str) -> dict[Any, list[dict]]:
    if not items:
        return {}
    groups = defaultdict(list)
    for item in items:
        if key not in item:
            raise KeyError(f"Key '{key}' not found in item: {item}")
        groups[item[key]].append(item)
    return dict(groups)


def aggregate(
    items: list[dict],
    group_key: str,
    value_key: str,
    func: str = "sum",
) -> dict[Any, Any]:
    groups = group_by(items, group_key)
    agg_funcs: dict[str, Callable] = {
        "sum": sum,
        "avg": lambda vals: sum(vals) / len(vals) if vals else 0,
        "min": min,
        "max": max,
        "count": len,
    }
    if func not in agg_funcs:
        raise ValueError(f"Unknown aggregation: {func}. Use: {', '.join(agg_funcs)}")
    
    result = {}
    for group, group_items in groups.items():
        values = [item[value_key] for item in group_items if value_key in item]
        if not values and func != "count":
            result[group] = None
        else:
            result[group] = agg_funcs[func](values if func != "count" else group_items)
    return result


def top_n(items: list[dict], key: str, n: int = 5, reverse: bool = True) -> list[dict]:
    if not items:
        return []
    if n <= 0:
        raise ValueError("n must be positive")
    return sorted(items, key=lambda x: x.get(key, 0), reverse=reverse)[:n]


def compute_stats(values: list[float]) -> dict[str, float]:
    if not values:
        return {"count": 0, "sum": 0, "avg": 0, "min": 0, "max": 0, "range": 0}
    n = len(values)
    total = sum(values)
    avg = total / n
    min_val = min(values)
    max_val = max(values)
    sorted_vals = sorted(values)
    median = (
        sorted_vals[n // 2]
        if n % 2 == 1
        else (sorted_vals[n // 2 - 1] + sorted_vals[n // 2]) / 2
    )
    return {
        "count": n,
        "sum": total,
        "avg": avg,
        "min": min_val,
        "max": max_val,
        "range": max_val - min_val,
        "median": median,
    }

Proceso Paso a Paso

Iteración 1: Medir y generar tests base

# Medir coverage actual (debería ser 0%)
pytest --cov=data_processor --cov-report=term-missing tests/

Prompt a Claude Code:

Genera unit tests para data_processor/cleaner.py.
Cubre happy path y error handling.
Usa parametrize para variantes.
Patrón AAA, naming descriptivo.

Repite para cada archivo. Mide coverage.

Iteración 2: Cerrar gaps con edge cases

# Medir después de los tests base
pytest --cov=data_processor --cov-report=term-missing --cov-branch tests/

Identifica las líneas Missing. Prompt:

Mi coverage para data_processor/transformer.py muestra líneas 
no cubiertas: [líneas]. Genera tests que cubran esas líneas.
Incluye edge cases: inputs vacíos, None, tipos incorrectos.

Iteración 3: Property testing y boundary

# tests/conftest.py
import pytest


@pytest.fixture
def sample_items():
    return [
        {"name": "Alice", "age": 30, "dept": "eng"},
        {"name": "Bob", "age": 25, "dept": "sales"},
        {"name": "Carol", "age": 35, "dept": "eng"},
    ]

Agrega hypothesis para validar propiedades:

Genera property-based tests con hypothesis para
data_processor/aggregator.py. Define propiedades como:
- compute_stats siempre retorna min <= avg <= max
- group_by preserva la cantidad total de items
- top_n retorna a lo sumo n items

Iteración 4: Branch coverage y refinamiento

pytest --cov=data_processor --cov-branch --cov-report=html tests/
# Abre htmlcov/index.html y revisa branches no cubiertos

Criterios de Éxito

Tu proyecto está completo cuando:

  • ✅ pytest --cov=data_processor --cov-report=term-missing tests/ → ≥90% line coverage
  • ✅ pytest --cov=data_processor --cov-branch tests/ → ≥80% branch coverage
  • ✅ Al menos 2 property-based tests con hypothesis
  • ✅ Edge cases cubiertos: empty inputs, None, boundary values, type errors
  • ✅ Tests organizados por módulo (test_cleaner.py, test_transformer.py, etc.)
  • ✅ Mínimo 3 iteraciones del ciclo medir→generar→medir documentadas

Rúbrica de Evaluación (100 puntos)

Coverage (30 puntos)

  • (15 pts) ≥90% line coverage
  • (10 pts) ≥80% branch coverage
  • (5 pts) Sin gaps en error handling (except blocks cubiertos)

Tests de Calidad (30 puntos)

  • (10 pts) Happy path cubierto para todas las funciones
  • (10 pts) Error handling testeado (ValueError, TypeError, KeyError)
  • (10 pts) Edge cases cubiertos (empty, None, boundary, types)

Property-Based Testing (15 puntos)

  • (10 pts) Al menos 2 property tests con hypothesis
  • (5 pts) Properties significativas (no triviales)

Proceso (15 puntos)

  • (10 pts) Al menos 3 iteraciones medir→generar→medir documentadas
  • (5 pts) Uso de Claude Code con prompts específicos por iteración

Organización (10 puntos)

  • (5 pts) Tests organizados por módulo
  • (5 pts) conftest.py con fixtures compartidas

Extra Credit (hasta +10 puntos)

  • (+5 pts) ≥95% line coverage
  • (+5 pts) Property test que descubre un bug real en el código dado

Errores Comunes

Error 1: Escribir tests para llegar al número, no para validar comportamiento

Causa: Agregar assert True o tests triviales solo para subir el porcentaje.

Solución: Cada test debe verificar un comportamiento específico. Si una línea no se cubre, pregúntate "¿qué escenario la ejecuta?" y escribe un test para ese escenario.

Error 2: Ignorar branch coverage

Causa: Solo mirar line coverage, que puede ser engañoso.

Solución: Siempre ejecuta con --cov-branch. Branch coverage revela los paths lógicos no testeados.

Error 3: Property tests sin propiedades reales

Causa: @given(st.integers()) def test_func(n): assert True — esto no testa nada.

Solución: Define propiedades reales: "count siempre es ≥0", "min ≤ avg ≤ max", "flatten seguido de unflatten retorna el original."

Error 4: No documentar las iteraciones

Causa: Solo entregar los tests finales sin el proceso.

Solución: Registra: "Iteración 1: 0%→62%. Iteración 2: 62%→81% (edge cases). Iteración 3: 81%→92% (hypothesis + branches)."


Recursos para el Proyecto

  1. pytest-cov Documentation - Referencia de pytest-cov
  2. Hypothesis Documentation - Property-based testing
  3. Coverage.py Configuration - Configuración avanzada
  4. Martin Fowler: Test Coverage - Perspectiva sobre coverage
  5. Property-Based Testing with Python - Quickstart de hypothesis

Conexión con Siguiente Módulo

Lo que construiste hoy se expande en los siguientes módulos:

  • Módulo 6 (Mocking): Algunos gaps de coverage requieren mocks para servicios externos
  • Módulo 7 (Estrategia): Definirás targets de coverage como parte de tu estrategia de testing
  • Módulo 8 (Proyecto Final): El target es ≥90% coverage para toda la aplicación — el mismo workflow que practicaste aquí

Dominas el ciclo de coverage. Medir → identificar → generar → medir. Este loop iterativo es la herramienta profesional para mantener calidad en cualquier proyecto.


Módulo 5, Cápsula 06 — Testing with Claude Code Guide De 0% a 90%+ coverage — el workflow iterativo