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óduloQué aprendiste/aprenderás
Módulo 1Pytest, fixtures, markers, estructura de tests, mindset AI testing
Módulo 2Mocking LLM responses, prompt contracts, parsers, snapshot testing
Módulo 3Integration testing con LLM real, semantic assertions, non-determinism
Módulo 4CI/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 caracteres
  • confidence: float entre 0.0 y 1.0
  • sources: 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.mock y pytest-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

AspectoMódulo 1Módulo 2
FocoConfigurar pytest, mindsetMocking, contracts, parsers
TestsSmoke, contract básicoContract completo, snapshot, parsers
FixturesEstáticas básicasFactories, variaciones, edge cases
Tiempo de ejecución< 30s< 10s
Llamadas a APIAlgunas (smoke)0 (unit tests puros)
ProyectoTest Suite SetupPrompt 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:

PreguntaContract TestEvaluation Metric
¿Qué verifica?Estructura y formatoCalidad semántica
Ejemplo de assertion"summary" in result"El resumen es coherente"
Herramientapytest, PydanticLLM-as-judge, ROUGE, BERTScore
Cuándo fallaJSON malformado, key faltanteRespuesta sin sentido
VelocidadMillisecondsSeconds (necesita LLM)
DeterminismoSí (con mock)No (LLM variable)
Cuándo usarloCada commitPre-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ápsulaContenidoTipo
01IntroducciónEsta cápsula — mindset y arquitecturaConceptual
02Mocking de respuestasunittest.mock, pytest-mock, mocks realistasTécnico
03Prompt contract testsDefinir y validar contratos con pytest + PydanticTécnico
04Snapshot testingDetectar regresiones de prompts automáticamenteTécnico
05Parsers y output processorsTesting aislado de lógica determinísticaTécnico
06Fixture factoriesVariaciones parametrizadas de mocksTécnico
07Proyecto Prompt Contract TestsHands-on: construir la suite completaProyecto
08Resumen y troubleshootingCierre, errores comunes, transición M3Cierre

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:

  1. Recibe un texto
  2. Llama a un LLM con un prompt de análisis de sentimiento
  3. Parsea el output JSON del LLM
  4. Retorna {sentiment: str, score: float, explanation: str}

En el Módulo 2 vas a:

  1. Mockear la llamada al LLM
  2. Definir el contrato del prompt de sentimiento
  3. Testear el parser de forma aislada
  4. Crear snapshot del output esperado
  5. 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:

  1. assert "sentiment" in result
  2. assert result["score"] >= 0 and result["score"] <= 1
  3. assert result["explanation"].startswith("El texto")
  4. Verificar que el resumen captura los puntos clave del original
  5. assert isinstance(result["tags"], list)
  6. Verificar que la clasificación de sentimiento es correcta para textos negativos
Ver solución
  1. Contract — verifica que existe la key
  2. Contract — verifica rango numérico (constraint)
  3. ⚠️ 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
  4. Evaluation — "captura los puntos clave" requiere juicio semántico
  5. Contract — verifica tipo de dato
  6. 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ónRespuesta
Testear que el parser maneja JSON bien formadoMock (determinístico, no necesita LLM)
Verificar que el prompt en español funciona correctamenteLLM 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 calidadLLM real (regression testing de calidad)
Probar que la app maneja un error 429Mock (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

  1. unittest.mock — Documentación oficial Python — La base del mocking en Python
  2. pytest-mock — Plugin de pytest para mocking más ergonómico
  3. Pact — Contract Testing — Conceptos de contratos (más orientado a microservicios, pero útil para entender la filosofía)
  4. OpenAI API Reference — Estructura real de las responses para crear mocks realistas
  5. Property-based testing con Hypothesis — Técnica complementaria (se verá en M3)
  6. Módulo 1: Testing Fundamentals — Prerequisito
  7. Software Testing Anti-patterns — Errores comunes a evitar