Módulo 3: Integration Testing & Estrategias No-Determinísticas
3. LLM Real vs Mocks: Decision Framework
Descripción
¿Cuándo usar mocks y cuándo LLM real? Esta cápsula presenta un framework de decisión práctico basado en cuatro dimensiones: costo, velocidad, valor del test, y contexto de ejecución (desarrollo, CI, pre-release). Sin criterios claros, los equipos terminan con dos extremos igualmente problemáticos: tests caros que fallan por varianza del LLM, o suites de mocks que dan falsa seguridad sin detectar problemas reales.
El dilema real
Dos equipos con el mismo app de análisis de sentimiento:
Equipo A (solo mocks):
# Todo con mocks — 200 tests, todos pasan
# Velocidad: 5 segundos total
# Costo: $0
# Problema: En producción, el LLM real produce outputs
# con formato ligeramente diferente que el parser no maneja.
# Bug llegó a producción. Los mocks no lo detectaron.
Equipo B (todo real):
# Todo con LLM real — 200 tests
# Velocidad: 10 minutos
# Costo: $2 por run × 50 runs/día = $100/día
# Tests fallan 20% del tiempo por varianza → equipo ignora los fallos
# "Los tests de AI siempre son así" → confianza destruida
La solución: Un framework que combina ambos según el contexto.
El framework de decisión
Pregunta 1: ¿Necesito validar la ESTRUCTURA del output?
└── Sí → MOCK (contract test, determinístico, gratis)
└── No → siguiente pregunta
Pregunta 2: ¿Necesito validar la CALIDAD SEMÁNTICA del output?
└── Sí → LLM REAL (integration test, con budget)
└── No → siguiente pregunta
Pregunta 3: ¿Estoy en qué contexto?
├── Desarrollo diario → MOCK (velocidad, gratis)
├── CI cada commit → MOCK (no bloquear, no gastar)
├── CI en main/PR → MOCK + subset integration (si hay budget)
└── Pre-release → MOCK + integration completo (validación crítica)
La pregunta diagnóstico
La forma más rápida de decidir:
"¿Si el LLM devuelve basura estructurada, el test fallaría?"
- Sí → Usa mock (el test valida estructura, no calidad)
- No → Necesitas LLM real (el test valida calidad semántica)
Tabla de decisión completa
| Test a escribir | Mock | LLM Real | Razón |
|---|---|---|---|
| El output tiene key "sentiment" | ✅ | ❌ | Estructura — contract test |
| score está entre 0 y 1 | ✅ | ❌ | Constraint numérico |
| El parser maneja JSON en markdown | ✅ | ❌ | Lógica determinística |
| El handler de errores funciona | ✅ | ❌ | Error handling, no LLM |
| El resumen es coherente con el original | ❌ | ✅ | Calidad semántica |
| El prompt funciona en inglés y español | ❌ | ✅ | Comportamiento real del LLM |
| Model drift post-actualización | ❌ | ✅ | Requiere LLM real |
| El flujo completo produce output usable | ✅ + ❌ | ✅ | E2E combina ambos |
| Tests en cada commit de CI | ✅ | ❌ | Velocidad y costo |
| Validación pre-release | ✅ | ✅ | Ambos, con budget |
Los tres entornos de test
Entorno 1: Mock (Development/Fast CI)
# Características:
# - Sin API calls
# - Milliseconds
# - Determinístico
# - Costo: $0
# - Cuándo: desarrollo diario, cada commit
# Configuración:
@pytest.mark.unit
def test_contract_with_mock(make_sentiment_client):
client = make_sentiment_client(sentiment="positivo", score=0.9)
result = analyze_sentiment("texto", client=client)
assert result["sentiment"] == "positivo"
# pytest.ini:
# [pytest]
# addopts = -m "not integration" # Por defecto, solo unit
Entorno 2: Sandbox (Budget-Capped Integration)
# Características:
# - LLM real con budget máximo
# - Modelo económico (gpt-4o-mini)
# - Subset de tests críticos
# - Cuándo: CI en main, pre-release
# Configuración:
E2E_CONFIG = {
"model": "gpt-4o-mini",
"max_budget_usd": 0.25,
"max_tests": 15, # Solo los más críticos
"timeout_sec": 30
}
@pytest.mark.integration
@pytest.mark.sandbox
def test_e2e_sandbox(sandbox_client):
# sandbox_client verifica budget y hace skip si se excedió
result = analyze_sentiment("texto real de prueba", client=sandbox_client)
assert_quality_properties(result)
Entorno 3: Real (Full Validation)
# Características:
# - LLM real sin restricciones de modelo
# - Puede usar gpt-4o si hay razón
# - Suite completa de integration tests
# - Cuándo: release, manual pre-deploy, nightly
# Configuración:
@pytest.mark.integration
@pytest.mark.real
@pytest.mark.skipif(not os.getenv("FULL_VALIDATION"), reason="Solo en full validation")
def test_full_quality_validation(real_client):
result = analyze_sentiment("texto crítico", client=real_client)
assert_full_quality(result)
Configuración por entorno: código completo
# tests/config.py
import os
from enum import Enum
class TestEnvironment(Enum):
MOCK_ONLY = "mock_only"
SANDBOX = "sandbox"
FULL = "full"
def get_test_env() -> TestEnvironment:
"""
Determina el entorno de tests basado en variables de entorno.
Variables:
OPENAI_API_KEY: Requerida para sandbox y full
RUN_INTEGRATION: "true" para habilitar integration tests
FULL_VALIDATION: "true" para ejecutar la suite completa
Lógica:
- Sin API key: mock_only
- API key + RUN_INTEGRATION=true: sandbox
- API key + FULL_VALIDATION=true: full
"""
has_api_key = bool(os.getenv("OPENAI_API_KEY"))
run_integration = os.getenv("RUN_INTEGRATION", "false").lower() == "true"
full_validation = os.getenv("FULL_VALIDATION", "false").lower() == "true"
if not has_api_key:
return TestEnvironment.MOCK_ONLY
if full_validation:
return TestEnvironment.FULL
if run_integration:
return TestEnvironment.SANDBOX
return TestEnvironment.MOCK_ONLY
def should_run_integration() -> bool:
return get_test_env() in (TestEnvironment.SANDBOX, TestEnvironment.FULL)
def should_run_full() -> bool:
return get_test_env() == TestEnvironment.FULL
# En conftest.py:
@pytest.fixture(scope="session")
def test_env():
return get_test_env()
# Decoradores de conveniencia:
skip_unless_integration = pytest.mark.skipif(
not should_run_integration(),
reason="Requiere RUN_INTEGRATION=true y OPENAI_API_KEY"
)
skip_unless_full = pytest.mark.skipif(
not should_run_full(),
reason="Requiere FULL_VALIDATION=true y OPENAI_API_KEY"
)
Costo vs valor: ser estratégico
No todos los integration tests tienen el mismo valor. Prioriza:
# Prioridad ALTA: tests que detectan bugs reales frecuentes
# - Flujo principal (el 80% del tráfico)
# - Prompts que han tenido bugs antes
# - Validar cambio de modelo
@pytest.mark.integration
@pytest.mark.priority("high")
def test_main_flow_e2e():
"""El flujo principal funciona con el LLM real."""
result = analyze_sentiment("Texto representativo del tráfico real")
assert_quality_properties(result)
# Prioridad MEDIA: tests de robustez
# - Inputs edge (muy cortos, muy largos, otro idioma)
# - Manejo de errores del LLM
@pytest.mark.integration
@pytest.mark.priority("medium")
def test_edge_case_short_input():
result = analyze_sentiment("ok")
assert result["sentiment"] in ["positivo", "negativo", "neutral"]
# Prioridad BAJA: tests de calidad fina
# - Threshold de similitud semántica
# - Calidad del explanation
# Solo en full validation
@pytest.mark.integration
@pytest.mark.priority("low")
@skip_unless_full
def test_explanation_quality():
result = analyze_sentiment("Me encanta este producto")
assert len(result["explanation"]) > 20 # Explanation informativa
La trampa: tests que "validan estructura" con LLM real
Un error frecuente es correr tests de estructura con LLM real cuando el mock es suficiente:
# ❌ Costo innecesario: usa LLM real para validar estructura
@pytest.mark.integration
def test_output_has_sentiment_key():
client = openai.OpenAI() # LLM real
result = analyze_sentiment("texto", client=client)
assert "sentiment" in result # ← Esto no requiere LLM real
# ✅ Correcto: estructura con mock
@pytest.mark.unit
def test_output_has_sentiment_key(make_sentiment_client):
client = make_sentiment_client()
result = analyze_sentiment("texto", client=client)
assert "sentiment" in result # ← Mismo assertion, sin costo
La regla: Si el test fallaría aunque el LLM retornara "respuesta aleatoria válida", usa mock. Solo usa LLM real cuando necesitas que el contenido tenga sentido.
Configuración de CI/CD por entorno
# .github/workflows/tests.yml
name: Tests
on: [push, pull_request]
jobs:
# ─── Siempre: Unit Tests ───
unit-tests:
name: Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- run: pytest -m "not integration" -v --tb=short
# Sin API key, sin costo, determinístico, rápido
# ─── Solo en main: Integration (Sandbox) ───
integration-sandbox:
name: Integration Tests (Sandbox)
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
RUN_INTEGRATION: "true"
E2E_BUDGET_USD: "0.25"
steps:
- uses: actions/checkout@v3
- run: pytest -m integration --timeout=60 -v
# Con API key, budget limitado, solo en main
# ─── Manual: Full Validation ───
full-validation:
name: Full Validation
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch' # Solo manual
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
FULL_VALIDATION: "true"
steps:
- uses: actions/checkout@v3
- run: pytest -m "integration or e2e" -v
# Solo manual, sin límite de budget, para pre-release
Ejercicios
Ejercicio 1: Clasificar tus tests
Para los siguientes tests, decide: mock, sandbox, o full validation:
- Verificar que el output tiene la key "summary"
- Verificar que el resumen no habla de un tema diferente al input
- Verificar que el parser maneja JSON con markdown code blocks
- Verificar que el prompt en inglés produce outputs de calidad equivalente al español
- Verificar que el score es float entre 0 y 1
Ver solución
- Mock — estructura, contract test puro
- Sandbox/Full — relevancia semántica, requiere LLM real
- Mock — lógica determinística del parser
- Full — calidad comparativa, requiere LLM real en dos idiomas
- Mock — constraint numérico, contract test
Ejercicio 2: Diseñar el pipeline de CI
Diseña el pipeline de CI para un equipo con:
- 100 unit tests
- 15 integration tests críticos
- 5 tests de validación de calidad avanzada
¿Cuándo corre cada grupo? ¿Qué variables de entorno necesitas?
Ver solución
Push a cualquier rama:
→ 100 unit tests (sin API key, siempre, <30s)
Merge a main:
→ 100 unit tests
→ 15 integration tests (RUN_INTEGRATION=true, budget $0.15)
Manual pre-release:
→ 100 unit tests
→ 15 integration tests
→ 5 quality tests (FULL_VALIDATION=true)
Variables de entorno:
- OPENAI_API_KEY (secret)
- RUN_INTEGRATION (default: false)
- FULL_VALIDATION (default: false)
- E2E_BUDGET_USD (default: 0.25)
Ejercicio 3: El caso del model drift
Estás cambiando de gpt-4o-mini-2024-07-18 a gpt-4o-mini-2025-01-15. ¿Qué tests correrías para validar que no hay regresión?
Ver guía
- Todos los unit tests (ya deben pasar — son mocks, independientes del modelo)
- Integration tests de estructura (el output sigue teniendo las keys esperadas)
- Integration tests de calidad (semantic similarity >= threshold anterior)
- Comparar directamente: correr los mismos inputs con ambos modelos y comparar scores
- Tests de prompts específicos que saben a veces cambian de comportamiento entre versiones
Test específico de drift:
@pytest.mark.integration
def test_model_drift_detection(integration_client):
"""Detecta si el nuevo modelo cambia el comportamiento."""
test_cases = [
("Texto muy positivo", "positivo"),
("Texto muy negativo", "negativo"),
("Texto neutro factual", "neutral")
]
for text, expected_sentiment in test_cases:
result = analyze_sentiment(text, client=integration_client)
assert result["sentiment"] == expected_sentiment, \
f"Model drift detectado para: '{text[:50]}'"
Ejercicio 4: ROI del framework
Calcula el ahorro mensual de cambiar de "todo con LLM real" a "80% mock + 20% integration":
Datos:
- 200 tests totales
- Cada test: 600 tokens promedio
- gpt-4o-mini
- CI: 30 runs/día (commits del equipo)
Ver cálculo
Sin framework (todo real):
200 tests × 600 tokens × $0.00000015/token = $0.018/run
$0.018 × 30 runs/día = $0.54/día
$0.54 × 30 días = $16.20/mes
Con framework (80% mock, 20% integration, integration solo en main):
Integration: 40 tests × 600 tokens × $0.00000015 = $0.0036/run
Integration en main: ~5 runs/día (merges a main)
$0.0036 × 5 = $0.018/día
$0.018 × 30 días = $0.54/mes
Ahorro: $16.20 - $0.54 = $15.66/mes
Porcentaje: 97% reducción de costos
Bonus: los unit tests corren en <5s en vez de ~20 minutos →
30 devs × 30 min ahorrados/día × $80/hora = $1,200/mes en tiempo
Ejercicio 5: Documenta tu decisión
Escribe la sección "Testing Strategy" para el README de tu proyecto. Debe explicar:
- Qué tests usan mock vs LLM real
- Cómo ejecutar cada tipo
- Qué variables de entorno se necesitan
Ver plantilla
## Testing Strategy
### Tipos de tests
| Tipo | Mock/Real | Cuándo corre | Comando |
|------|-----------|--------------|---------|
| Unit tests | Mock | Siempre | `pytest -m unit` |
| Contract tests | Mock | Siempre | `pytest -m contract` |
| Integration (sandbox) | LLM real | En main | `pytest -m integration` |
| Full validation | LLM real | Manual | `FULL_VALIDATION=true pytest -m integration` |
### Variables de entorno
- `OPENAI_API_KEY`: Requerida para integration tests
- `RUN_INTEGRATION=true`: Habilita integration tests en CI
- `FULL_VALIDATION=true`: Habilita validación completa
- `E2E_BUDGET_USD=0.25`: Budget máximo por run (default: $0.25)
### Ejecutar tests localmente
\`\`\`bash
# Solo unit tests (siempre disponible, sin API key)
pytest -m "not integration" -v
# Con integration tests (requiere API key)
export OPENAI_API_KEY=sk-...
export RUN_INTEGRATION=true
pytest -m integration -v --timeout=60
\`\`\`
Resumen
- Mock para estructura/lógica — siempre, rápido, gratis, determinístico
- LLM real para calidad semántica — estratégico, con budget, en main o manual
- Tres entornos: Mock-only (desarrollo), Sandbox (CI en main), Full (pre-release)
- La trampa: no usar LLM real donde basta con mock — es costo sin valor adicional
- CI configurado por entorno: solo unit en cada push; integration solo en main
Recursos adicionales
- Test Pyramid — Martin Fowler — Proporciones correctas de tipos de tests
- GitHub Actions — Conditional execution — Ejecutar jobs condicionalmente
- pytest markers — Organización de tests
- OpenAI Pricing — Calcular costos
- The Testing Trophy — Otra perspectiva sobre proporciones
- Effective Software Testing — Libro de referencia sobre testing estratégico