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.inicon todos los markers registrados
Resumen de conceptos del módulo
| Cápsula | Concepto central | Cuándo aplicarlo |
|---|---|---|
| 01 | Non-determinism + espectro de assertions | Siempre que uses LLM real |
| 02 | E2E tests: flujo completo con assertions flexibles | Pre-release, validación de calidad |
| 03 | Decision framework: mock vs sandbox vs real | Diseño del pipeline de CI |
| 04 | Semantic similarity: comparar significado con embeddings | Output varía en redacción |
| 05 | Property-based testing: invariantes con Hypothesis | Parsers, processors, lógica determinística |
| 06 | Flaky management: retry, tolerance, quarantine | Cuando la varianza del LLM causa fallos |
| 07 | Proyecto: suite completa M1+M2+M3 | El proyecto integrador |
| 08 | Troubleshooting y cierre | Cuando 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
- Input sanitization: validar y sanear input del usuario antes del LLM
- Prompt injection detection: detectar ataques de jailbreak/injection
- Output validation: verificar que el output del LLM cumple restricciones de seguridad
- PII filtering: eliminar información personal identificable del output
- Content moderation: detectar contenido tóxico o inapropiado
- 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:
assert "positiv" in result["explanation"].lower(): El LLM puede decir "El texto tiene carga emocional favorable" (sin "positiv") — assertion frágil pero razonable.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:
-
Relajar el threshold del score (más simple):
assert result["score"] > 0.70 # Más permisivo pero aún significativo -
Usar semantic similarity para la explicación:
assert_semantically_similar( result["explanation"], "texto positivo con valoración favorable", threshold=0.65 ) -
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
- pytest-rerunfailures — Retry automático para tests flaky
- sentence-transformers — Pretrained Models — Qué modelo elegir para español/multilingüe
- Hypothesis — Reproducing failures — Entender el shrinking de Hypothesis
- Non-Determinism in Tests — Martin Fowler sobre flaky tests
- GitHub Actions — Workflow syntax — Para configurar CI/CD
- Módulo 4: Guardrails — Input/Output Validation
- OpenAI Pricing — Para calcular budgets exactos
- Testing ML Systems — Referencia de Google para testing de ML