Módulo 8: Proyecto Integrador — Test Suite Completa con TDD

Business Logic, Coverage y Edge Cases

Business Logic, Coverage y Edge Cases

Descripción de la cápsula

El core de TaskFlow API ya funciona: auth, equipos, tareas CRUD. Pero una aplicación profesional no solo hace lo básico — valida reglas de negocio, maneja edge cases, y expone estadísticas útiles. Esta cápsula cierra esos gaps usando TDD: implementas transiciones de estado válidas, permisos por rol, estadísticas de equipo, y usas hypothesis para property-based tests que descubren casos que no habías pensado.

Al terminar tendrás cobertura ≥90% y confianza en que las reglas de negocio están correctamente implementadas y testeadas.


Reglas de Negocio a Implementar

1. Transiciones de estado

Las tareas tienen estados: pending, in_progress, completed. No todas las transiciones son válidas:

  • pending → in_progress: válida
  • in_progress → completed: válida
  • completed → in_progress: inválida (no retroceder)
  • pending → completed: inválida (debe pasar por in_progress)
  • Cualquier transición a un status inexistente: inválida

2. Permisos

  • Solo el owner del equipo puede agregar miembros (ya cubierto en Teams)
  • Solo el usuario asignado puede marcar una tarea como completed
  • Tarea sin asignar: cualquier miembro del equipo puede completarla

3. Estadísticas de equipo

  • Conteo de tareas por status (pending, in_progress, completed)
  • Tasa de completación: completed / total * 100

Implementar con TDD: Transiciones de Estado

Ciclo 1: Definir las reglas

Tests en tests/unit/test_rules.py:

# tests/unit/test_rules.py
from app.tasks.rules import can_transition, VALID_TRANSITIONS

def test_pending_to_in_progress_is_valid():
    assert can_transition("pending", "in_progress") is True

def test_in_progress_to_completed_is_valid():
    assert can_transition("in_progress", "completed") is True

def test_completed_to_in_progress_is_invalid():
    assert can_transition("completed", "in_progress") is False

def test_pending_to_completed_is_invalid():
    assert can_transition("pending", "completed") is False

def test_invalid_from_status_raises():
    with pytest.raises(ValueError, match="Unknown status"):
        can_transition("invalid", "pending")

def test_invalid_to_status_raises():
    with pytest.raises(ValueError, match="Unknown status"):
        can_transition("pending", "done")

Prompt a Claude Code:

Implementa app/tasks/rules.py con:
- can_transition(from_status, to_status) -> bool
- Transiciones válidas: pending->in_progress, in_progress->completed
- Cualquier otra transición retorna False
- Status desconocido: raise ValueError("Unknown status")
- Estados válidos: pending, in_progress, completed

Ciclo 2: Integrar en TaskService

Tests en tests/unit/test_tasks.py:

def test_update_task_status_valid_transition_succeeds():
    service = TaskService()
    task = service.create_task("T1", team_id=1, created_by=1, status="pending")
    updated = service.update_task(task["id"], status="in_progress")
    assert updated["status"] == "in_progress"

def test_update_task_status_invalid_transition_raises():
    service = TaskService()
    task = service.create_task("T1", team_id=1, created_by=1, status="completed")
    with pytest.raises(ValueError, match="Invalid transition"):
        service.update_task(task["id"], status="in_progress")

Prompt a Claude Code:

Modifica TaskService.update_task para validar transiciones de estado.
- Antes de actualizar status, llama can_transition(from, to)
- Si inválida: raise ValueError("Invalid transition")
- Usa app.tasks.rules.can_transition

Implementar con TDD: Permisos

Ciclo 3: Solo asignee puede completar

Tests en tests/unit/test_rules.py:

def test_only_assigned_user_can_complete_task():
    # Lógica: si assigned_to existe y no es el usuario actual, no puede completar
    from app.tasks.rules import can_user_complete_task
    assert can_user_complete_task(assigned_to=5, current_user_id=5) is True
    assert can_user_complete_task(assigned_to=5, current_user_id=3) is False

def test_unassigned_task_any_member_can_complete():
    from app.tasks.rules import can_user_complete_task
    assert can_user_complete_task(assigned_to=None, current_user_id=1) is True

Prompt a Claude Code:

Implementa can_user_complete_task(assigned_to, current_user_id) en app/tasks/rules.py:
- Si assigned_to is None: cualquier usuario puede completar (return True)
- Si assigned_to == current_user_id: puede completar (return True)
- Si assigned_to != current_user_id: no puede (return False)

Ciclo 4: Integrar permiso en update

Tests:

def test_non_assigned_user_cannot_complete_task():
    service = TaskService()
    task = service.create_task("T1", team_id=1, created_by=1, status="in_progress")
    service.assign_task(task["id"], user_id=5)  # Asignar a usuario 5
    with pytest.raises(PermissionError, match="Only assigned user"):
        service.update_task(task["id"], status="completed", updated_by=3)  # Usuario 3 intenta

Prompt a Claude Code:

Modifica TaskService.update_task:
- Cuando status cambia a "completed", verifica can_user_complete_task(assigned_to, updated_by)
- Si False: raise PermissionError("Only assigned user can complete this task")
- updated_by es el user_id del usuario que hace la actualización

Implementar con TDD: Estadísticas

Ciclo 5: Team stats

Tests en tests/unit/test_rules.py o tests/unit/test_tasks.py:

def test_get_team_stats_returns_tasks_by_status():
    from app.tasks.service import TaskService
    service = TaskService()
    service.create_task("T1", team_id=1, created_by=1, status="pending")
    service.create_task("T2", team_id=1, created_by=1, status="in_progress")
    service.create_task("T3", team_id=1, created_by=1, status="completed")
    service.create_task("T4", team_id=1, created_by=1, status="completed")
    stats = service.get_team_stats(team_id=1)
    assert stats["by_status"]["pending"] == 1
    assert stats["by_status"]["in_progress"] == 1
    assert stats["by_status"]["completed"] == 2
    assert stats["total"] == 4

def test_get_team_stats_completion_rate():
    service = TaskService()
    service.create_task("T1", team_id=1, created_by=1, status="pending")
    service.create_task("T2", team_id=1, created_by=1, status="completed")
    stats = service.get_team_stats(team_id=1)
    assert stats["completion_rate"] == 50.0  # 1/2 * 100

def test_get_team_stats_empty_team():
    service = TaskService()
    stats = service.get_team_stats(team_id=999)
    assert stats["total"] == 0
    assert stats["completion_rate"] == 0.0

Prompt a Claude Code:

Agrega get_team_stats(team_id) a TaskService:
- Retorna { "by_status": {"pending": n, "in_progress": n, "completed": n}, "total": n, "completion_rate": float }
- completion_rate = (completed / total * 100) si total > 0, else 0

Medir Coverage con pytest-cov

Configuración en pyproject.toml

[tool.coverage.run]
source = ["app"]
omit = ["app/__init__.py", "tests/*"]

[tool.coverage.report]
fail_under = 90
show_missing = true
exclude_lines = [
    "pragma: no cover",
    "def __repr__",
    "raise NotImplementedError"
]

Comando

pytest tests/ -v --cov=app --cov-report=term-missing --cov-report=html

Interpretar el reporte

  • Line coverage: porcentaje de líneas ejecutadas
  • term-missing: muestra qué líneas no están cubiertas
  • Target: ≥90% en app/

Prompts para Edge Case Discovery con Claude Code

Prompt 1: Análisis de gaps

Revisa app/auth/service.py y app/tasks/service.py.
Lista todos los edge cases que podrían fallar y que no están cubiertos por los tests actuales.
Para cada uno, sugiere el nombre de un test que lo cubriría.

Prompt 2: Generar tests para un módulo

Genera 5 tests adicionales para app/tasks/rules.py que cubran edge cases.
Incluye: status vacío, None como assigned_to, completion_rate con total=0, transición al mismo status.

Prompt 3: Boundary testing

¿Qué valores límite podrían romper get_team_stats o can_transition?
Genera tests con pytest.mark.parametrize para esos casos.

Property-Based Testing con Hypothesis

Instalación

pip install hypothesis

Tests con hypothesis en test_rules.py

from hypothesis import given, strategies as st

@given(
    from_s=st.sampled_from(["pending", "in_progress", "completed"]),
    to_s=st.sampled_from(["pending", "in_progress", "completed"])
)
def test_can_transition_property_based(from_s, to_s):
    """Cualquier transición debe ser consistente: o True o False, nunca excepción inesperada."""
    result = can_transition(from_s, to_s)
    assert isinstance(result, bool)
    if from_s == to_s:
        # Transición a mismo estado: podría ser True o False según tu spec
        pass  # Solo verificamos que no crashea
    elif (from_s, to_s) in [("pending", "in_progress"), ("in_progress", "completed")]:
        assert result is True
    else:
        assert result is False

Otro ejemplo: completion_rate

@given(
    pending=st.integers(min_value=0, max_value=100),
    in_progress=st.integers(min_value=0, max_value=100),
    completed=st.integers(min_value=0, max_value=100)
)
def test_completion_rate_always_between_0_and_100(pending, in_progress, completed):
    total = pending + in_progress + completed
    if total == 0:
        rate = 0.0
    else:
        rate = (completed / total) * 100
    assert 0 <= rate <= 100

Checklist de Coverage ≥90%

Antes de dar por cerrada esta cápsula:

  • pytest tests/ --cov=app --cov-fail-under=90 pasa
  • Todos los módulos de app/ tienen tests unit
  • app/tasks/rules.py tiene tests explícitos + hypothesis
  • Edge cases documentados: transiciones inválidas, permisos, stats vacíos
  • No hay "pragma: no cover" innecesarios — si algo no se testea, pregúntate por qué

Errores Comunes

Error 1: Tests que pasan pero coverage baja

Causa: Tests superficiales que no ejecutan ramas condicionales (if/else, excepciones).

Solución: Revisa --cov-report=term-missing y escribe tests que ejerciten las líneas faltantes.

Error 2: Hypothesis encuentra contraejemplos

Causa: La propiedad que definiste es incorrecta o la implementación tiene un bug.

Solución: Hypothesis te dará el ejemplo que falla. Analízalo: ¿el test está mal o la implementación? Corrige lo que corresponda.

Error 3: Demasiados "pragma: no cover"

Causa: Código difícil de testear (logging, debug) marcado como no cover.

Solución: Solo usa pragma en código realmente no testeable. Si es lógica de negocio, debe tener test.


Resumen

  • Implementas reglas de negocio con TDD: transiciones de estado, permisos, estadísticas
  • Mides coverage con pytest-cov; target ≥90%
  • Usas prompts a Claude Code para descubrir edge cases no considerados
  • Usas hypothesis para property-based tests que validan invariantes
  • La combinación de tests explícitos + property-based da confianza profesional

Próxima cápsula: Mocks, fixtures y CI pipeline — cerrar la infraestructura de testing.


Módulo 8, Cápsula 04 — Testing with Claude Code Guide