Módulo 3: Integration Testing & Estrategias No-Determinísticas

8. Resumen y Troubleshooting del Módulo 3

Descripción

Cierre del Módulo 3: los 8 errores más comunes con sus soluciones, árbol de diagnóstico rápido, checklist de cierre, resumen de conceptos, y preparación para el Módulo 4 (Guardrails). Si algo salió mal durante el proyecto de integration tests, aquí está la solución. Si todo salió bien, este cierre consolida lo aprendido.


Los 8 errores más comunes del Módulo 3

Error 1: Integration tests en cada commit (sin budget control)

Síntoma:

# Después de 2 semanas de commits:
$ openai usage --month
Total: $87.50 ← ¡Esto es real!
"Los tests de CI costaron más que la infraestructura"

Causa: Integration tests configurados para correr en cada push, sin budget control y sin skip condicional.

Solución:

# .github/workflows/tests.yml
jobs:
  unit-tests:
    # ✅ Siempre — sin API key, sin costo
    steps:
      - run: pytest -m "not integration" -v

  integration-tests:
    if: github.ref == 'refs/heads/main'  # ← Solo en main
    env:
      OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
      E2E_BUDGET_USD: "0.25"               # ← Budget limitado
    steps:
      - run: pytest -m integration --timeout=60

Regla: Unit tests en cada push. Integration tests solo en main (o nightly).


Error 2: Semantic threshold mal calibrado

Síntoma:

# Test falla aunque el output es correcto:
assert_semantically_similar(
    actual="El texto expresa alegría y satisfacción",
    expected="Sentimiento positivo detectado",
    threshold=0.95  # ← Demasiado alto
)
# AssertionError: Similarity 0.78 < 0.95
# Pero 0.78 indica el mismo tema — el threshold está mal calibrado

Causa: Usar un threshold genérico sin calibrar para el dominio específico.

Solución:

# Calibrar primero:
pairs = [
    ("El texto expresa alegría y satisfacción", "Sentimiento positivo detectado"),
    ("El texto tiene carga emocional positiva", "Texto positivo y optimista"),
    # Añadir 5-10 pares equivalentes del dominio
]

for a, b in pairs:
    sim = semantic_similarity_local(a, b)
    print(f"{sim:.3f}: '{a[:40]}'")
    
# Resultado: ~0.72-0.80 para estos pares
# → Usar threshold=0.70 para este tipo de comparación

Regla: Para cada tipo de assertion semántica, calibrar el threshold con 5+ pares de ejemplo del dominio antes de usarlo.


Error 3: Assertions exactas en tests con LLM real

Síntoma:

# Falla aleatoriamente — el score varía en cada ejecución
@pytest.mark.integration
def test_score_exact():
    result = analyze_sentiment("Me encanta")
    assert result["score"] == 0.92  # ← Falla 60% del tiempo
    assert result["explanation"] == "El texto usa lenguaje positivo"  # ← Falla 80%

Causa: Aplicar el estilo de unit tests (mock determinístico) a integration tests con LLM real.

Solución:

@pytest.mark.integration
def test_score_flexible(integration_client):
    result = analyze_sentiment("Me encanta", client=integration_client)
    
    # ✅ Rango, no valor exacto
    assert 0.6 <= result["score"] <= 1.0
    
    # ✅ Propiedad, no texto exacto
    assert len(result.get("explanation", "")) > 5
    
    # ✅ Categoría (esto sí puede ser exacto para texto claro)
    assert result["sentiment"] == "positivo"

Regla: Con LLM real, usa propiedades y rangos. Solo la categoría/label puede ser assertion exacta para textos inequívocos.


Error 4: property-based con LLM real (sin limit de ejemplos)

Síntoma:

# Hypothesis genera 100 ejemplos × cada uno llama al LLM real
@given(text=st.text())
def test_pipeline_hypothesis():
    result = analyze_sentiment(text)  # LLM real × 100 veces
    assert result["sentiment"] in ["positivo", "negativo", "neutral"]

# Resultado: $0.10 por run × 50 runs = $5/día solo en este test

Causa: Usar property-based testing con LLM real sin limitar max_examples.

Solución:

# Opción A: usar mock para property-based (recomendado)
@given(content=st.fixed_dictionaries({
    "sentiment": st.sampled_from(["positivo", "negativo", "neutral"]),
    "score": st.floats(0, 1)
}))
@settings(max_examples=50)  # Suficiente para encontrar edge cases
def test_pipeline_property_with_mock(content):
    mock_client = create_mock_client(content)
    result = analyze_sentiment("texto", client=mock_client)
    assert result["sentiment"] in ["positivo", "negativo", "neutral"]

# Opción B: si necesitas LLM real, limitar ejemplos
@given(text=st.sampled_from(PREDEFINED_INPUTS))  # Inputs predefinidos, no aleatorios
@settings(max_examples=5)  # Muy pocos ejemplos con LLM real
def test_with_real_llm(text, integration_client):
    result = analyze_sentiment(text, client=integration_client)
    assert result["sentiment"] in ["positivo", "negativo", "neutral"]

Regla: Property-based testing + mocks = ideal. Property-based + LLM real = usar max_examples muy pequeño (≤10).


Error 5: Flaky tests sin quarantine → ignorados

Síntoma:

CI output Monday: 2 tests failed → "Es el LLM, es normal" → merged
CI output Tuesday: 3 tests failed → "Siempre pasa" → merged  
CI output Wednesday: Bug en producción → nadie se dio cuenta porque ignoraban fallos

Causa: Normalización de la flakiness — el equipo aprende a ignorar tests que fallan.

Solución:

# Paso 1: Identificar tests flaky
# pytest --count=10 tests/integration/ -v  ← Ver cuántas veces falla cada uno

# Paso 2: Para tests flaky por varianza del LLM — relajar la assertion
# Si no se puede relajar → quarantine

@pytest.mark.quarantine(
    reason="Falla ~20% del tiempo. Score varía para inputs ambiguos. "
           "Issue: github.com/repo/issues/456 | Created: 2025-01-15"
)
@pytest.mark.integration
def test_ambiguous_text_score():
    result = analyze_sentiment("texto ambiguo puede ser positivo o neutral")
    assert result["score"] >= 0.8  # ← Demasiado estricto para texto ambiguo

# CI: excluir quarantine del pipeline normal
# pytest -m "not quarantine" -v

Regla: Un fallo no investigado = una bomba de tiempo. Quarantine es temporal, no permanente.


Error 6: Budget no rastreado en multi-fixture

Síntoma:

# El budget tracker tiene race condition con tests paralelos:
@pytest.fixture(scope="session")
def e2e_budget():
    return {"spent": 0.0, "max": 0.50}

# En test A: budget["spent"] += 0.01
# En test B: budget["spent"] += 0.01  ← Race condition en paralelo
# Resultado: los checks de budget no son confiables

Causa: El dict mutable compartido no es thread-safe.

Solución:

import threading

@pytest.fixture(scope="session")
def e2e_budget():
    class SafeBudget:
        def __init__(self, max_usd=0.50):
            self.max_usd = max_usd
            self._spent = 0.0
            self._lock = threading.Lock()
        
        def add_cost(self, cost: float):
            with self._lock:
                self._spent += cost
                if self._spent >= self.max_usd:
                    pytest.skip(f"Budget excedido: ${self._spent:.4f}")
        
        @property
        def spent(self):
            with self._lock:
                return self._spent
    
    return SafeBudget(max_usd=float(os.getenv("E2E_BUDGET_USD", "0.50")))

Regla: El budget tracker en sesiones paralelas necesita ser thread-safe (lock).


Error 7: Skip de integration tests que bloquea el CI

Síntoma:

# CI output:
# SKIPPED tests/integration/test_e2e.py::test_sentiment_e2e
# SKIPPED tests/integration/test_e2e.py::test_negative_sentiment
# ...
# === 0 passed, 20 skipped in 0.5s ===
# Build status: ✅ PASSING  ← Pero ningún test corrió realmente

Causa: En CI, los integration tests siempre se skipean porque OPENAI_API_KEY no está configurada o RUN_INTEGRATION no está en true.

Solución:

# Verificar que el job de integration tiene las variables:
integration-tests:
  if: github.ref == 'refs/heads/main'
  env:
    OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}  # ← Debe existir como secret
    RUN_INTEGRATION: "true"                           # ← Explícitamente "true"
  steps:
    - run: |
        # Verificar que las variables están configuradas antes de correr
        if [ -z "$OPENAI_API_KEY" ]; then
          echo "ERROR: OPENAI_API_KEY no configurada como secret"
          exit 1
        fi
        pytest -m integration -v --timeout=60

Regla: Verificar que los integration tests realmente corren en CI — no solo que no fallan.


Error 8: Tests de propiedades con propiedades vagas

Síntoma:

# Propiedad tan vaga que no detecta ningún bug
@given(text=st.text())
def test_pipeline_vague_property(text):
    mock_client = make_mock(text)
    result = analyze_sentiment(text, client=mock_client)
    assert result is not None  # ← Esto siempre pasa — no detecta nada

Causa: Pensar en "el output existe" en vez de "el output tiene propiedades específicas".

Solución:

@given(content=st.fixed_dictionaries({
    "sentiment": st.sampled_from(["positivo", "negativo", "neutral"]),
    "score": st.floats(0, 1, allow_nan=False)
}))
@settings(max_examples=50)
def test_pipeline_specific_properties(content):
    mock_client = create_mock_client(content)
    result = analyze_sentiment("texto", client=mock_client)
    
    # ✅ Propiedades específicas:
    assert result["sentiment"] in ["positivo", "negativo", "neutral"]  # Valores válidos
    assert 0.0 <= result["score"] <= 1.0   # Rango correcto
    assert isinstance(result["keywords"], list)  # Tipo correcto
    assert not (result["score"] != result["score"])  # No es NaN

Regla: Una buena propiedad puede fallar para algún input. Si tu propiedad siempre pasa para cualquier posible output, no protege nada.


Árbol de diagnóstico rápido

Test de integration falla
  ├── ¿Es SKIPPED? 
  │   ├── Sin API key → configurar OPENAI_API_KEY
  │   └── Sin RUN_INTEGRATION → configurar RUN_INTEGRATION=true
  │
  ├── ¿Falla con AssertionError?
  │   ├── Assertion exacta con LLM real → relajar a rango/propiedad
  │   ├── Semantic similarity < threshold → calibrar threshold con pares del dominio
  │   └── Falla siempre → bug real en el código
  │
  ├── ¿Falla intermitentemente (~20-40%)?
  │   ├── Por varianza del LLM → relajar assertion o añadir retry
  │   └── Por timeout → añadir @pytest.mark.timeout + retry
  │
  ├── ¿Budget excedido en CI?
  │   ├── Integration corre en cada push → mover a solo main
  │   └── Muchos tests → reducir al subset crítico
  │
  └── ¿Property-based test falla con ejemplo específico?
      ├── Ver el ejemplo generado por Hypothesis (shrunk)
      └── El ejemplo mínimo revela el edge case a arreglar

Test semántico falla con similarity < threshold
  ├── ¿El output es correcto semánticamente?
  │   ├── Sí → threshold demasiado alto, calibrar con pares del dominio
  │   └── No → el LLM produce output incoherente → bug en el prompt
  └── ¿El expected es muy específico?
      └── Hacer el expected más general, o usar assertion de propiedades

Checklist de cierre del Módulo 3

Tests escritos

  • Al menos 4 tests E2E con LLM real (con skip condicional)
  • Al menos 2 tests con semantic similarity (threshold calibrado)
  • Al menos 4 tests property-based (con mock, propiedades específicas)
  • Tests de flaky management (al menos 1 con retry, 1 con quarantine)
  • Configuración de entornos correcta (unit siempre, integration solo en main)

Calidad de los tests

  • Budget tracker funcionando y thread-safe
  • Assertions flexibles en integration tests (rangos, propiedades, semántica)
  • Thresholds de semantic similarity calibrados con pares del dominio
  • Propiedades de Hypothesis son específicas y pueden fallar para inputs incorrectos

Performance y configuración

  • pytest -m "not integration" < 15 segundos
  • Budget máximo configurado ($0.25-$0.50 por run)
  • CI/CD: unit en cada push, integration solo en main
  • pytest.ini con todos los markers registrados

Resumen de conceptos del módulo

CápsulaConcepto centralCuándo aplicarlo
01Non-determinism + espectro de assertionsSiempre que uses LLM real
02E2E tests: flujo completo con assertions flexiblesPre-release, validación de calidad
03Decision framework: mock vs sandbox vs realDiseño del pipeline de CI
04Semantic similarity: comparar significado con embeddingsOutput varía en redacción
05Property-based testing: invariantes con HypothesisParsers, processors, lógica determinística
06Flaky management: retry, tolerance, quarantineCuando la varianza del LLM causa fallos
07Proyecto: suite completa M1+M2+M3El proyecto integrador
08Troubleshooting y cierreCuando algo falla

Métricas de éxito del módulo

# Resultado esperado al completar el módulo:

$ pytest -m "not integration" -v --tb=short
=== 65+ passed in 8.2s ===  ← Unit tests rápidos

$ pytest -m integration -v --timeout=60
=== 12 passed in 47.3s === ← Integration pasan con LLM real

$ pytest tests/integration/test_property.py -v
=== 5 passed in 2.1s ===  ← Property-based con Hypothesis

# Coverage final:
$ pytest -m "not integration" --cov=app --cov-report=term-missing
app/parsers.py      96%
app/processors.py   97%
app/sentiment.py    82%
Total               89%

Próximo módulo: Guardrails

El Módulo 4 (Guardrails — Input & Output Validation) aplica directamente lo que aprendiste en M3.

La conexión M3 → M4

Módulo 3: Aprendiste a testear con LLM real usando assertions flexibles
          ↓
Módulo 4: Implementas guardrails — y los testeas con las estrategias de M3

Ejemplo:
- Guardrail: "Bloquear prompt injection"
- Test M3 style: E2E test que verifica que el sanitizer bloquea inputs maliciosos
  con el LLM real — assertion: el output no contiene el injection

- Guardrail: "Output no puede contener PII"
- Test M3 style: Property-based test que verifica que para cualquier output
  del LLM (mockeado), el filtro de PII siempre elimina emails y teléfonos

Qué verás en el Módulo 4

  1. Input sanitization: validar y sanear input del usuario antes del LLM
  2. Prompt injection detection: detectar ataques de jailbreak/injection
  3. Output validation: verificar que el output del LLM cumple restricciones de seguridad
  4. PII filtering: eliminar información personal identificable del output
  5. Content moderation: detectar contenido tóxico o inapropiado
  6. Rate limiting: proteger contra abuso de la API

Y cada guardrail tendrá sus tests — usando las técnicas de M2 (mocks, contract tests) y M3 (property-based, semantic assertions).


Ejercicios finales del módulo

Ejercicio 1: Diagnóstico de test real

El siguiente test falla con frequency del 35%. Diagnostica la causa y propón 3 estrategias de fix en orden de preferencia:

@pytest.mark.integration
def test_explanation_accuracy():
    result = analyze_sentiment("Muy buena compra, totalmente recomendable")
    assert "positiv" in result["explanation"].lower()
    assert result["score"] > 0.85
Ver solución

Diagnóstico:

  1. assert "positiv" in result["explanation"].lower(): El LLM puede decir "El texto tiene carga emocional favorable" (sin "positiv") — assertion frágil pero razonable.
  2. assert result["score"] > 0.85: El LLM puede dar 0.80, 0.82, 0.83 — todos correctos pero bajo el umbral.

Estrategias en orden de preferencia:

  1. Relajar el threshold del score (más simple):

    assert result["score"] > 0.70  # Más permisivo pero aún significativo
  2. Usar semantic similarity para la explicación:

    assert_semantically_similar(
        result["explanation"],
        "texto positivo con valoración favorable",
        threshold=0.65
    )
  3. Retry para la varianza residual:

    @pytest.mark.flaky(reruns=2, reruns_delay=1)

    (Solo si las dos anteriores no son suficientes)


Ejercicio 2: Diseñar una propiedad de Hypothesis

Para el siguiente processor, identifica 3 propiedades invariantes y escribe los tests:

def normalize_output(result: dict) -> dict:
    """Normaliza el output: sentiment a lowercase, score a 4 decimales, keywords sorted."""
    return {
        "sentiment": result.get("sentiment", "neutral").strip().lower(),
        "score": round(float(result.get("score", 0.5)), 4),
        "keywords": sorted(set(result.get("keywords", [])))
    }
Ver solución
from hypothesis import given, settings
import hypothesis.strategies as st

@given(sentiment=st.text(max_size=50))
def test_normalize_sentiment_always_lowercase(sentiment):
    """PROPIEDAD: El sentiment siempre está en lowercase."""
    result = normalize_output({"sentiment": sentiment, "score": 0.5})
    assert result["sentiment"] == result["sentiment"].lower()

@given(score=st.floats(allow_nan=False, allow_infinity=False))
def test_normalize_score_four_decimals(score):
    """PROPIEDAD: El score siempre tiene máximo 4 decimales."""
    result = normalize_output({"sentiment": "neutral", "score": score})
    decimal_part = str(result["score"]).split(".")
    if len(decimal_part) > 1:
        assert len(decimal_part[1]) <= 4

@given(keywords=st.lists(st.text(min_size=1), max_size=10))
def test_normalize_keywords_unique_sorted(keywords):
    """PROPIEDAD: Keywords siempre son únicos y ordenados."""
    result = normalize_output({"sentiment": "neutral", "score": 0.5, "keywords": keywords})
    
    # Son únicos
    assert len(result["keywords"]) == len(set(result["keywords"]))
    # Están ordenados
    assert result["keywords"] == sorted(result["keywords"])

Ejercicio 3: Presupuesto de tests

Tu equipo tiene $50/mes para integration tests. Diseña la estrategia para maximizar el valor:

Ver guía
Con $50/mes para integration tests con gpt-4o-mini:

Costo aproximado por test: $0.0002 (600 tokens promedio)
Tests posibles por mes: $50 / $0.0002 = 250,000 runs de test

Estrategia recomendada:
  - 10 tests críticos × 1 run/día × 30 días = 300 runs
  - Costo: 300 × $0.0002 = $0.06/mes ← Usas solo el 0.1% del presupuesto

Con $50/mes puedes:
  - Correr 200 integration tests 2 veces al día durante 30 días:
    200 × 2 × 30 = 12,000 runs × $0.0002 = $2.40/mes

Conclusión: Con gpt-4o-mini, el presupuesto de $50/mes es
más que suficiente para una suite razonable de integration tests.
El constraint real es el tiempo de ejecución, no el costo.

Si usaras gpt-4o (17x más caro):
  200 × 2 × 30 × $0.0034 = $40.80/mes ← Cerca del límite
  → Reducir a 50 tests críticos o ejecutar menos frecuentemente

Recursos adicionales

  1. pytest-rerunfailures — Retry automático para tests flaky
  2. sentence-transformers — Pretrained Models — Qué modelo elegir para español/multilingüe
  3. Hypothesis — Reproducing failures — Entender el shrinking de Hypothesis
  4. Non-Determinism in Tests — Martin Fowler sobre flaky tests
  5. GitHub Actions — Workflow syntax — Para configurar CI/CD
  6. Módulo 4: Guardrails — Input/Output Validation
  7. OpenAI Pricing — Para calcular budgets exactos
  8. Testing ML Systems — Referencia de Google para testing de ML