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 escribirMockLLM RealRazón
El output tiene key "sentiment"Estructura — contract test
score está entre 0 y 1Constraint numérico
El parser maneja JSON en markdownLógica determinística
El handler de errores funcionaError handling, no LLM
El resumen es coherente con el originalCalidad semántica
El prompt funciona en inglés y españolComportamiento real del LLM
Model drift post-actualizaciónRequiere LLM real
El flujo completo produce output usable✅ + ❌E2E combina ambos
Tests en cada commit de CIVelocidad y costo
Validación pre-releaseAmbos, 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:

  1. Verificar que el output tiene la key "summary"
  2. Verificar que el resumen no habla de un tema diferente al input
  3. Verificar que el parser maneja JSON con markdown code blocks
  4. Verificar que el prompt en inglés produce outputs de calidad equivalente al español
  5. Verificar que el score es float entre 0 y 1
Ver solución
  1. Mock — estructura, contract test puro
  2. Sandbox/Full — relevancia semántica, requiere LLM real
  3. Mock — lógica determinística del parser
  4. Full — calidad comparativa, requiere LLM real en dos idiomas
  5. 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
  1. Todos los unit tests (ya deben pasar — son mocks, independientes del modelo)
  2. Integration tests de estructura (el output sigue teniendo las keys esperadas)
  3. Integration tests de calidad (semantic similarity >= threshold anterior)
  4. Comparar directamente: correr los mismos inputs con ambos modelos y comparar scores
  5. 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

  1. Test Pyramid — Martin Fowler — Proporciones correctas de tipos de tests
  2. GitHub Actions — Conditional execution — Ejecutar jobs condicionalmente
  3. pytest markers — Organización de tests
  4. OpenAI Pricing — Calcular costos
  5. The Testing Trophy — Otra perspectiva sobre proporciones
  6. Effective Software Testing — Libro de referencia sobre testing estratégico