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 unit termina en menos de 10 segundos
  • 0 llamadas a la API real de OpenAI en tests unitarios
  • pytest --cov=app.parsers muestra >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ápsulaConcepto centralHerramienta principalCuándo usarlo
01Prompts como contratos; mocking como superpoderMagicMock, fixturesSiempre en unit tests
02Mock realista con estructura de API realcreate_openai_chat_responseCada test que mockea LLM
03Contrato específico: estructura + tipos + constraintsPydantic, pytest assertionsCada prompt de la app
04Snapshot para detectar regresiones; revisar diffpytest-snapshot, syrupyOutputs complejos
05Parsers: 100% determinísticos, alto ROIparametrize, pytest.raisesSiempre testear parsers
06Factory fixture para variaciones dinámicasFixtures que retornan funciones3+ variaciones del mismo mock
07Suite completa: contract + parser + regressionTodo lo anteriorEl proyecto
08Troubleshooting y cierreEsta cápsulaCuando 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

  1. 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.

  2. 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".

  3. Flaky test management: Estrategias para tests con LLM real que a veces fallan: retry logic, tolerancia en assertions, categorizar como "flaky" vs "real failure".

  4. 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.

  5. 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:

  1. 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
  2. Actualizar los contract tests:

    def test_contract_structure():
        # Añadir assertion para urgency (opcional)
        assert "urgency" in result or result.get("urgency") is None
  3. 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
  4. Actualizar los snapshots si los tienes:

    pytest tests/ --snapshot-update  # Solo para los tests afectados
  5. 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:

  1. ¿Qué partes realmente necesitan el LLM real para ser testadas?
  2. ¿Qué tests de los que escribiste en M2 te dieron más confianza?
  3. ¿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

  1. pytest-mock — Documentación — Plugin de pytest para mocking más limpio
  2. Pydantic v2 — Validators — Para contratos ejecutables
  3. unittest.mock — Where to patch — La guía crítica para entender el scope del patch
  4. syrupy — Snapshot testing — La mejor librería de snapshots para pytest
  5. pytest-cov — Para medir y exigir cobertura mínima
  6. Módulo 3: Integration Testing — El siguiente paso: LLM real + semantic assertions
  7. OpenAI API Reference — Para crear mocks con estructura exacta
  8. Testing Anti-patterns — Los errores más comunes en testing (muchos aplican a AI apps)