Módulo 3: Integration Testing & Estrategias No-Determinísticas
2. Tests End-to-End
Descripción
Los tests end-to-end (E2E) ejecutan el flujo completo: input → formatter → LLM → parse → output. Sin mocks en el camino crítico. Esta cápsula cubre cómo estructurar E2E tests para apps AI, implementar budget controls obligatorios, qué assertions usar cuando el output varía, y cómo integrar E2E en tu pipeline de CI/CD sin que se vuelvan caros o frágiles.
Qué es un E2E test en contexto AI
Un E2E test llama a componentes reales — incluyendo el LLM:
Unit test (M2):
input → [MOCK LLM] → parse → output
E2E test (M3):
input → formatter → [LLM REAL] → parse → processor → output
↑
API call real → costo real → tiempo real
Lo que verifica un E2E que los unit tests no pueden:
- El prompt funciona con el LLM real (no solo con el mock)
- La cadena completa produce output usable para el usuario final
- Los timeouts, rate limits y errores reales se manejan bien
- El modelo produce outputs que tu parser maneja correctamente
Estructura de un E2E test bien formado
import pytest
import os
@pytest.mark.integration
@pytest.mark.e2e
@pytest.mark.skipif(
not os.getenv("OPENAI_API_KEY"),
reason="OPENAI_API_KEY no configurada — E2E tests requieren API real"
)
def test_analyze_sentiment_e2e():
"""
Flujo completo: texto → LLM real → parse → output validado.
Este test verifica que el sistema completo produce un resultado
usable, no solo que la estructura es correcta (eso ya lo cubren
los contract tests del M2).
"""
# Arrange: input real y controlado
text = "Acabé de usar este producto por primera vez y es absolutamente increíble."
# Act: flujo completo, sin mocks
result = analyze_sentiment(text) # Llama al LLM real
# Assert: flexible — no igualdad exacta, sino propiedades
# Estructura (redundante con contract tests, pero útil para E2E)
assert isinstance(result, dict), "El resultado debe ser un dict"
assert "sentiment" in result
assert "score" in result
# Propiedades de la salida real del LLM
assert result["sentiment"] in ["positivo", "negativo", "neutral"]
assert 0.0 <= result["score"] <= 1.0
# Para texto claramente positivo, el score no debe ser muy bajo
# (esto es una assertion de calidad, no solo de estructura)
assert result["score"] >= 0.5, \
f"Para texto positivo, el score debería ser >=0.5, es {result['score']}"
Budget controls: implementación completa
Los budget controls son obligatorios en E2E tests. Sin ellos, un run accidental puede costar decenas de dólares.
Implementación básica: session budget
# tests/conftest.py
import pytest
import os
import threading
class BudgetTracker:
"""Rastrea el gasto en API calls durante la sesión de tests."""
# Precios por 1M tokens (gpt-4o-mini, Enero 2025)
PRICES = {
"gpt-4o-mini": {"input": 0.15, "output": 0.60},
"gpt-4o": {"input": 2.50, "output": 10.00},
"gpt-4": {"input": 30.00, "output": 60.00},
}
def __init__(self, max_usd: float = 0.50):
self.max_usd = max_usd
self.spent = 0.0
self._lock = threading.Lock()
self.calls = []
def add_cost(self, model: str, prompt_tokens: int, completion_tokens: int):
prices = self.PRICES.get(model, self.PRICES["gpt-4o-mini"])
cost = (
prompt_tokens / 1_000_000 * prices["input"] +
completion_tokens / 1_000_000 * prices["output"]
)
with self._lock:
self.spent += cost
self.calls.append({
"model": model,
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"cost": cost
})
return cost
def check_budget(self):
if self.spent >= self.max_usd:
raise pytest.skip.Exception(
f"Budget E2E excedido: ${self.spent:.4f} >= ${self.max_usd:.2f}"
)
def report(self):
return {
"total_spent": self.spent,
"max_budget": self.max_usd,
"remaining": self.max_usd - self.spent,
"total_calls": len(self.calls)
}
@pytest.fixture(scope="session")
def e2e_budget():
"""Budget compartido por toda la sesión de integration tests."""
max_budget = float(os.getenv("E2E_BUDGET_USD", "0.50"))
tracker = BudgetTracker(max_usd=max_budget)
yield tracker
# Al final de la sesión, reportar el gasto
report = tracker.report()
print(f"\n💰 E2E Budget Report: ${report['total_spent']:.4f} / ${report['max_budget']:.2f}")
print(f" {report['total_calls']} API calls realizadas")
@pytest.fixture
def openai_client_e2e(e2e_budget):
"""
Cliente OpenAI para E2E tests con budget tracking.
Skip automático si el budget está excedido.
"""
import openai
e2e_budget.check_budget() # Skip si budget excedido
api_key = os.getenv("OPENAI_API_KEY")
if not api_key:
pytest.skip("OPENAI_API_KEY no configurada")
client = openai.OpenAI(api_key=api_key)
return client, e2e_budget
Uso del budget tracker en tests
@pytest.mark.integration
def test_sentiment_with_budget(openai_client_e2e):
client, budget = openai_client_e2e
# Hacer la llamada al LLM
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "Analiza: 'Me encanta este producto'"}],
temperature=0.0,
max_tokens=200
)
# Registrar el costo
budget.add_cost(
model="gpt-4o-mini",
prompt_tokens=response.usage.prompt_tokens,
completion_tokens=response.usage.completion_tokens
)
raw = response.choices[0].message.content
result = parse_json_response(raw)
processed = process_sentiment_output(result)
assert processed["sentiment"] in ["positivo", "negativo", "neutral"]
assert 0 <= processed["score"] <= 1
Assertions robustas para E2E
El arte de los E2E tests está en las assertions. Demasiado estrictas → tests frágiles. Demasiado débiles → no detectan problemas.
Niveles de assertions
# ─── Nivel 1: Estructura (mínimo obligatorio) ───
def assertions_nivel_1(result):
"""Estas assertions son idénticas a los contract tests del M2."""
assert isinstance(result, dict)
assert "sentiment" in result
assert result["sentiment"] in ["positivo", "negativo", "neutral"]
assert 0 <= result["score"] <= 1
# ─── Nivel 2: Propiedades de calidad ───
def assertions_nivel_2(result, input_text):
"""Assertions que verifican que el output tiene sentido."""
# Si el texto tiene palabras claramente positivas, el score debe ser alto
clearly_positive_words = ["increíble", "excelente", "fantástico", "perfecto", "encanta"]
if any(w in input_text.lower() for w in clearly_positive_words):
assert result["score"] >= 0.6, \
f"Para texto positivo, score debe ser >=0.6, es {result['score']}"
# El explanation no debe ser vacío para textos con sentimiento claro
if result["score"] < 0.3 or result["score"] > 0.7:
assert len(result.get("explanation", "")) > 0
# ─── Nivel 3: Keywords del input ───
def assertions_nivel_3(result, input_text):
"""Verificar que el output está relacionado con el input."""
# Al menos una palabra clave del input debe aparecer en los keywords o explanation
input_words = set(input_text.lower().split()) - {"el", "la", "es", "un", "una", "de"}
output_text = (result.get("explanation", "") + " ".join(result.get("keywords", []))).lower()
relevant_found = any(word in output_text for word in input_words if len(word) > 3)
assert relevant_found, \
"El output no parece relacionado con el input — posible alucinación"
Assertions por tipo de prompt
# Para prompts de RESUMEN:
def assert_summary_quality(result: dict, original_text: str):
# Longitud razonable (no más largo que el original)
assert len(result["summary"]) <= len(original_text)
# Mínimo informativo
assert len(result["summary"].split()) >= 5
# Confidence razonable
assert result["confidence"] >= 0.5, "Confidence muy baja para texto estándar"
# Para prompts de CLASIFICACIÓN:
def assert_classification_quality(result: dict):
# Confidence alta si la categoría es clara
if result["confidence"] > 0.9:
assert result["category"] != "unknown"
# Tags deben ser relevantes (al menos 1 si hay categoría)
if result["category"] != "unknown":
assert len(result.get("tags", [])) >= 1
# Para prompts de EXTRACCIÓN:
def assert_extraction_quality(result: dict, input_text: str):
# Las entidades extraídas deben aparecer en el input
for entity in result.get("entities", []):
assert entity["text"].lower() in input_text.lower(), \
f"Entidad '{entity['text']}' no encontrada en el input — posible alucinación"
Skip condicional: la base de los E2E en CI
Los E2E tests deben ser opcionales, no bloquear el pipeline:
# Opción 1: skipif basado en variable de entorno
import pytest
import os
requires_api_key = pytest.mark.skipif(
not os.getenv("OPENAI_API_KEY"),
reason="Requiere OPENAI_API_KEY para E2E"
)
requires_integration = pytest.mark.skipif(
os.getenv("SKIP_INTEGRATION", "true").lower() == "true",
reason="Integration tests deshabilitados (SKIP_INTEGRATION=true)"
)
# Uso:
@requires_api_key
@requires_integration
@pytest.mark.integration
def test_sentiment_e2e():
...
# Opción 2: fixture que hace skip
@pytest.fixture
def integration_client():
"""Solo crea el cliente si tenemos API key y estamos en modo integration."""
api_key = os.getenv("OPENAI_API_KEY")
run_integration = os.getenv("RUN_INTEGRATION", "false").lower() == "true"
if not api_key or not run_integration:
pytest.skip("E2E tests requieren OPENAI_API_KEY y RUN_INTEGRATION=true")
import openai
return openai.OpenAI(api_key=api_key)
def test_with_conditional_fixture(integration_client):
# Si no hay key, este test se salta automáticamente
response = integration_client.chat.completions.create(...)
Modelo económico para E2E
Siempre usa el modelo más económico para tests:
# pytest.ini o conftest.py
E2E_MODEL = os.getenv("E2E_MODEL", "gpt-4o-mini") # Default: el más barato
# Nunca en E2E tests (a menos que haya una razón específica):
# ❌ "gpt-4" → ~200x más caro que gpt-4o-mini
# ❌ "gpt-4o" → ~17x más caro
# ✅ "gpt-4o-mini" → Balanceo calidad/costo óptimo para testing
def test_e2e_with_economic_model(integration_client):
response = integration_client.chat.completions.create(
model="gpt-4o-mini", # Siempre explícito
messages=[...],
temperature=0.0, # Sin aleatoriedad adicional
max_tokens=200, # Limitar tokens de output
)
Comparación: E2E vs Unit vs Integration estándar
| Aspecto | Unit (M2) | Integration E2E (M3) |
|---|---|---|
| LLM | Mock | Real |
| Velocidad | <1ms | 2-10 segundos |
| Costo | $0 | ~$0.0001-0.001/test |
| Determinismo | 100% | No (LLM varía) |
| Qué valida | Estructura, lógica, contratos | Calidad semántica, flujo real |
| Cuándo correr | Siempre | Pre-release, en main, nightly |
| Si falla | Bug en código | Bug en código O varianza del LLM |
| Assertions | Exactas | Flexibles (propiedades, semánticas) |
Timeout: protegerse de tests colgados
# pip install pytest-timeout
@pytest.mark.integration
@pytest.mark.timeout(30) # Máximo 30 segundos
def test_sentiment_e2e_with_timeout(integration_client):
result = analyze_sentiment("texto de prueba", client=integration_client)
assert "sentiment" in result
# O configurar timeout global para todos los integration tests:
# pytest.ini
# [pytest]
# timeout = 30
E2E en CI/CD: configuración recomendada
# .github/workflows/ci.yml
jobs:
unit-tests:
name: Unit Tests (always)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Run unit tests
run: pytest -m "not integration" -v --tb=short
# Siempre corre, sin API key, determinístico
integration-tests:
name: Integration Tests (main only)
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' # Solo en main
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
RUN_INTEGRATION: "true"
E2E_BUDGET_USD: "0.25" # Budget máximo por run
steps:
- uses: actions/checkout@v3
- name: Run integration tests
run: pytest -m integration -v --tb=short --timeout=60
# Solo en main, con API key, con budget controlado
Ejercicios
Ejercicio 1: Escribir el E2E completo
Escribe un E2E test completo para el endpoint /analyze de la FastAPI app. Incluye: skip condicional, budget tracking, assertions de propiedades.
Ver solución
import pytest
import os
from fastapi.testclient import TestClient
from app.main import app
@pytest.mark.integration
@pytest.mark.e2e
@pytest.mark.skipif(
not os.getenv("OPENAI_API_KEY") or not os.getenv("RUN_INTEGRATION"),
reason="Requiere OPENAI_API_KEY y RUN_INTEGRATION=true"
)
@pytest.mark.timeout(30)
def test_analyze_endpoint_e2e():
"""E2E test del endpoint /analyze usando el LLM real."""
client = TestClient(app)
response = client.post("/analyze", json={
"text": "Me encanta este producto, es absolutamente fantástico"
})
assert response.status_code == 200
data = response.json()
# Assertions de propiedades (no igualdad exacta)
assert data["sentiment"] in ["positivo", "negativo", "neutral"]
assert 0 <= data["score"] <= 1
assert isinstance(data["keywords"], list)
# Para texto claramente positivo:
assert data["score"] >= 0.6, "Texto positivo debe tener score >= 0.6"
assert data["sentiment"] == "positivo", "Texto positivo debe clasificarse como positivo"
Ejercicio 2: Implementar budget fixture
Implementa una versión simplificada del budget tracker que haga skip si se gasta más de $0.10:
Ver solución
@pytest.fixture(scope="session")
def simple_budget():
budget = {"spent": 0.0, "max": 0.10}
yield budget
print(f"\nGasto E2E: ${budget['spent']:.4f}")
def register_cost(budget: dict, tokens: int, model: str = "gpt-4o-mini"):
cost_per_token = 0.00000015 # gpt-4o-mini aprox
budget["spent"] += tokens * cost_per_token
if budget["spent"] >= budget["max"]:
pytest.skip(f"Budget excedido: ${budget['spent']:.4f}")
@pytest.mark.integration
def test_with_simple_budget(simple_budget, integration_client):
response = integration_client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "Hola"}],
max_tokens=50
)
register_cost(simple_budget, response.usage.total_tokens)
assert response.choices[0].message.content
Ejercicio 3: Assertions para prompt de clasificación
Para un prompt de clasificación de documentos (retorna category, confidence, tags), diseña 5 assertions robustas para E2E:
Ver solución
def assert_classification_e2e(result: dict, input_text: str):
# 1. Estructura (siempre)
assert "category" in result and "confidence" in result and "tags" in result
# 2. Valores permitidos
VALID_CATEGORIES = ["tecnología", "ciencia", "deportes", "política", "entretenimiento", "general"]
assert result["category"] in VALID_CATEGORIES, f"Categoría inválida: {result['category']}"
# 3. Rango de confidence
assert 0.0 <= result["confidence"] <= 1.0
# 4. Tags no vacíos cuando hay categoría clara
if result["confidence"] >= 0.8:
assert len(result["tags"]) >= 1, "Alta confidence debe tener al menos un tag"
# 5. Tags son strings relevantes (no numéricos ni vacíos)
for tag in result["tags"]:
assert isinstance(tag, str) and len(tag.strip()) > 0
Ejercicio 4: E2E para flujo con error
Escribe un E2E test que verifica que la app maneja correctamente un texto vacío:
Ver solución
@pytest.mark.integration
@pytest.mark.e2e
@pytest.mark.skipif(not os.getenv("OPENAI_API_KEY"), reason="Requiere API key")
def test_empty_text_handled_e2e():
"""E2E: texto vacío debe retornar error controlado, no crash."""
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
response = client.post("/analyze", json={"text": ""})
# FastAPI debe validar el input antes de llamar al LLM
assert response.status_code == 422 # Unprocessable Entity (Pydantic validation)
# El LLM NO debe ser llamado — el error es pre-LLM
Ejercicio 5: Timeout y retry
Combina timeout de 30s con retry de 2 intentos para un E2E test que puede ser lento:
Ver solución
@pytest.mark.integration
@pytest.mark.timeout(30)
@pytest.mark.flaky(reruns=2, reruns_delay=3) # Requiere pytest-rerunfailures
def test_slow_endpoint_with_retry():
"""Test que puede ser lento — con retry y timeout."""
result = analyze_sentiment("texto de prueba")
# Assertions flexibles para evitar false negatives
assert result["sentiment"] in ["positivo", "negativo", "neutral"]
assert 0 <= result["score"] <= 1
Resumen
- E2E tests = flujo completo con LLM real — verifican lo que los mocks no pueden
- Budget controls son obligatorios — implementa el tracker antes de escribir tests
- Assertions robustas: propiedades, rangos, keywords — no igualdad exacta
- Skip condicional: por API key, por variable de entorno — E2E no deben bloquear CI
- Modelo económico: siempre
gpt-4o-minipara tests, nuncagpt-4sin razón - Timeout: siempre
@pytest.mark.timeout(30)— un LLM colgado no debe colgar el pipeline
Recursos adicionales
- pytest-timeout — Timeouts por test
- pytest-rerunfailures — Retry automático
- OpenAI Usage dashboard — Monitorear costos reales
- FastAPI TestClient — Para E2E de endpoints
- GitHub Actions Secrets — Para API keys en CI
- pytest skipif — Skip condicional
- OpenAI API Rate Limits — Entender los límites de la API