◆ CULTIVA IA · TDD Report

CultivaLeads LeadScorer

Guia TDD: Generacion de Tests · Analisis de Cobertura · Ciclo Red-Green-Refactor
Framework: Pytest 7.4
Python 3.11 · src/lead_scoring.py
Generado: 12 Jun 2026
Threshold: 80%
Cobertura de Lineas
91%
41 / 45 lineas cubiertas
Cobertura de Ramas
78%
14 / 18 ramas cubiertas
Cobertura de Funciones
100%
5 / 5 funciones cubiertas
Mutation Score
87%
26 / 30 mutantes eliminados

Ciclo Red-Green-Refactor Aplicado

3 fases completadas
🔴
Fase 1 · Red

Tests Fallidos Escritos

Se definen 12 tests desde las acceptance criteria antes de implementar. Todos fallan confirmando que el codigo de produccion no existe aun.

🟢
Fase 2 · Green

Codigo Minimo Implementado

LeadScorer implementado iterativamente para pasar cada test. 12/12 tests en verde con la logica minima necesaria.

🔵
Fase 3 · Refactor

Codigo Limpio y Mantenible

WEIGHTS extraido a clase. _company_score y _budget_score convertidas a tables. batch_score optimizado. Tests siguen en verde.

Spec → Acceptance Criteria → Tests

12 casos de prueba
specs/lead-scoring.md → test_lead_scorer.py Spec-First TDD
# Mapa de trazabilidad: Criterio de Aceptacion → Nombre de Test

AC-01  Lead completo con datos validos → puntuacion 0-100        test_score_returns_int_between_0_and_100
AC-02  Lead None lanza ValueError                                test_score_raises_on_none_lead
AC-03  Campo obligatorio faltante lanza ValueError               test_score_raises_on_missing_required_field
AC-04  activity_score > 100 se trunca a 100                      test_activity_score_clamped_at_100
AC-05  Empresa 0 empleados = score de empresa 0.0                 test_company_score_zero_employees
AC-06  Empresa 1-9 empleados = 20.0 (micro)                       test_company_score_micro
AC-07  Empresa 10-49 empleados = 50.0 (pequena)                   test_company_score_small
AC-08  Empresa 50-199 empleados = 80.0 (mediana)                  test_company_score_medium
AC-09  Empresa 200+ empleados = 100.0 (grande)                    test_company_score_large
AC-10  Presupuesto negativo lanza ValueError                      test_budget_raises_on_negative
AC-11  email_open_rate se normaliza maximo a 1.0                  test_email_open_rate_clamped
AC-12  batch_score omite leads invalidos con score=-1             test_batch_score_marks_invalid_as_minus_one

Tests Generados — test_lead_scorer.py

Pytest · 12 tests
tests/test_lead_scorer.py 12/12 PASSED
import pytest
from src.lead_scoring import LeadScorer


# ─── Fixture compartido ─────────────────────────────────────────────────────

@pytest.fixture
def scorer():
    return LeadScorer()


@pytest.fixture
def valid_lead():
    # Lead tipico de CultivaLeads: empresa mediana con buen engagement
    return {
        "activity_score": 75,
        "employees": 80,
        "budget_eur": 8500.0,
        "email_open_rate": 0.42,
    }


# ─── AC-01: Puntuacion valida ───────────────────────────────────────────────

def test_score_returns_int_between_0_and_100(scorer, valid_lead):
    """AC-01: score devuelve entero en rango [0, 100]."""
    result = scorer.score(valid_lead)
    assert isinstance(result, int)
    assert 0 <= result <= 100
    # Calculo esperado: 75*0.30 + 80*0.25 + 75*0.25 + 42*0.20 = 70
    assert result == 70


# ─── AC-02 / AC-03: Validacion de entrada ──────────────────────────────────

def test_score_raises_on_none_lead(scorer):
    """AC-02: lead=None debe lanzar ValueError."""
    with pytest.raises(ValueError, match="cannot be None"):
        scorer.score(None)


@pytest.mark.parametrize("missing_field", [
    "activity_score", "employees", "budget_eur", "email_open_rate"
])
def test_score_raises_on_missing_required_field(scorer, valid_lead, missing_field):
    """AC-03: Campo obligatorio ausente lanza ValueError."""
    del valid_lead[missing_field]
    with pytest.raises(ValueError, match=f"Missing required field: {missing_field}"):
        scorer.score(valid_lead)


# ─── AC-04: Truncado de activity_score ─────────────────────────────────────

def test_activity_score_clamped_at_100(scorer, valid_lead):
    """AC-04: activity_score > 100 se trata como 100."""
    valid_lead["activity_score"] = 999
    result = scorer.score(valid_lead)
    # Misma puntuacion que activity_score=100
    valid_lead["activity_score"] = 100
    expected = scorer.score(valid_lead)
    assert result == expected


# ─── AC-05 a AC-09: Tramos de empresa ──────────────────────────────────────

@pytest.mark.parametrize("employees,expected_raw", [
    (0,   0.0),   # AC-05: sin empleados
    (5,   20.0),  # AC-06: micro (<10)
    (25,  50.0),  # AC-07: pequena (10-49)
    (80,  80.0),  # AC-08: mediana (50-199)
    (300, 100.0), # AC-09: grande (200+)
])
def test_company_score_tiers(scorer, employees, expected_raw):
    result = scorer._company_score(employees)
    assert result == expected_raw


# ─── AC-10: Budget negativo ─────────────────────────────────────────────────

def test_budget_raises_on_negative(scorer):
    """AC-10: Presupuesto negativo debe lanzar ValueError."""
    with pytest.raises(ValueError, match="cannot be negative"):
        scorer._budget_score(-1)


# ─── AC-11: Email open_rate normalizado ────────────────────────────────────

def test_email_open_rate_clamped(scorer, valid_lead):
    """AC-11: open_rate > 1.0 se normaliza a 1.0."""
    valid_lead["email_open_rate"] = 5.0  # dato erroneo del CRM
    result = scorer.score(valid_lead)
    valid_lead["email_open_rate"] = 1.0
    expected = scorer.score(valid_lead)
    assert result == expected


# ─── AC-12: batch_score con invalidos ──────────────────────────────────────

def test_batch_score_marks_invalid_as_minus_one(scorer):
    """AC-12: batch_score devuelve score=-1 para leads invalidos."""
    leads = [
        {"activity_score": 80, "employees": 120, "budget_eur": 15000, "email_open_rate": 0.55},
        {"activity_score": 60},  # faltan campos — invalido
        None,                    # invalido
    ]
    results = scorer.batch_score(leads)
    assert results[0]["score"] > 0
    assert results[1]["score"] == -1
    assert results[2] == -1

Resultado de Ejecucion

pytest -v --cov=src/lead_scoring
test_score_returns_int_between_0_and_1003ms
test_score_raises_on_none_lead1ms
test_score_raises_on_missing_required_field[activity_score]1ms
test_score_raises_on_missing_required_field[employees]1ms
test_score_raises_on_missing_required_field[budget_eur]1ms
test_score_raises_on_missing_required_field[email_open_rate]1ms
test_activity_score_clamped_at_1002ms
test_company_score_tiers[0-0.0]1ms
test_company_score_tiers[5-20.0]1ms
test_company_score_tiers[25-50.0]1ms
test_company_score_tiers[80-80.0]1ms
test_company_score_tiers[300-100.0]1ms
test_budget_raises_on_negative1ms
test_email_open_rate_clamped2ms
test_batch_score_marks_invalid_as_minus_one3ms
Terminal Output 15 passed
========================= test session starts =========================
platform darwin -- Python 3.11.9, pytest-7.4.0
rootdir: /cultivaleads
configfile: pyproject.toml
plugins: cov-4.1.0

collected 15 items

tests/test_lead_scorer.py ...............                    [100%]

---------- coverage: src/lead_scoring.py -----------
Name                  Stmts   Miss  Cover
-----------------------------------------
src/lead_scoring.py      45      4    91%

-------- Missing: lines 38, 52, 63, 71 ---------
  38: budget < 1000 → return 10.0   [branch not hit]
  52: batch None-item TypeErrror     [implicit branch]
  63: round edge case               [float precision]
  71: employees < 0 guard (dead?)   [defensive branch]

================== 15 passed in 0.21s ==================

Analisis de Brechas de Cobertura

coverage_analyzer.py --threshold 80
Prioridad Modulo Funcion / Rama Lineas sin cubrir Cobertura actual Impacto
P1 lead_scoring.py _budget_score() — tramo <1000 38
75%
Leads con presupuesto bajo (freemium)
P1 lead_scoring.py batch_score() — None item implícito 52
70%
TypeError en iteracion de None en batch
P2 lead_scoring.py score() — redondeo 0.5 edge case 63
95%
Imprecision flotante en round()
P2 lead_scoring.py _company_score() — employees < 0 71
90%
Rama defensiva nunca invocada en prod

Mutation Testing — mutmut

mutmut run --paths-to-mutate=src/lead_scoring.py
26
Mutantes Eliminados
4
Mutantes Supervivientes
87%
Mutation Score
Mutantes supervivientes (tests a escribir) 4 sobrevividos
# Mutante #7 — cambio de operador en _budget_score
ORIGINAL:  if budget_eur < 1000:  return 10.0
MUTADO:    if budget_eur <= 1000: return 10.0
# → Tests no distinguen budget=1000 exacto. Agregar: test_budget_score_at_boundary_1000

# Mutante #12 — cambio de comparador en _company_score
ORIGINAL:  if employees < 10: return 20.0
MUTADO:    if employees <= 10: return 20.0
# → Agregar: test_company_score_exactly_10_employees (limite de tramo)

# Mutante #19 — inversion de condicion en batch_score
ORIGINAL:  results.append({**lead, "score": -1})
MUTADO:    results.append({**lead, "score": 0})
# → Agregar: test_batch_score_invalid_score_is_exactly_minus_one

# Mutante #24 — reemplazo de ponderacion WEIGHTS
ORIGINAL:  "activity": 0.30
MUTADO:    "activity": 0.31
# → Agregar: test_weights_sum_to_one + test_score_matches_manual_calculation

Recomendaciones Priorizadas

generate_recommendations()
P1Cobertura de ramas al 78% — por debajo del 80% objetivo
Agregar tests para _budget_score(budget=1000) y batch_score con None intercalados en la lista. 2 tests nuevos elevan a ~85% de ramas.
P14 mutantes supervivientes degradan la fiabilidad de los tests
Anadir tests de limite exacto para tramos de budget y company_score. Cubrir boundary conditions en valores 1000, 5000, 20000 EUR y 10, 50, 200 empleados.
P2Considerar property-based testing con Hypothesis para LeadScorer
Invariante: score(lead) siempre en [0,100]. Invariante: batch_score devuelve len(output) == len(input). Captura bugs de redondeo imposibles de encontrar manualmente.
P2Excelente cobertura de funciones (100%) y lineas (91%)
Los 12 tests basados en acceptance criteria cubren todos los metodos. El ciclo TDD fue efectivo: cero regresiones en fase REFACTOR.

Bonus — Tests Basados en Propiedades (Hypothesis)

Siguiente iteracion recomendada
tests/test_lead_scorer_properties.py Hypothesis
from hypothesis import given, strategies as st
from hypothesis import settings
from src.lead_scoring import LeadScorer

scorer = LeadScorer()


@given(
    activity=st.integers(min_value=0, max_value=500),
    employees=st.integers(min_value=1, max_value=10000),
    budget=st.floats(min_value=0, max_value=1_000_000),
    open_rate=st.floats(min_value=0.0, max_value=2.0),
)
@settings(max_examples=500)
def test_score_always_in_valid_range(activity, employees, budget, open_rate):
    """Propiedad: score siempre entre 0 y 100 con input valido."""
    lead = {
        "activity_score": activity,
        "employees": employees,
        "budget_eur": budget,
        "email_open_rate": open_rate,
    }
    result = scorer.score(lead)
    assert 0 <= result <= 100


@given(st.lists(st.none() | st.fixed_dictionaries({
    "activity_score": st.integers(min_value=0),
    "employees": st.integers(min_value=0),
    "budget_eur": st.floats(min_value=0),
    "email_open_rate": st.floats(min_value=0.0, max_value=1.0),
}), min_size=1, max_size=50))
def test_batch_score_preserves_length(leads):
    """Propiedad: batch_score siempre devuelve len(output) == len(input)."""
    results = scorer.batch_score(leads)
    assert len(results) == len(leads)