Módulo 2: Unit Testing LLM Applications
1. Introducción: Unit Testing LLM Applications
Descripción
El Módulo 1 configuró pytest y estableció el mindset fundamental. Ahora aplicamos la técnica central: hacer lo no-determinístico determinístico mediante mocks, y tratar prompts como contratos que se pueden validar. Este módulo te da el superpoder de testear apps AI sin llamar al LLM real.
Contexto: ¿Dónde estamos?
Antes de entrar al detalle técnico, sitúate en el mapa de lo que aprendiste y lo que viene:
| Módulo | Qué aprendiste/aprenderás |
|---|---|
| Módulo 1 | Pytest, fixtures, markers, estructura de tests, mindset AI testing |
| Módulo 2 | Mocking LLM responses, prompt contracts, parsers, snapshot testing |
| Módulo 3 | Integration testing con LLM real, semantic assertions, non-determinism |
| Módulo 4 | CI/CD pipeline, E2E tests, testing en producción |
El módulo 2 es el puente entre "sé configurar pytest" y "tengo tests reales que protegen mi app". Aquí construyes la base de tu test suite: rápida, determinística, sin costo de API.
La transformación mental del módulo
Antes de este módulo
Developer → "Mi app usa LLMs, no se puede testear bien"
Developer → "Los tests serían lentos y caros"
Developer → "El output varía, ¿cómo hago assertions?"
Developer → "Solo puedo probar manualmente"
Después de este módulo
Developer → "Mockeo el LLM → tests en milliseconds, sin API calls"
Developer → "Mi prompt es un contrato → lo puedo validar automáticamente"
Developer → "Los parsers son determinísticos → cobertura del 100% fácil"
Developer → "Snapshot testing detecta regresiones sin esfuerzo"
Este cambio de mentalidad es el resultado más importante del módulo.
Idea central: Prompts como contratos
Un prompt no es solo texto — es un contrato de comportamiento:
"Dado este input, el output tendrá esta estructura, cumplirá estas constraints, y respetará este formato."
Si el contrato es claro, es testeable. Si cambias el prompt y el contrato se rompe, el test falla. Esta idea transforma prompts de "texto que modifico y rezo que funcione" a "especificación con validación automática".
Contrato implícito vs explícito
La mayoría de los developers tienen contratos implícitos en su cabeza. El objetivo es hacerlos explícitos y testeables:
Contrato implícito (en tu cabeza):
"El LLM resume textos y devuelve algo útil"
Contrato explícito (testeable):
"El output es un JSON con:
summary: string de 10 a 500 caracteresconfidence: float entre 0.0 y 1.0sources: lista de strings (puede estar vacía)- Formato válido siempre, incluso si el input es corto"
# El contrato como código:
def test_summary_prompt_contract(mock_client):
"""El prompt de resumen cumple el contrato de estructura y constraints."""
# Arrange
texto = "Python es un lenguaje de programación interpretado."
# Act
result = summarize(texto, client=mock_client)
# Assert: estructura
assert isinstance(result, dict), "El resultado debe ser un diccionario"
assert "summary" in result, "Debe existir la key 'summary'"
assert "confidence" in result, "Debe existir la key 'confidence'"
assert "sources" in result, "Debe existir la key 'sources'"
# Assert: tipos
assert isinstance(result["summary"], str)
assert isinstance(result["confidence"], (int, float))
assert isinstance(result["sources"], list)
# Assert: constraints
assert 10 <= len(result["summary"]) <= 500, \
f"summary debe tener 10-500 chars, tiene {len(result['summary'])}"
assert 0.0 <= result["confidence"] <= 1.0, \
f"confidence debe ser 0-1, es {result['confidence']}"
Mocking como superpoder
El LLM es no-determinístico: mismo input → outputs ligeramente distintos. Eso hace difícil el testing. La solución: mockear el LLM para que siempre devuelva lo que tú defines.
Por qué mocking gana
# Sin mock: lento, costoso, variable
def test_slow_and_costly():
client = openai.OpenAI() # API real
result = summarize("texto", client=client) # ~2 segundos, ~$0.001
assert "summary" in result # puede fallar por variación
# ❌ Lento (segundos), costoso, no-determinístico
# Con mock: rápido, gratuito, determinístico
def test_fast_and_free(mock_client):
result = summarize("texto", client=mock_client) # <1ms, $0
assert "summary" in result # siempre igual
# ✅ Rápido (<1ms), gratuito, determinístico
Anatomía de un mock LLM básico
from unittest.mock import MagicMock
import pytest
@pytest.fixture
def mock_client():
"""Mock de OpenAI client con estructura realista."""
client = MagicMock()
# Estructura real de la respuesta OpenAI
mock_choice = MagicMock()
mock_choice.message.content = '{"summary": "Resumen de prueba", "confidence": 0.9, "sources": []}'
mock_choice.finish_reason = "stop"
mock_response = MagicMock()
mock_response.choices = [mock_choice]
mock_response.usage.total_tokens = 150
mock_response.model = "gpt-4o-mini"
client.chat.completions.create.return_value = mock_response
return client
def test_with_mock(mock_client):
result = summarize("Texto de entrada", client=mock_client)
assert result["summary"] == "Resumen de prueba"
assert result["confidence"] == 0.9
# El mock garantiza que esto siempre pase — sin variación
Los tres beneficios medibles
Velocidad: Mock = 0.001s | LLM real = 2-5s → 2000-5000x más rápido
Costo: Mock = $0 | LLM real = $0.001+ → ahorra miles en CI
Determinismo: Mock = siempre igual | LLM real = variable → 0 flaky tests
El 70/30 de testing en AI apps
El Módulo 1 introdujo este concepto. En el Módulo 2 lo aplicas:
70% de tu código es determinístico:
├── Parsers (convierte raw string → dict)
├── Validators (verifica estructura del output)
├── Formatters (prepara input para el LLM)
├── Chains (orquesta llamadas múltiples)
└── Error handlers (maneja respuestas incorrectas)
30% es no-determinístico:
└── El LLM (caja negra, outputs variables)
Módulo 2 testea el 70% con mocks + tests determinísticos. Módulo 3 testea el 30% con integration tests y semantic assertions.
Qué lograrás en este módulo
Al final del Módulo 2 tendrás:
Skills técnicas
- ✅ Mockear respuestas LLM con
unittest.mockypytest-mock - ✅ Crear mocks con estructura realista (choices, message, usage)
- ✅ Definir prompt contracts específicos y testeables
- ✅ Escribir tests que validan contracts con assertions precisas
- ✅ Implementar snapshot testing para detectar regresiones
- ✅ Testear parsers y output processors de forma aislada
- ✅ Crear fixture factories para variaciones parametrizadas
El mini-proyecto
Una suite de Prompt Contract Tests para la app del Módulo 1 que:
- Mockea todas las llamadas a LLM (0 llamadas reales)
- Define contracts para cada prompt de la app
- Valida estructura, tipos y constraints automáticamente
- Detecta regresiones con snapshots
- Corre en < 10 segundos completa
Comparación: Módulo 1 vs Módulo 2
| Aspecto | Módulo 1 | Módulo 2 |
|---|---|---|
| Foco | Configurar pytest, mindset | Mocking, contracts, parsers |
| Tests | Smoke, contract básico | Contract completo, snapshot, parsers |
| Fixtures | Estáticas básicas | Factories, variaciones, edge cases |
| Tiempo de ejecución | < 30s | < 10s |
| Llamadas a API | Algunas (smoke) | 0 (unit tests puros) |
| Proyecto | Test Suite Setup | Prompt Contract Tests |
Arquitectura mental: qué mockeas y qué testeas
Input del usuario
↓
[Formatter] ← testear directamente (determinístico)
↓
[Prompt builder] ← testear directamente (determinístico)
↓
[LLM call] ← MOCKEAR AQUÍ
↓
[Raw response] ← mockeado
↓
[Parser] ← testear directamente (determinístico)
↓
[Validator] ← testear directamente (determinístico)
↓
[Output processor] ← testear directamente (determinístico)
↓
Output final
Regla práctica: Mockeas el LLM. Testeas todo lo demás directamente. La mayor parte de los bugs está en el "todo lo demás" — y es donde más fácil es escribir tests.
La diferencia entre Contract Test y Evaluation
Esta confusión aparece frecuentemente. Clarificación definitiva:
| Pregunta | Contract Test | Evaluation Metric |
|---|---|---|
| ¿Qué verifica? | Estructura y formato | Calidad semántica |
| Ejemplo de assertion | "summary" in result | "El resumen es coherente" |
| Herramienta | pytest, Pydantic | LLM-as-judge, ROUGE, BERTScore |
| Cuándo falla | JSON malformado, key faltante | Respuesta sin sentido |
| Velocidad | Milliseconds | Seconds (necesita LLM) |
| Determinismo | Sí (con mock) | No (LLM variable) |
| Cuándo usarlo | Cada commit | Pre-release, periódico |
Resumen: Contract test verifica "¿tiene la forma correcta?". Evaluation verifica "¿es bueno?". Ambos son necesarios. Este módulo cubre contract tests; evaluation es otro tema (Guía #12).
Roadmap del módulo
| # | Cápsula | Contenido | Tipo |
|---|---|---|---|
| 01 | Introducción | Esta cápsula — mindset y arquitectura | Conceptual |
| 02 | Mocking de respuestas | unittest.mock, pytest-mock, mocks realistas | Técnico |
| 03 | Prompt contract tests | Definir y validar contratos con pytest + Pydantic | Técnico |
| 04 | Snapshot testing | Detectar regresiones de prompts automáticamente | Técnico |
| 05 | Parsers y output processors | Testing aislado de lógica determinística | Técnico |
| 06 | Fixture factories | Variaciones parametrizadas de mocks | Técnico |
| 07 | Proyecto Prompt Contract Tests | Hands-on: construir la suite completa | Proyecto |
| 08 | Resumen y troubleshooting | Cierre, errores comunes, transición M3 | Cierre |
Setup: ¿qué necesitas tener del Módulo 1?
Antes de comenzar, verifica que tienes del Módulo 1:
# 1. Estructura del proyecto
your-project/
├── src/
│ └── app/
│ ├── __init__.py
│ ├── config.py
│ ├── parsers.py
│ ├── sentiment.py
│ └── main.py
├── tests/
│ ├── conftest.py
│ ├── smoke/
│ └── unit/
├── pytest.ini
└── requirements.txt
# 2. pytest funciona
pytest --collect-only # debe mostrar tests sin error
# 3. Markers configurados
pytest -m smoke # debe ejecutar smoke tests
pytest -m unit # debe ejecutar unit tests
Si tienes eso, estás listo para el Módulo 2. Si no, regresa al Módulo 1 y configura la base.
Una nota sobre la app de referencia
En el Módulo 1 construiste (o recibiste) una app de análisis de sentimiento. Esta es la base que usaremos en el Módulo 2 para construir los prompt contract tests.
La app hace:
- Recibe un texto
- Llama a un LLM con un prompt de análisis de sentimiento
- Parsea el output JSON del LLM
- Retorna
{sentiment: str, score: float, explanation: str}
En el Módulo 2 vas a:
- Mockear la llamada al LLM
- Definir el contrato del prompt de sentimiento
- Testear el parser de forma aislada
- Crear snapshot del output esperado
- Usar fixture factories para probar edge cases
Ejercicios
Ejercicio 1: Identificar el contrato de tu prompt
Toma un prompt que usas en tu trabajo o proyecto. Escribe el contrato en prosa (no en código todavía):
- ¿Qué keys debe tener el output?
- ¿Qué tipos son cada valor?
- ¿Cuáles son los constraints (rangos, longitudes, valores permitidos)?
- ¿Qué debe pasar si el input es inusual (vacío, muy largo, idioma distinto)?
Ver guía de evaluación
Un buen contrato tiene:
- Estructura: "El output es un JSON / una lista / un string estructurado"
- Keys obligatorias vs opcionales: "summary es obligatorio, sources es opcional"
- Tipos: "confidence es float, tags es lista de strings"
- Constraints: "summary tiene máximo 200 caracteres", "confidence está entre 0 y 1"
- Edge cases: "Si el input está vacío, summary es string vacío, confidence es 0.0"
Si tu contrato tiene todo eso, ya está en el 80% de calidad. El 20% restante son casos de error que descubrirás al testear.
Ejercicio 2: Identificar el 70% determinístico
Para la siguiente app, identifica qué partes son determinísticas (mockeable con mock) y cuáles son el LLM:
def classify_ticket(ticket_text: str) -> dict:
# 1. Normalizar el texto
clean_text = ticket_text.strip().lower()[:2000]
# 2. Construir el prompt
prompt = f"Clasifica este ticket: {clean_text}\nCategoría: urgente/normal/baja"
# 3. Llamar al LLM
raw_response = call_llm(prompt)
# 4. Parsear el output
category = parse_category(raw_response)
# 5. Validar y estructurar
return {
"category": category,
"original_length": len(ticket_text),
"truncated": len(ticket_text) > 2000
}
Ver solución
Determinístico (testear directamente):
- Paso 1: Normalización de texto →
assert clean_text == expected - Paso 2: Construcción del prompt → verificar que el prompt contiene el texto limpio
- Paso 4:
parse_category→ testear con strings de input conocidos - Paso 5: Construcción del dict → testear con output del parser mockeado
No-determinístico (mockear):
- Paso 3:
call_llm→ mock que retorna respuesta fija
Para el unit test:
def test_classify_ticket(mock_llm):
# mock_llm retorna "urgente" siempre
result = classify_ticket("Sistema caído en producción")
assert result["category"] in ["urgente", "normal", "baja"]
assert isinstance(result["original_length"], int)
assert isinstance(result["truncated"], bool)
Ejercicio 3: Contract vs Evaluation
Para cada assertion, indica si es un contract test o una evaluation metric:
assert "sentiment" in resultassert result["score"] >= 0 and result["score"] <= 1assert result["explanation"].startswith("El texto")- Verificar que el resumen captura los puntos clave del original
assert isinstance(result["tags"], list)- Verificar que la clasificación de sentimiento es correcta para textos negativos
Ver solución
- ✅ Contract — verifica que existe la key
- ✅ Contract — verifica rango numérico (constraint)
- ⚠️ Contract frágil — verifica prefijo específico; esto es contractual pero muy frágil. Mejor:
assert isinstance(result["explanation"], str) and len(result["explanation"]) > 0 - ❌ Evaluation — "captura los puntos clave" requiere juicio semántico
- ✅ Contract — verifica tipo de dato
- ❌ Evaluation — "correcta para textos negativos" requiere ground truth + juicio
Patrón: Si la assertion usa isinstance, in, len, >=, <= sobre estructura → contract. Si la assertion requiere comparación semántica o juicio de calidad → evaluation.
Ejercicio 4: Diseñar el mock
Para esta función, diseña el mock que usarías para unit testing:
def analyze_sentiment(text: str, client: openai.OpenAI) -> dict:
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": "Analiza el sentimiento."},
{"role": "user", "content": text}
],
response_format={"type": "json_object"}
)
raw = response.choices[0].message.content
return parse_sentiment(raw)
¿Qué estructura debe tener el mock? ¿Qué debería retornar choices[0].message.content?
Ver solución
from unittest.mock import MagicMock
import pytest
@pytest.fixture
def mock_openai_client():
client = MagicMock()
# Estructura que replica exactamente la API de OpenAI
mock_choice = MagicMock()
mock_choice.message.content = '{"sentiment": "positivo", "score": 0.85, "explanation": "El texto expresa satisfacción."}'
mock_choice.finish_reason = "stop"
mock_response = MagicMock()
mock_response.choices = [mock_choice]
mock_response.usage.prompt_tokens = 50
mock_response.usage.completion_tokens = 30
mock_response.usage.total_tokens = 80
mock_response.model = "gpt-4o-mini"
mock_response.id = "chatcmpl-test-id"
client.chat.completions.create.return_value = mock_response
return client
def test_analyze_sentiment(mock_openai_client):
result = analyze_sentiment("El producto es excelente", mock_openai_client)
assert result["sentiment"] == "positivo"
assert result["score"] == 0.85
assert isinstance(result["explanation"], str)
Clave: El content del mock debe ser un JSON válido que el parser pueda procesar — no un objeto Python, sino el string JSON real que el LLM retornaría.
Ejercicio 5: ¿Cuándo usar mocks vs LLM real?
Clasifica cada situación: ¿mock o LLM real?
| Situación | ¿Mock o real? |
|---|---|
| Testear que el parser maneja JSON bien formado | ? |
| Verificar que el prompt en español funciona correctamente | ? |
| Correr tests en cada commit (CI) | ? |
| Verificar que el output tiene la key "summary" | ? |
| Validar que el LLM no degrada su calidad después de una actualización de modelo | ? |
| Probar que la app maneja un error 429 (rate limit) correctamente | ? |
Ver solución
| Situación | Respuesta |
|---|---|
| Testear que el parser maneja JSON bien formado | Mock (determinístico, no necesita LLM) |
| Verificar que el prompt en español funciona correctamente | LLM real (evaluación de calidad semántica) |
| Correr tests en cada commit (CI) | Mock (velocidad, costo, determinismo) |
| Verificar que el output tiene la key "summary" | Mock (contract test puro) |
| Validar que el LLM no degrada su calidad | LLM real (regression testing de calidad) |
| Probar que la app maneja un error 429 | Mock (simular el error con side_effect) |
Regla: Mock para estructura/lógica/errores. Real para calidad semántica.
Resumen
- Prompts como contratos: estructura, constraints, formato — todo testeable
- Mocking elimina non-determinism en unit tests: rápido, gratuito, determinístico
- El 70% de tu código es determinístico — testea sin mock directamente
- Contract test ≠ Evaluation: uno verifica forma, el otro verifica calidad
- La mayoría de tu suite debe usar mocks; integration tests con LLM real son complemento estratégico
- Parsers y processors son la lógica más fácil y de mayor ROI para testear
Recursos adicionales
- unittest.mock — Documentación oficial Python — La base del mocking en Python
- pytest-mock — Plugin de pytest para mocking más ergonómico
- Pact — Contract Testing — Conceptos de contratos (más orientado a microservicios, pero útil para entender la filosofía)
- OpenAI API Reference — Estructura real de las responses para crear mocks realistas
- Property-based testing con Hypothesis — Técnica complementaria (se verá en M3)
- Módulo 1: Testing Fundamentals — Prerequisito
- Software Testing Anti-patterns — Errores comunes a evitar