Módulo 1: Testing Fundamentals para AI
2. Por qué AI Necesita Testing Diferente
Descripción
Las aplicaciones LLM tienen cuatro particularidades que hacen que el testing tradicional sea insuficiente: non-determinism en outputs, prompt fragility, model drift, y costo por test. Esta cápsula explora cada una con ejemplos concretos de cómo se manifiesta en producción y qué estrategia de testing le corresponde.
La conclusión no es "no puedes testear AI." La conclusión es: el 70-80% de tu app AI es completamente determinístico y testeable con asserts normales. Para el 20-30% restante hay estrategias específicas que aprenderás en módulos 2 y 3. Esta cápsula te da el mapa completo.
Al terminar entenderás exactamente qué partes de tu app puedes testear hoy (con herramientas estándar), qué partes requieren mocking, y qué partes requieren estrategias más avanzadas.
Problema 1: Non-determinism
Qué es
El mismo prompt con el mismo input puede producir outputs diferentes en cada llamada:
# tests/demos/non_determinism_demo.py
import os
from openai import OpenAI
from dotenv import load_dotenv
load_dotenv()
client = OpenAI()
def call_llm(prompt: str, temperature: float = 0.7) -> str:
"""Llamada directa al LLM para demostración."""
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
temperature=temperature,
)
return response.choices[0].message.content
# Mismo prompt, múltiples respuestas
prompt = "Describe Python en exactamente 2 palabras."
for i in range(3):
result = call_llm(prompt)
print(f"Run {i+1}: {result!r}")
# Output posible:
# Run 1: 'Lenguaje versátil.'
# Run 2: 'Simple poderoso.'
# Run 3: 'Multipropósito flexible.'
Ejecutar este demo: python tests/demos/non_determinism_demo.py
Por qué esto rompe tests tradicionales
# ESTO FALLA INTERMITENTEMENTE — NO HAGAS ESTO
def test_llm_describes_python():
result = call_llm("Describe Python en exactamente 2 palabras.")
assert result == "Lenguaje versátil." # ❌ Falla 2 de cada 3 veces
El test pasa a veces y falla otras. Esto se llama flaky test y es el peor tipo de test: te hace perder confianza en toda tu suite porque nunca sabes si un fallo es real o aleatorio.
La solución correcta
# ESTO ES ROBUSTO — VERIFICA PROPIEDADES, NO VALOR EXACTO
def test_llm_describes_python_structure():
result = call_llm("Describe Python en exactamente 2 palabras.")
# Verifica propiedades invariantes, no el valor exacto
words = result.strip().rstrip(".").split()
assert len(words) == 2, f"Expected 2 words, got: {result!r}"
assert all(word.isalpha() for word in words), f"Expected words only: {result!r}"
Pero incluso esto puede ser frágil. La solución definitiva para unit tests es mockear el LLM (módulo 2), de modo que el output es 100% predecible.
La regla del 70/30
Tu app LLM típica:
┌─────────────────────────────────────────┐
│ 70-80% DETERMINÍSTICO │
│ │
│ - validate_input() → assert exacto │
│ - build_prompt() → assert exacto │
│ - parse_llm_output() → assert exacto │
│ - validate_schema() → assert exacto │
│ - format_response() → assert exacto │
│ - config loading → assert exacto │
│ - chain routing logic → mock + assert │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ 20-30% NO-DETERMINÍSTICO │
│ │
│ - call_llm() → mockear │
│ - streaming output → mockear │
│ │
│ Para tests que SÍ necesitan LLM real: │
│ - semantic assertions (M3) │
│ - property-based testing (M3) │
└─────────────────────────────────────────┘
Problema 2: Prompt Fragility
Qué es
Los prompts son código frágil. Un cambio mínimo — añadir una palabra, cambiar puntuación, reformular una frase — puede cambiar completamente el comportamiento del LLM.
Ejemplo concreto: cambios que rompen el sistema
# src/app/prompts.py
# VERSION 1: Funciona perfectamente
PROMPT_V1 = """Analyze the sentiment of the following text.
Return ONLY a JSON object with this exact structure:
{{"sentiment": "positive|negative|neutral", "confidence": 0.0-1.0}}
Text: {text}"""
# VERSION 2: Añadimos una instrucción útil... que rompe todo
PROMPT_V2 = """Analyze the sentiment of the following text.
Return ONLY a JSON object with this exact structure:
{{"sentiment": "positive|negative|neutral", "confidence": 0.0-1.0}}
If the text is in Spanish, analyze accordingly.
Text: {text}"""
# VERSION 3: Pequeño cambio de formato
PROMPT_V3 = """Analyze the sentiment. Return JSON:
{{"sentiment": "positive|negative|neutral", "confidence": 0.0-1.0}}
Text: {text}"""
Cómo se manifiestan estos cambios en producción:
# tests/unit/test_prompt_fragility_demo.py
import pytest
from unittest.mock import MagicMock, patch
def parse_sentiment_response(raw: str) -> dict:
"""Parser que asume JSON limpio."""
import json
return json.loads(raw.strip())
# ❌ V2 podría producir esto → rompe el parser:
problematic_outputs = [
# El LLM añade nota al final
'{"sentiment": "positive", "confidence": 0.9}\nNote: Text appears to be in English.',
# El LLM añade markdown
'```json\n{"sentiment": "positive", "confidence": 0.9}\n```',
# El LLM añade texto introductorio
'Here is the analysis: {"sentiment": "positive", "confidence": 0.9}',
]
@pytest.mark.parametrize("output", problematic_outputs)
def test_parser_handles_problematic_outputs(output):
"""Verifica que el parser maneja outputs problemáticos."""
try:
result = parse_sentiment_response(output)
# Si llega aquí sin excepción, verificamos estructura
assert "sentiment" in result
assert "confidence" in result
except Exception as e:
# Si falla, el test documenta el tipo de output que no se maneja
pytest.fail(f"Parser failed on output: {output!r}\nError: {e}")
Prompt Contract Tests
La solución es tratar los prompts como contratos de comportamiento y testearlos:
# tests/unit/test_prompt_contracts.py
import pytest
from unittest.mock import patch, MagicMock
from app.sentiment import analyze_sentiment # Función que construye prompt y llama al LLM
def create_mock_response(content: str):
"""Factory para respuestas mock realistas."""
mock = MagicMock()
mock.choices = [MagicMock()]
mock.choices[0].message.content = content
return mock
class TestSentimentPromptContract:
"""El prompt de sentiment SIEMPRE debe producir esta estructura."""
@patch("app.sentiment.client.chat.completions.create")
def test_returns_dict(self, mock_create):
"""Contrato: el resultado es un dict."""
mock_create.return_value = create_mock_response(
'{"sentiment": "positive", "confidence": 0.9}'
)
result = analyze_sentiment("I love this!")
assert isinstance(result, dict)
@patch("app.sentiment.client.chat.completions.create")
def test_has_required_keys(self, mock_create):
"""Contrato: el resultado tiene las keys requeridas."""
mock_create.return_value = create_mock_response(
'{"sentiment": "positive", "confidence": 0.9}'
)
result = analyze_sentiment("I love this!")
assert "sentiment" in result
assert "confidence" in result
@patch("app.sentiment.client.chat.completions.create")
def test_sentiment_is_valid_value(self, mock_create):
"""Contrato: sentiment es uno de los valores válidos."""
mock_create.return_value = create_mock_response(
'{"sentiment": "positive", "confidence": 0.9}'
)
result = analyze_sentiment("I love this!")
assert result["sentiment"] in ["positive", "negative", "neutral"]
@patch("app.sentiment.client.chat.completions.create")
def test_confidence_is_valid_range(self, mock_create):
"""Contrato: confidence está en rango [0.0, 1.0]."""
mock_create.return_value = create_mock_response(
'{"sentiment": "positive", "confidence": 0.9}'
)
result = analyze_sentiment("I love this!")
assert 0.0 <= result["confidence"] <= 1.0
Estos tests no verifican si el sentiment detectado es correcto (eso es evaluation). Verifican que el contrato estructural del prompt se cumple.
Problema 3: Model Drift
Qué es
Los proveedores de LLMs actualizan sus modelos constantemente. gpt-4o-mini de enero 2025 y gpt-4o-mini de julio 2025 pueden tener comportamientos ligeramente diferentes para el mismo prompt.
Casos reales de model drift
Escenario 1: Actualización silenciosa del modelo
- Tu app usa "gpt-3.5-turbo" (alias que siempre apunta al latest)
- OpenAI actualiza qué modelo es "latest"
- Tu prompt funcionaba con el modelo anterior
- El nuevo modelo produce formato JSON levemente diferente
- Tu parser falla silenciosamente
- Usuarios reciben errores durante 6 horas antes de que alguien se dé cuenta
Escenario 2: Migración de modelo
- Decides migrar de gpt-3.5-turbo a gpt-4o-mini por costo
- Aparentemente es un drop-in replacement
- 3 prompts de 12 producen output con formato diferente
- Sin tests de regression no sabes cuáles son los 3 hasta producción
Regression tests para model drift
# tests/regression/test_model_regression.py
"""
Tests de regresión para detectar model drift.
Se ejecutan semanalmente o antes de migrar modelo.
Son más lentos (llaman a API real) y cuestan dinero.
"""
import pytest
import json
# Solo correr en CI semanalmente o con flag especial
pytestmark = pytest.mark.regression
@pytest.fixture
def real_sentiment_app():
"""App real con LLM, para regression tests."""
from app.sentiment import SentimentAnalyzer
return SentimentAnalyzer() # Usa API key real
class TestSentimentRegression:
"""
Estos tests verifican que el modelo actual sigue comportándose
igual que cuando configuramos los prompts.
"""
@pytest.mark.parametrize("text,expected_sentiment", [
("I absolutely love this product!", "positive"),
("This is terrible, worst purchase ever.", "negative"),
("The package arrived on Tuesday.", "neutral"),
])
def test_sentiment_matches_golden_set(self, real_sentiment_app, text, expected_sentiment):
"""
Golden set: casos donde el sentimiento es inequívoco.
Si el LLM falla en estos, hay model drift significativo.
"""
result = real_sentiment_app.analyze(text)
assert result["sentiment"] == expected_sentiment, (
f"Model drift detectado para texto: {text!r}\n"
f"Expected: {expected_sentiment}, Got: {result['sentiment']}"
)
Ejecutar regression tests:
# Semanalmente (GitHub Actions cron)
pytest -m regression -v --tb=short
# Antes de migrar modelo
pytest -m regression -v
Problema 4: Cost Per Test
El cálculo real
# Ejemplo: app de análisis de documentos
# Modelo: gpt-4o-mini
# Tokens por test: ~500 input + ~200 output
# Precios actuales (verificar en platform.openai.com/pricing):
INPUT_PRICE_PER_1M = 0.15 # $0.15 por 1M tokens input
OUTPUT_PRICE_PER_1M = 0.60 # $0.60 por 1M tokens output
TOKENS_INPUT = 500
TOKENS_OUTPUT = 200
cost_per_test = (
(TOKENS_INPUT / 1_000_000) * INPUT_PRICE_PER_1M +
(TOKENS_OUTPUT / 1_000_000) * OUTPUT_PRICE_PER_1M
)
print(f"Costo por test: ${cost_per_test:.6f}") # $0.000195
# Suite de 100 tests
cost_100_tests = cost_per_test * 100
print(f"100 tests: ${cost_100_tests:.4f}") # $0.0195
# Si haces 20 PR por día, cada uno corre la suite:
daily_cost = cost_100_tests * 20
print(f"Costo diario: ${daily_cost:.2f}") # $0.39/día
# Mensual:
monthly_cost = daily_cost * 22 # días laborables
print(f"Costo mensual: ${monthly_cost:.2f}") # $8.58/mes
Parece poco. Pero considera:
- Suite crece a 500 tests = $43/mes
- Modelo más costoso (gpt-4o): ~30x más caro = ~$1,290/mes
Estrategia de 3 niveles
Nivel 1: Unit tests con mocks (rápidos, baratos)
├── Costo: $0 (no llaman a API)
├── Velocidad: <1 segundo por test
├── Cuándo correr: en cada save (watch mode) y cada PR
└── Cubren: 80% de la funcionalidad
Nivel 2: Integration tests con LLM real (lentos, tienen costo)
├── Costo: $X por run (depende de suite)
├── Velocidad: 2-10 segundos por test
├── Cuándo correr: en cada PR (con budget limit) o nightly
└── Cubren: flujos end-to-end críticos
Nivel 3: Regression tests (lentos, tienen costo)
├── Costo: $XX por run
├── Velocidad: variable
├── Cuándo correr: semanalmente o pre-deploy
└── Cubren: golden set para detectar model drift
# pytest.ini — configuración de la estrategia de 3 niveles
"""
[pytest]
testpaths = tests
markers =
unit: Tests con mocks. Rápidos, sin costo. (default en CI)
integration: Tests con LLM real. Tienen costo. Corren en PR.
regression: Tests de regresión. Costosos. Corren semanalmente.
smoke: Smoke tests. Verifican que el sistema levanta.
addopts = -v --tb=short
"""
# CI en cada commit: solo unit + smoke (sin costo)
pytest -m "unit or smoke"
# CI en cada PR: unit + smoke + integration (con budget limit)
pytest -m "not regression" --max-time=120
# Cron semanal: todo incluyendo regression
pytest --all
Por qué no testear cuesta más que testear
La siguiente tabla resume el costo real de no tener tests:
| Evento | Sin tests | Con tests |
|---|---|---|
| Cambio de prompt rompe el parseo | Bug vive en producción 2-6 horas, X usuarios afectados | CI detecta en 30 segundos antes del merge |
| Actualización de modelo cambia formato | Bug silencioso, días hasta detectar | Regression test falla en próximo run semanal |
| Refactoring rompe un parser | No hay forma de verificar, manual testing de 2 horas | pytest -m unit en 10 segundos |
| Bug crítico en fin de semana | Debugging a las 3am | Alerta de CI con línea exacta del fallo |
El costo mensual de una suite de tests bien diseñada (unit con mocks gratuitos + integration controlados) es menor que una hora de debugging de un ingeniero senior.
Mapa completo: qué testear y cómo
# Clasificación de estrategia de testing por componente
TESTING_MAP = {
# DETERMINÍSTICO → assert exacto, sin costo, rápido
"input_validator": {
"strategy": "assert exacto",
"mock_llm": False,
"example": "assert validate_input('') raises ValueError"
},
"prompt_builder": {
"strategy": "assert exacto en string",
"mock_llm": False,
"example": "assert '{text}' in build_prompt(text='hello')"
},
"output_parser": {
"strategy": "assert exacto con inputs fijos",
"mock_llm": False,
"example": "assert parse_json_output('{\"x\": 1}') == {'x': 1}"
},
"output_validator": {
"strategy": "assert exacto con schema",
"mock_llm": False,
"example": "assert validate_schema({'sentiment': 'positive'}) is True"
},
# CHAIN/PIPELINE → mock LLM, assert flujo
"llm_chain": {
"strategy": "mock LLM + assert resultado",
"mock_llm": True,
"example": "mock llm returns JSON, assert chain returns parsed dict"
},
"orchestrator": {
"strategy": "mock servicios externos + assert flujo",
"mock_llm": True,
"example": "mock llm + db, assert orchestrator calls in right order"
},
# NO-DETERMINÍSTICO → semantic assertions (módulo 3)
"llm_output_quality": {
"strategy": "semantic similarity / property-based (ver M3)",
"mock_llm": False,
"example": "assert len(response) > 50 and 'Python' in response"
},
}
Ejemplo integrado: app de summarización
Para anclar todo lo anterior, aquí está el mapa completo de una app real:
# src/app/summarizer.py
import json
from openai import OpenAI
client = OpenAI()
# COMPONENTE 1: Determinístico (testeable con assert exacto)
def validate_input(text: str, max_chars: int = 10000) -> str:
"""Valida y limpia el input antes de enviarlo al LLM."""
if not text or not text.strip():
raise ValueError("Input no puede estar vacío")
if len(text) > max_chars:
raise ValueError(f"Input excede {max_chars} caracteres")
return text.strip()
# COMPONENTE 2: Determinístico (testeable con assert exacto)
def build_summary_prompt(text: str, language: str = "es") -> str:
"""Construye el prompt para resumir."""
return f"""Resume el siguiente texto en 3 puntos clave.
Responde SOLO con JSON: {{"points": ["punto1", "punto2", "punto3"]}}
Idioma de respuesta: {language}
Texto: {text}"""
# COMPONENTE 3: No-determinístico (mockear en unit tests)
def call_llm(prompt: str) -> str:
"""Llama al LLM. No-determinístico."""
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
temperature=0.3, # Temperatura baja reduce variabilidad
)
return response.choices[0].message.content
# COMPONENTE 4: Determinístico (testeable con assert exacto)
def parse_summary_response(raw: str) -> dict:
"""Parsea la respuesta JSON del LLM."""
# Extraer JSON aunque haya texto alrededor
start = raw.find("{")
end = raw.rfind("}") + 1
if start == -1 or end == 0:
raise ValueError(f"No se encontró JSON en: {raw!r}")
return json.loads(raw[start:end])
# COMPONENTE 5: Determinístico (testeable con assert exacto)
def validate_summary(parsed: dict) -> dict:
"""Valida que el resumen tiene la estructura correcta."""
if "points" not in parsed:
raise ValueError("Falta key 'points' en respuesta")
if not isinstance(parsed["points"], list):
raise ValueError("'points' debe ser una lista")
if len(parsed["points"]) != 3:
raise ValueError(f"Se esperaban 3 puntos, got {len(parsed['points'])}")
return parsed
# FUNCIÓN PRINCIPAL: Orquesta todo (testeable con mock del LLM)
def summarize(text: str, language: str = "es") -> dict:
"""Función principal: valida → construye prompt → llama LLM → parsea → valida."""
validated_text = validate_input(text)
prompt = build_summary_prompt(validated_text, language)
raw_response = call_llm(prompt) # ← único punto no-determinístico
parsed = parse_summary_response(raw_response)
return validate_summary(parsed)
Tests para cada componente:
# tests/unit/test_summarizer.py
import pytest
from unittest.mock import patch, MagicMock
from app.summarizer import (
validate_input, build_summary_prompt,
parse_summary_response, validate_summary, summarize
)
# Tests de validate_input — completamente determinísticos
class TestValidateInput:
def test_valid_input(self):
assert validate_input("Hello world") == "Hello world"
def test_strips_whitespace(self):
assert validate_input(" Hello ") == "Hello"
def test_empty_raises(self):
with pytest.raises(ValueError, match="vacío"):
validate_input("")
def test_too_long_raises(self):
with pytest.raises(ValueError, match="excede"):
validate_input("x" * 10001)
# Tests de build_summary_prompt — completamente determinísticos
class TestBuildSummaryPrompt:
def test_includes_text(self):
prompt = build_summary_prompt("mi texto")
assert "mi texto" in prompt
def test_includes_language(self):
prompt = build_summary_prompt("texto", language="en")
assert "en" in prompt
def test_includes_json_structure(self):
prompt = build_summary_prompt("texto")
assert '"points"' in prompt
# Tests de parse_summary_response — completamente determinísticos
class TestParseSummaryResponse:
def test_valid_json(self):
result = parse_summary_response('{"points": ["a", "b", "c"]}')
assert result == {"points": ["a", "b", "c"]}
def test_json_with_surrounding_text(self):
raw = 'Aquí está: {"points": ["a", "b", "c"]} fin.'
result = parse_summary_response(raw)
assert result["points"] == ["a", "b", "c"]
def test_no_json_raises(self):
with pytest.raises(ValueError, match="No se encontró JSON"):
parse_summary_response("No hay JSON aquí")
# Test de summarize — mockea el LLM
class TestSummarize:
@patch("app.summarizer.client.chat.completions.create")
def test_summarize_returns_valid_structure(self, mock_create):
"""Con mock del LLM, verifica que la función orquesta correctamente."""
mock_response = MagicMock()
mock_response.choices[0].message.content = (
'{"points": ["Punto 1", "Punto 2", "Punto 3"]}'
)
mock_create.return_value = mock_response
result = summarize("Un texto largo sobre Python y AI.")
assert isinstance(result, dict)
assert "points" in result
assert len(result["points"]) == 3
mock_create.assert_called_once() # Verificar que llamó al LLM exactamente una vez
Comparación de estrategias de testing
| Estrategia | Costo | Velocidad | Determinist. | Cuándo usar |
|---|---|---|---|---|
| Assert exacto (sin mock) | $0 | <1ms | 100% | Parsers, validators, builders |
| Mock LLM + assert | $0 | <10ms | 100% | Chain logic, orquestación |
| LLM real + assert exacto | $$ | 2-5s | No | ❌ Flaky tests — evitar |
| LLM real + semantic assert | $$ | 2-5s | ~90% | Integration tests críticos |
| LLM real + golden dataset | $$$ | lento | ~80% | Regression tests semanales |
Troubleshooting
Problema: Mi test falla intermitentemente con el mismo código.
Solución: Es un flaky test — estás llamando al LLM real en un test que debería mockear. Identifica la llamada no-mockeada con --tb=long y agrega @patch.
Problema: Mockeo el LLM pero el test igual llama a la API (veo cobros).
Solución: El path del patch está mal. El patch debe apuntar a donde el objeto se usa, no donde se define. Si app.summarizer hace from openai import OpenAI; client = OpenAI(), el patch correcto es @patch("app.summarizer.client.chat.completions.create").
Problema: Los tests de regression son muy lentos y costosos. Solución: Usa un golden set pequeño (10-20 casos inequívocos) en vez de testear todo. El objetivo es detectar drift significativo, no perfección.
Problema: No sé cómo diferenciar un bug de non-determinism vs un bug real. Solución: Corre el test 3 veces. Si falla consistentemente → bug real. Si falla 1 de 3 → flaky test o non-determinism. La solución siempre es mockear el LLM en unit tests.
Problema: El LLM produce JSON válido pero con keys diferentes según el día.
Solución: Tu prompt no especifica suficientemente el schema. Añade un ejemplo de JSON explícito en el prompt y/o usa structured outputs de OpenAI (función calling o response_format={"type": "json_object"}).
Ejercicios
Ejercicio 1: Clasificar componentes de tu app
Toma tu app LLM y clasifica cada función en: (a) determinístico puro, (b) requiere mock LLM, (c) requiere semantic assertions.
Ver guía
Criterio de clasificación:
- Determinístico puro: La función no llama al LLM directamente ni usa su output. Ejemplos: parsers, validators, builders de prompt, formatters.
- Requiere mock LLM: La función llama al LLM o usa su output, pero la lógica alrededor es determinística (if/else, routing, etc.)
- Requiere semantic assertions: El test necesita verificar calidad semántica del output (no solo estructura). Ejemplos: test de que el resumen es coherente, test de que el sentiment detectado es correcto.
Ejemplo de clasificación:
# (a) Determinístico puro
def build_prompt(context: str) -> str: ...
def parse_json_response(raw: str) -> dict: ...
def validate_schema(data: dict) -> bool: ...
# (b) Requiere mock LLM
def generate_summary(text: str) -> dict:
prompt = build_prompt(text)
raw = call_llm(prompt) # ← punto de mock
return parse_json_response(raw)
# (c) Requiere semantic assertions
# Tests que verifican que el resumen es "correcto"
# → Módulo 3
Ejercicio 2: Calcular el costo de tu suite actual (o propuesta)
Estima el costo mensual si tu suite de 50 tests llamara a la API real en cada PR (20 PRs/día laborable).
Ver solución
# Cálculo
tests = 50
tokens_input = 500
tokens_output = 200
prs_per_day = 20
days_per_month = 22
# gpt-4o-mini (verificar precios actuales)
cost_per_input_token = 0.15 / 1_000_000
cost_per_output_token = 0.60 / 1_000_000
cost_per_test = (
tokens_input * cost_per_input_token +
tokens_output * cost_per_output_token
)
cost_per_run = cost_per_test * tests
cost_monthly = cost_per_run * prs_per_day * days_per_month
print(f"Costo por test: ${cost_per_test:.6f}")
print(f"Costo por run: ${cost_per_run:.4f}")
print(f"Costo mensual: ${cost_monthly:.2f}")
# Con mocks: $0 para unit tests
# Solo tests de integración (5-10% del total) usan API real
Conclusión: Incluso con gpt-4o-mini, 50 tests que llaman a la API real en cada PR costarían ~$10-20/mes. Con 500 tests o modelos más costosos, el costo es prohibitivo. Los mocks no son solo conveniencia — son necesidad económica.
Ejercicio 3: Escribir un contract test
Para la función summarize() del ejemplo de esta cápsula, escribe un contract test que verifique que el output tiene exactamente 3 puntos y cada uno es un string no vacío.
Ver solución
# tests/unit/test_summarize_contract.py
import pytest
from unittest.mock import patch, MagicMock
from app.summarizer import summarize
@patch("app.summarizer.client.chat.completions.create")
def test_summarize_three_non_empty_points(mock_create):
"""
Contrato: summarize() siempre retorna exactamente 3 puntos no vacíos.
Este test verifica la estructura, no la calidad del contenido.
"""
# Arrange
mock_response = MagicMock()
mock_response.choices[0].message.content = (
'{"points": ["Python es versátil", "Python es popular en AI", "Python tiene gran ecosistema"]}'
)
mock_create.return_value = mock_response
# Act
result = summarize("Texto sobre Python para demostración.")
# Assert — contrato estructural
assert "points" in result
assert len(result["points"]) == 3
for point in result["points"]:
assert isinstance(point, str), f"Punto debe ser string: {point!r}"
assert len(point.strip()) > 0, f"Punto no puede estar vacío: {point!r}"
Ejercicio 4: Identificar prompt fragility
El siguiente prompt tiene 2 formas de fallar silenciosamente. Identifícalas y propón soluciones.
prompt = """Analyze this customer feedback and provide insights.
Return your analysis as JSON.
Feedback: {text}"""
Ver solución
Problema 1: "Return your analysis as JSON" no especifica la estructura.
El LLM puede retornar JSONs con keys variables: {"analysis": "..."}, {"insights": [...]}, {"summary": "...", "sentiment": "..."} — todo es "válido" según el prompt.
Solución:
prompt = """Analyze this customer feedback.
Return ONLY this JSON (no other text):
{{"sentiment": "positive|negative|neutral", "key_issue": "string", "recommendation": "string"}}
Feedback: {text}"""
Problema 2: "Return your analysis as JSON" no prohíbe texto adicional.
El LLM puede escribir: "Here is my analysis: {...}" — el texto antes del JSON rompe json.loads().
Solución: Añadir "Return ONLY this JSON (no other text)" y/o usar response_format={"type": "json_object"} en OpenAI API.
Contract test para verificar:
@pytest.mark.parametrize("output", [
'{"sentiment": "positive", "key_issue": "pricing", "recommendation": "add discount"}',
])
def test_feedback_prompt_contract(output, mocker):
mocker.patch("app.feedback.call_llm", return_value=output)
result = analyze_feedback("Great product but too expensive!")
assert all(key in result for key in ["sentiment", "key_issue", "recommendation"])
Ejercicio 5: Diseñar la estrategia de testing para tu app
Para tu app LLM (o la de referencia del proyecto), define:
- ¿Cuántos unit tests (con mocks) necesitas? ¿Qué cubren?
- ¿Cuántos integration tests (LLM real) necesitas? ¿Cuándo se ejecutan?
- ¿Necesitas regression tests? ¿Con qué golden set?
Ver guía
Framework de decisión:
Unit tests (con mocks):
- Un test por función determinística (validators, parsers, builders)
- Un test por camino de ejecución en la función principal (happy path, error paths)
- Objetivo: cubrir el 80% del código sin llamadas a API
- Cuándo: en cada save, en cada PR
Integration tests (LLM real):
- 1-3 tests por flujo crítico de usuario
- Solo los flujos más usados en producción
- Objetivo: verificar que el sistema end-to-end funciona con LLM real
- Cuándo: en cada PR con budget limit ($0.50-1 por PR)
Regression tests:
- Solo si el modelo cambia frecuentemente o si tienes SLA
- Golden set pequeño (10-20 casos inequívocos)
- Cuándo: semanalmente o pre-deploy importante
Regla 80/20: 80% de los beneficios vienen de los unit tests con mocks. Los integration tests son el 20% extra que da confianza adicional. Si tienes que elegir, empieza con unit tests.
Resumen
- Non-determinism solo afecta al output del LLM (~20-30% del código); el resto es determinístico y testeable con asserts normales
- Prompt fragility: un cambio mínimo puede romper el parseo — los contract tests detectan esto antes del deploy
- Model drift: los modelos cambian; los regression tests con golden sets detectan cambios de comportamiento
- Costo por test: los unit tests deben usar mocks ($0); solo los integration tests usan LLM real (con budget controls)
- La estrategia óptima son 3 niveles: unit (mocks, siempre), integration (LLM real, en PR), regression (LLM real, semanal)
- El ROI de testing en AI es incluso mayor que en software tradicional por la naturaleza silenciosa de los failures
Recursos adicionales
- Testing ML Systems (Google) — Testing para sistemas de ML/AI en producción
- Non-Determinism in Testing (Martin Fowler) — Por qué los flaky tests son tan dañinos y cómo eliminarlos
- OpenAI Structured Outputs —
response_formatpara eliminar el problema de parseo de JSON - pytest-mock — Integración más limpia de mocking con pytest
- Property-Based Testing with Hypothesis — Estrategia para manejar non-determinism (se profundiza en módulo 3)
- OpenAI API Pricing — Para calcular costos reales de tu suite de tests
- Evals for LLM Applications (Hamel Husain) — Framework mental para decidir qué evaluar vs qué testear