Módulo 2: Unit Testing LLM Applications
8. Resumen y Troubleshooting del Módulo 2
Descripción
Cierre del Módulo 2: errores comunes con sus soluciones, checklist de cierre, tabla resumen de conceptos clave, y preparación para el Módulo 3 (Integration Testing). Si algo salió mal durante el proyecto, esta cápsula tiene la respuesta. Si todo salió bien, úsala para consolidar lo aprendido antes de continuar.
Los 8 errores más comunes del Módulo 2
Error 1: Mock trivial que no refleja la API real
Síntoma:
# Tu mock:
client.chat.completions.create.return_value = "Hello"
# Error en producción:
AttributeError: 'str' object has no attribute 'choices'
# O peor: el test pasa pero en producción falla con estructura inesperada
Causa: El mock retorna un string simple en lugar de un objeto que replica la estructura de la API OpenAI.
Solución:
# Siempre usar create_openai_chat_response:
from tests.helpers import create_openai_chat_response
client.chat.completions.create.return_value = create_openai_chat_response(
'{"sentiment": "positivo", "score": 0.9}'
)
# Verificar que tu mock tiene la misma estructura que el objeto real:
# response.choices[0].message.content → string
# response.choices[0].finish_reason → "stop" / "length"
# response.usage.total_tokens → int
Regla: Si tu código accede a response.X.Y.Z, tu mock debe tener response.X.Y.Z configurado. MagicMock() crea atributos al acceder, pero si el código hace operaciones sobre ellos (como len(), in, indexing), necesitas valores reales.
Error 2: Patch en el lugar equivocado
Síntoma:
@patch("openai.OpenAI")
def test_something(mock_openai):
result = analyze_sentiment("texto")
# El mock NO se aplica — la función usa el cliente real
# Los tests pasan pero hace llamadas a la API real
Causa: Se parchea donde se DEFINE el objeto, no donde se USA.
Solución:
# Identifica dónde se importa en tu módulo:
# Si app/sentiment.py tiene: import openai; client = openai.OpenAI()
@patch("app.sentiment.openai.OpenAI") # ← Patch donde se usa
# Si app/sentiment.py tiene: from openai import OpenAI; client = OpenAI()
@patch("app.sentiment.OpenAI") # ← Patch donde se importa
# Si app/sentiment.py tiene: client = openai.OpenAI() al nivel del módulo
@patch("app.sentiment.client") # ← Parchear el objeto directamente
# La forma más limpia: dependency injection
def analyze_sentiment(text: str, client=None):
if client is None:
client = openai.OpenAI()
# Ahora no necesitas patch — pasas el mock directamente en tests
Regla de oro: Lee la primera línea de app/sentiment.py. ¿Cómo importa el cliente? Eso determina el path del patch.
Error 3: Contratos vagos que no protegen nada
Síntoma:
def test_contract():
result = analyze_sentiment("texto", client=mock_client)
assert result is not None # ← Este "contrato" no protege nada
assert "sentiment" in result # ← Mínimo útil, pero insuficiente
Causa: El contrato verifica que el resultado existe pero no que cumple las especificaciones del prompt.
Solución:
def test_contract_completo():
result = analyze_sentiment("texto", client=mock_client)
# Estructura
assert "sentiment" in result
assert "score" in result
assert "keywords" in result
assert "explanation" in result
# Tipos
assert isinstance(result["sentiment"], str)
assert isinstance(result["score"], (int, float))
assert isinstance(result["keywords"], list)
# Constraints (aquí está el valor real del contrato)
assert result["sentiment"] in ["positivo", "negativo", "neutral"]
assert 0.0 <= result["score"] <= 1.0
assert len(result["explanation"]) <= 500
assert all(isinstance(k, str) for k in result["keywords"])
Regla: Un contrato sin constraints es solo un "smoke test de estructura". El valor real está en los constraints: rangos, valores permitidos, longitudes.
Error 4: Snapshot actualizado sin revisar el diff
Síntoma:
# El test falló, así que actualizas:
pytest --snapshot-update # Para todos los snapshots
# Una semana después, el bug está en producción
# El snapshot escondió la regresión
Causa: Actualizar snapshots en batch sin revisar cada diff.
Solución:
# Paso 1: Ver qué falló
pytest tests/test_snapshots.py -v
# Paso 2: Ver el diff exacto del snapshot que falló
# (el output del test muestra el diff)
# Paso 3: Analizar el diff
# ¿El cambio es intencional? → Actualizar ese snapshot específico
# ¿Es un bug? → Arreglar el código
# Paso 4: Si es intencional, actualizar solo ese snapshot
pytest tests/test_snapshots.py::test_specific -v --snapshot-update
git diff tests/__snapshots__/ # Revisar qué cambió exactamente
Regla: Nunca pytest --snapshot-update en batch. Siempre uno a uno, siempre con revisión del diff.
Error 5: Test del parser que depende del LLM
Síntoma:
def test_parser():
# Llama al LLM real para testear el parser
client = openai.OpenAI()
response = client.chat.completions.create(...)
raw = response.choices[0].message.content
result = parse_json_response(raw)
assert "sentiment" in result
# Este test: es lento, costoso, y puede fallar por el LLM
Causa: Confusión entre testear el parser y testear el LLM. Son responsabilidades diferentes.
Solución:
# Testear el parser directamente con inputs controlados
@pytest.mark.parametrize("raw,expected", [
('{"sentiment": "positivo"}', {"sentiment": "positivo"}),
('```json\n{"sentiment": "negativo"}\n```', {"sentiment": "negativo"}),
('El análisis: {"sentiment": "neutral"}', {"sentiment": "neutral"}),
])
def test_parser_isolated(raw, expected):
# Sin LLM, sin mocks: el parser recibe strings directamente
result = parse_json_response(raw)
assert result == expected
Regla: Los parsers son 100% determinísticos. Testéalos con strings directos, no con outputs del LLM.
Error 6: Factory fixture que mezcla responsabilidades
Síntoma:
@pytest.fixture
def super_factory():
def _create(
sentiment=None,
summary=None,
classification=None,
error_type=None,
async_mode=False,
stream=False,
# ... 15 parámetros más
):
# 100 líneas de código...
return _create
Causa: Una factory que intenta manejar todos los casos posibles se vuelve imposible de entender y mantener.
Solución:
# Factories pequeñas y específicas:
@pytest.fixture
def make_sentiment_client():
"""Solo para respuestas de sentimiento."""
def _create(sentiment="neutral", score=0.5, **kwargs):
...
return _create
@pytest.fixture
def make_error_client():
"""Solo para simular errores del LLM."""
def _create(error_type="rate_limit"):
...
return _create
@pytest.fixture
def make_summary_client():
"""Solo para respuestas de resumen."""
def _create(summary="Resumen de prueba", confidence=0.8):
...
return _create
Regla: Una factory, una responsabilidad. Si necesitas combinar, usa múltiples factories en el test.
Error 7: No verificar que el LLM fue llamado correctamente
Síntoma:
def test_sentiment():
result = analyze_sentiment("texto", client=mock_client)
assert result["sentiment"] == "positivo"
# Pasó — pero ¿el LLM fue llamado con el prompt correcto?
# ¿Se pasó el texto en el mensaje? ¿Se usó temperature=0?
Causa: Los tests verifican el output pero no la interacción con el LLM.
Solución:
def test_sentiment_verifica_llamada(mock_openai_client):
result = analyze_sentiment("Texto importante", client=mock_openai_client)
# Verificar que se llamó
mock_openai_client.chat.completions.create.assert_called_once()
# Verificar los argumentos
call_kwargs = mock_openai_client.chat.completions.create.call_args.kwargs
assert call_kwargs["model"] == "gpt-4o-mini"
assert call_kwargs["temperature"] == 0.0
messages = call_kwargs["messages"]
user_message = next(m for m in messages if m["role"] == "user")
assert "Texto importante" in user_message["content"]
Regla: Para la lógica crítica, verifica tanto el output como las interacciones con el mock (assert_called_once, call_args).
Error 8: AsyncMock olvidado para funciones async
Síntoma:
async def analyze_async(text, client):
response = await client.chat.completions.create(...) # ← Es await
# En el test:
def test_analyze_async():
client = MagicMock()
client.chat.completions.create.return_value = ... # ← MagicMock normal
# Error: TypeError: object MagicMock can't be used in 'await' expression
Causa: MagicMock no es awaitable. Para funciones async, necesitas AsyncMock.
Solución:
from unittest.mock import AsyncMock, MagicMock
@pytest.mark.asyncio
async def test_analyze_async_correct():
client = MagicMock()
# AsyncMock para la función que se va a await
client.chat.completions.create = AsyncMock(
return_value=create_openai_chat_response('{"sentiment": "positivo", "score": 0.9}')
)
result = await analyze_async("texto", client=client)
assert result["sentiment"] == "positivo"
client.chat.completions.create.assert_called_once()
Regla: Si tu función hace await algo(), el mock de algo debe ser AsyncMock, no MagicMock.
Diagnóstico rápido: árbol de decisiones
Test falla con AttributeError en el mock
→ Verifica que create_openai_chat_response está configurado correctamente
→ Asegúrate de que choices[0].message.content está definido
Test no usa el mock (llama al LLM real)
→ Patch en el lugar incorrecto
→ Verifica la ruta: patch("módulo.donde.se.usa.nombre")
Test pasa pero cobertura es baja
→ Falta parametrize para múltiples formatos
→ Falta testear edge cases (vacío, malformado, error)
Contract test pasa con mock pero falla con LLM real
→ El mock no refleja la variabilidad real del LLM
→ Captura un output real y úsalo como test case
→ Ajusta el contrato o el parser para ser más flexible
Snapshot se actualiza solo en CI
→ NUNCA actualices snapshots en CI automáticamente
→ El snapshot falló por una razón — investiga antes de actualizar
Factory muy compleja e imposible de entender
→ Divide en factories más pequeñas y específicas
→ Una factory, una responsabilidad
AsyncMock error
→ Usa AsyncMock para funciones async, MagicMock para sync
Checklist de cierre del Módulo 2
Antes de continuar al Módulo 3, verifica que tienes todo:
Tests escritos
- Todos los prompts de la app tienen al menos un contract test
- El contrato incluye: estructura + tipos + constraints
- Los parsers están testeados con 5+ formatos (incluyendo markdown, texto previo)
- Los output processors tienen tests para: valores normales, out of range, missing fields
- Error handling está cubierto: rate limit, timeout, empty response, malformed JSON
- Tests de regresión para los 3 casos principales (positivo, negativo, neutral)
Calidad de los tests
- Los mocks usan
create_openai_chat_response(estructura realista) - El patch está en el lugar correcto (donde se usa, no donde se define)
- Los contratos tienen constraints específicos (no solo "key exists")
- Los snapshots tienen documentación de cuándo actualizar
Performance
-
pytest -m unittermina en menos de 10 segundos - 0 llamadas a la API real de OpenAI en tests unitarios
-
pytest --cov=app.parsersmuestra >90% de cobertura
Organización
- Factories en
conftest.py(no duplicadas en cada test) - Tests de contrato en
tests/unit/contracts/ - Tests de parsers en
tests/unit/parsers/ - Tests de regresión en
tests/unit/regression/
Resumen de conceptos del módulo
| Cápsula | Concepto central | Herramienta principal | Cuándo usarlo |
|---|---|---|---|
| 01 | Prompts como contratos; mocking como superpoder | MagicMock, fixtures | Siempre en unit tests |
| 02 | Mock realista con estructura de API real | create_openai_chat_response | Cada test que mockea LLM |
| 03 | Contrato específico: estructura + tipos + constraints | Pydantic, pytest assertions | Cada prompt de la app |
| 04 | Snapshot para detectar regresiones; revisar diff | pytest-snapshot, syrupy | Outputs complejos |
| 05 | Parsers: 100% determinísticos, alto ROI | parametrize, pytest.raises | Siempre testear parsers |
| 06 | Factory fixture para variaciones dinámicas | Fixtures que retornan funciones | 3+ variaciones del mismo mock |
| 07 | Suite completa: contract + parser + regression | Todo lo anterior | El proyecto |
| 08 | Troubleshooting y cierre | Esta cápsula | Cuando algo falla |
Métricas de éxito del módulo
Al completar el Módulo 2 correctamente, deberías ver:
# Resultado esperado al final del Módulo 2:
$ pytest tests/unit/ -v --tb=short
=== 50+ passed in 3.2s ===
$ pytest --cov=app --cov-report=term-missing tests/unit/
Name Stmts Miss Cover
-------------------------------------------
app/parsers.py 45 2 96%
app/processors.py 38 1 97%
app/sentiment.py 52 12 77%
-------------------------------------------
TOTAL 135 15 89%
$ pytest -m unit --tb=short # Solo unit tests
=== 50+ passed in 3.1s ===
Si tus números son similares, completaste el módulo correctamente.
Próximo módulo: Integration Testing
En el Módulo 3 enfrentarás la realidad que los mocks evitan:
El desafío del Módulo 3
Unit tests (M2): Mock LLM → output determinístico → fácil de testear
Integration tests (M3): LLM real → output variable → requiere nuevas estrategias
Qué aprenderás en el Módulo 3
-
Semantic similarity assertions: En lugar de
assert result == "exacto", usar similaridad semántica:assert similarity(result, expected) > 0.8. Cuando el significado importa más que las palabras exactas. -
Property-based testing con Hypothesis: Definir propiedades que siempre deben cumplirse (invariantes) y dejar que Hypothesis genere los inputs. Ej: "Para cualquier texto de entrada, el score siempre es 0-1".
-
Flaky test management: Estrategias para tests con LLM real que a veces fallan: retry logic, tolerancia en assertions, categorizar como "flaky" vs "real failure".
-
Budget controls: Cómo testear con el LLM real sin gastar fortunas. Límites de tokens, caching de respuestas en tests, cuándo es necesario el LLM real vs cuándo el mock es suficiente.
-
Decision framework: ¿Cuándo usar mock? ¿Cuándo usar un modelo local (Ollama)? ¿Cuándo usar el LLM real? El framework de decisión basado en el tipo de test y el momento del desarrollo.
La transición clave
Módulo 2: "Mi app es testeada — todos los tests pasan con mocks"
↓
Módulo 3: "Pero ¿funciona con el LLM real? ¿Qué pasa cuando el modelo
produce variaciones? ¿Cómo testeo la calidad semántica?"
Ejercicios finales del módulo
Ejercicio 1: Diagnóstico de errores
Analiza este test que siempre pasa pero tiene un bug en el mock. Identifica el problema:
def test_sentiment_result(mocker):
mock_create = mocker.patch("app.sentiment.client.chat.completions.create")
mock_response = MagicMock()
mock_response.choices[0].message.content = {"sentiment": "positivo", "score": 0.9}
mock_create.return_value = mock_response
result = analyze_sentiment("Texto positivo")
assert result["sentiment"] == "positivo" # ← Pasa
Ver solución
Bug: mock_response.choices[0].message.content = {"sentiment": "positivo", "score": 0.9} — el content es un dict, pero el LLM real retorna un string JSON. Tu parser hace json.loads(raw), lo que fallará si raw ya es un dict (o en Python no fallará, pero con un dict real sí podría comportarse diferente).
El test pasa porque: MagicMock() interpola {"sentiment": "positivo", "score": 0.9} como un dict accesible, y si el parser hace json.loads(raw) sobre un dict... puede fallar de formas inesperadas.
Fix:
mock_response.choices[0].message.content = '{"sentiment": "positivo", "score": 0.9}'
# ← String JSON, no dict
Esta es exactamente la razón por la que usamos create_openai_chat_response — evita estos bugs sutiles.
Ejercicio 2: Contrato que fue roto
Un developer cambió el prompt de sentimiento para añadir un campo "urgency". El contrato del prompt ahora necesita actualizarse. Describe los pasos:
Ver guía
Pasos para actualizar el contrato:
-
Actualizar el modelo Pydantic:
class SentimentOutput(BaseModel): sentiment: SentimentEnum score: float = Field(ge=0.0, le=1.0) explanation: str keywords: list[str] urgency: str | None = Field(default=None) # ← Nuevo campo -
Actualizar los contract tests:
def test_contract_structure(): # Añadir assertion para urgency (opcional) assert "urgency" in result or result.get("urgency") is None -
Actualizar los mocks de los tests existentes:
# Si urgency es obligatorio: make_sentiment_client(sentiment="positivo", score=0.9, urgency="high") # Si es opcional: los mocks existentes siguen funcionando sin urgency -
Actualizar los snapshots si los tienes:
pytest tests/ --snapshot-update # Solo para los tests afectados -
Correr todos los tests para verificar que no hay regresiones.
Ejercicio 3: ROI del Módulo 2
Calcula el ahorro aproximado si tienes 50 unit tests que antes llamaban al LLM real y ahora usan mocks. Asume:
- Cada test hacía 1 llamada al LLM
- Cada llamada cuesta $0.001 (gpt-4o-mini)
- Los tests se corren 10 veces al día (CI + local)
- Trabajas 20 días al mes
Ver cálculo
Sin mocks: 50 tests × 1 llamada × $0.001 × 10 runs × 20 días = $10/mes
Con mocks: $0.00/mes
Ahorro: $10/mes × 12 meses = $120/año solo en este proyecto
Pero el ahorro real incluye:
- Tiempo de espera: 50 tests × 2s × 10 runs × 20 días = 200,000s = 55 horas/mes
- Si corres los tests 100 veces al día: 55,000 horas/mes ahorradas
- Sin mencionar tests que no corrías por el costo → bugs no detectados → más costoso
Conclusión: El costo del mocking es 0 — la inversión es escribir los tests bien.
Ejercicio 4: Prepararse para el Módulo 3
Antes de comenzar el Módulo 3, identifica en tu app:
- ¿Qué partes realmente necesitan el LLM real para ser testadas?
- ¿Qué tests de los que escribiste en M2 te dieron más confianza?
- ¿Cuál es el caso donde un mock NO sería suficiente?
Ver guía
Partes que necesitan LLM real:
- Verificar que el prompt produce resultados semánticamente coherentes
- Detectar regresiones de calidad cuando cambia el modelo (gpt-4o → gpt-4o-mini)
- Validar que el prompt funciona para inputs edge (idiomas raros, textos ambiguos)
Tests de M2 con más confianza:
- Contract tests: sé que la estructura del output es siempre correcta
- Parser tests: sé que manejo todos los formatos del LLM
Donde mock no es suficiente:
- "¿El sentimiento detectado para 'Este producto es increíble' realmente es positivo?"
- "¿El resumen del artículo captura los puntos principales?"
- Estos requieren el LLM real + juicio semántico → Módulo 3
Recursos adicionales
- pytest-mock — Documentación — Plugin de pytest para mocking más limpio
- Pydantic v2 — Validators — Para contratos ejecutables
- unittest.mock — Where to patch — La guía crítica para entender el scope del patch
- syrupy — Snapshot testing — La mejor librería de snapshots para pytest
- pytest-cov — Para medir y exigir cobertura mínima
- Módulo 3: Integration Testing — El siguiente paso: LLM real + semantic assertions
- OpenAI API Reference — Para crear mocks con estructura exacta
- Testing Anti-patterns — Los errores más comunes en testing (muchos aplican a AI apps)