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
- pytest-cov Documentation - Referencia de pytest-cov
- Hypothesis Documentation - Property-based testing
- Coverage.py Configuration - Configuración avanzada
- Martin Fowler: Test Coverage - Perspectiva sobre coverage
- 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