Módulo 1: Testing Fundamentals para AI
6. Taxonomía de Tests: Smoke, Contract, Behavioral, Regression
Descripción
No todos los tests son iguales, y no todos valen el mismo tiempo de escritura. Sin una taxonomía clara, los equipos tienden a escribir tests al azar, duplicar esfuerzo, y nunca tener cobertura donde más importa. Esta cápsula define cuatro tipos de tests específicos para apps AI — smoke, contract, behavioral, y regression — con criterios claros de qué escribe cada uno y cuándo ejecutarlo.
La taxonomía no es teoría académica. Es un framework de decisión: cuando tienes 2 horas para escribir tests antes de un deploy, ¿qué escribes primero? Cuándo un bug llega a producción, ¿qué test lo habría detectado? Cuándo el LLM cambia de versión, ¿qué ejecutas para validar que nada se rompió?
Al terminar tendrás claridad sobre qué tipo de test corresponde a cada situación, cómo organizarlos con markers de pytest, y cuál es la prioridad de escritura para maximizar el valor con el menor tiempo invertido.
Los cuatro tipos de tests para AI
┌─────────────────────────────────────────────────────────────────┐
│ PIRÁMIDE DE TESTS PARA AI │
│ │
│ ┌───────────────────────────────┐ │
│ │ REGRESSION TESTS │ → Añadir cuando │
│ │ "¿Volvió algún bug?" │ encuentras bugs │
│ └───────────────────────────────┘ │
│ ┌─────────────────────────────────────┐ │
│ │ BEHAVIORAL TESTS │ → Después de │
│ │ "¿El output tiene las propiedades │ contracts │
│ │ esperadas?" │ │
│ └─────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────┐ │
│ │ CONTRACT TESTS │ → Segundo │
│ │ "¿El output cumple la estructura esperada?"│ paso │
│ └─────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ SMOKE TESTS │ │
│ │ "¿El sistema arranca y responde?" │ → Primero │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
Tipo 1: Smoke Tests
Definición
Los smoke tests verifican que el sistema está vivo y responde. No prueban que la lógica es correcta — solo que el código arranca, los módulos se importan sin errores, y los endpoints principales responden.
El nombre viene de hardware: "¿Le sale humo al encenderlo?" Si el sistema humea (crashea al arrancar), nada más importa.
Características
- Velocidad: Muy rápidos (<1 segundo por test)
- Costo: $0 (no llaman al LLM real)
- Determinísticos: 100%
- Cuándo correr: Siempre — en cada commit, en cada PR, en local antes de empezar a trabajar
Ejemplos
# tests/unit/test_smoke.py
import pytest
from app import llm, parsers, sentiment
@pytest.mark.smoke
def test_app_modules_import_without_errors():
"""El paquete app y sus módulos se importan sin errores."""
# Si cualquier import falla (NameError, ImportError, SyntaxError),
# este test falla y indica que hay un problema crítico en el código
assert llm is not None
assert parsers is not None
assert sentiment is not None
@pytest.mark.smoke
def test_main_functions_are_callable():
"""Las funciones principales del sistema son callable."""
from app.sentiment import analyze_sentiment
from app.summarizer import summarize
from app.parsers import parse_json_from_llm_output
assert callable(analyze_sentiment)
assert callable(summarize)
assert callable(parse_json_from_llm_output)
@pytest.mark.smoke
def test_config_loads_without_errors():
"""La configuración del sistema carga correctamente."""
from app.config import settings
# Solo verifica que existe, no que los valores son correctos
assert settings is not None
assert hasattr(settings, "model_name")
assert hasattr(settings, "max_tokens")
# Para apps con FastAPI:
@pytest.mark.smoke
def test_api_health_check_returns_200(test_client):
"""El endpoint /health responde con 200."""
response = test_client.get("/health")
assert response.status_code == 200
@pytest.mark.smoke
def test_api_docs_accessible(test_client):
"""La documentación de Swagger está accesible."""
response = test_client.get("/docs")
assert response.status_code == 200
@pytest.mark.smoke
def test_main_endpoint_accepts_valid_request(test_client):
"""El endpoint principal acepta un request válido (no 404, no 422)."""
response = test_client.post("/analyze", json={"text": "Test input"})
# No verificamos el resultado — solo que el endpoint existe y acepta el formato
assert response.status_code != 404, "Endpoint /analyze no existe"
assert response.status_code != 422, "Endpoint /analyze rechaza el formato del request"
Cuántos smoke tests necesitas
App LLM mínima: 3-5 smoke tests
├── test_modules_import ← SIEMPRE
├── test_main_function_callable ← SIEMPRE
└── test_config_loads ← Si tienes config
App con FastAPI: 5-8 smoke tests
├── test_modules_import
├── test_health_check_200
├── test_main_endpoint_accepts_request
├── test_docs_accessible ← Opcional
└── test_db_connection ← Si tienes DB
Tipo 2: Contract Tests
Definición
Los contract tests verifican que un componente cumple su "contrato": la estructura y tipos del output que prometió producir. Para apps LLM, el contrato más importante es el del prompt: "este prompt siempre produce JSON con estas keys y estos tipos."
El nombre viene de Design by Contract: si la función promete retornar {"sentiment": str, "confidence": float}, el contrato test verifica exactamente eso.
Características
- Velocidad: Rápidos con mocks (<100ms por test)
- Costo: $0 con mocks
- Determinísticos: 100% con mocks
- Cuándo correr: Siempre — en cada commit y PR
Ejemplos
# tests/unit/test_contracts.py
import pytest
from unittest.mock import patch, MagicMock
from app.sentiment import analyze_sentiment
from app.summarizer import summarize
def create_mock(content: str) -> MagicMock:
mock = MagicMock()
mock.choices[0].message.content = content
return mock
@pytest.mark.contract
class TestSentimentPromptContract:
"""
El prompt de análisis de sentimiento SIEMPRE debe producir:
- Un dict
- Con keys "sentiment" y "confidence"
- "sentiment" es uno de ["positive", "negative", "neutral"]
- "confidence" es float en [0.0, 1.0]
"""
@patch("app.sentiment.client.chat.completions.create")
def test_returns_dict(self, mock_create):
mock_create.return_value = create_mock(
'{"sentiment": "positive", "confidence": 0.9}'
)
result = analyze_sentiment("texto")
assert isinstance(result, dict), f"Expected dict, got {type(result)}"
@patch("app.sentiment.client.chat.completions.create")
def test_has_sentiment_key(self, mock_create):
mock_create.return_value = create_mock(
'{"sentiment": "positive", "confidence": 0.9}'
)
result = analyze_sentiment("texto")
assert "sentiment" in result, f"Missing 'sentiment' key. Got: {result}"
@patch("app.sentiment.client.chat.completions.create")
def test_has_confidence_key(self, mock_create):
mock_create.return_value = create_mock(
'{"sentiment": "positive", "confidence": 0.9}'
)
result = analyze_sentiment("texto")
assert "confidence" in result, f"Missing 'confidence' key. Got: {result}"
@patch("app.sentiment.client.chat.completions.create")
def test_sentiment_is_valid_value(self, mock_create):
mock_create.return_value = create_mock(
'{"sentiment": "positive", "confidence": 0.9}'
)
result = analyze_sentiment("texto")
valid_sentiments = {"positive", "negative", "neutral"}
assert result["sentiment"] in valid_sentiments, (
f"sentiment debe ser uno de {valid_sentiments}, got: {result['sentiment']!r}"
)
@patch("app.sentiment.client.chat.completions.create")
def test_confidence_is_numeric(self, mock_create):
mock_create.return_value = create_mock(
'{"sentiment": "positive", "confidence": 0.9}'
)
result = analyze_sentiment("texto")
assert isinstance(result["confidence"], (int, float)), (
f"confidence debe ser numérico, got: {type(result['confidence'])}"
)
@patch("app.sentiment.client.chat.completions.create")
def test_confidence_in_valid_range(self, mock_create):
mock_create.return_value = create_mock(
'{"sentiment": "positive", "confidence": 0.9}'
)
result = analyze_sentiment("texto")
assert 0.0 <= result["confidence"] <= 1.0, (
f"confidence fuera de rango [0,1]: {result['confidence']}"
)
@pytest.mark.contract
class TestSummarizerPromptContract:
"""
El prompt de summarización SIEMPRE debe producir:
- Un dict con key "points"
- "points" es una lista de exactamente 3 strings no vacíos
"""
@patch("app.summarizer.client.chat.completions.create")
def test_returns_dict_with_points(self, mock_create):
mock_create.return_value = create_mock(
'{"points": ["Punto 1", "Punto 2", "Punto 3"]}'
)
result = summarize("texto largo")
assert isinstance(result, dict)
assert "points" in result
assert isinstance(result["points"], list)
@patch("app.summarizer.client.chat.completions.create")
def test_returns_exactly_three_points(self, mock_create):
mock_create.return_value = create_mock(
'{"points": ["Punto 1", "Punto 2", "Punto 3"]}'
)
result = summarize("texto largo")
assert len(result["points"]) == 3, (
f"Se esperaban 3 puntos, got {len(result['points'])}: {result['points']}"
)
@patch("app.summarizer.client.chat.completions.create")
def test_each_point_is_non_empty_string(self, mock_create):
mock_create.return_value = create_mock(
'{"points": ["Punto 1", "Punto 2", "Punto 3"]}'
)
result = summarize("texto largo")
for i, point in enumerate(result["points"]):
assert isinstance(point, str), f"Punto {i} debe ser str: {point!r}"
assert len(point.strip()) > 0, f"Punto {i} no puede estar vacío"
Contract tests para parsers
@pytest.mark.contract
class TestJsonParserContract:
"""
parse_json_from_llm_output SIEMPRE debe:
- Retornar un dict para JSON válido (en cualquier formato)
- Lanzar ValueError para input sin JSON
- Lanzar JSONDecodeError para JSON malformado
"""
@pytest.mark.parametrize("raw_json,expected", [
('{"x": 1}', {"x": 1}),
('```json\n{"x": 1}\n```', {"x": 1}),
('Result: {"x": 1}', {"x": 1}),
])
def test_returns_dict_for_valid_json(self, raw_json, expected):
result = parse_json_from_llm_output(raw_json)
assert result == expected
def test_raises_for_no_json(self):
with pytest.raises(ValueError):
parse_json_from_llm_output("No JSON aquí")
def test_raises_for_malformed_json(self):
import json
with pytest.raises(json.JSONDecodeError):
parse_json_from_llm_output('{"incomplete":')
Tipo 3: Behavioral Tests
Definición
Los behavioral tests verifican que el output tiene las propiedades esperadas sin comparar el valor exacto. Son más flexibles que los contract tests (que verifican estructura) y se usan cuando hay algo que no puede ser completamente determinístico o cuando la especificación es "dentro de un rango" en vez de "exactamente X".
Características
- Velocidad: Rápidos con mocks; lentos con LLM real
- Costo: $0 con mocks; $X con LLM real
- Determinísticos: Con mocks sí; con LLM real, parcialmente
- Cuándo correr: Siempre con mocks; solo en PR/nightly con LLM real
Ejemplos
# tests/unit/test_behavioral.py
import pytest
from unittest.mock import patch, MagicMock
@pytest.mark.behavioral
class TestSentimentBehavior:
"""Tests de comportamiento — propiedades del output, no valores exactos."""
@patch("app.sentiment.client.chat.completions.create")
def test_confidence_reflects_certainty(self, mock_create):
"""
Para input inequívoco (claramente positivo o negativo),
la confidence debe ser alta (>0.7).
Este test usa LLM mock con respuesta apropiada para texto fuerte.
"""
mock_create.return_value = create_mock(
'{"sentiment": "positive", "confidence": 0.95}'
)
result = analyze_sentiment("I absolutely LOVE this! Best product EVER!")
# Behavioral: confidence debe ser alta para texto fuerte
assert result["confidence"] > 0.7, (
f"Para texto muy positivo esperamos confidence alta. Got: {result['confidence']}"
)
@patch("app.sentiment.client.chat.completions.create")
def test_response_is_deterministic_for_same_input(self, mock_create):
"""Con mocks, el mismo input siempre produce el mismo output."""
mock_response = create_mock('{"sentiment": "positive", "confidence": 0.9}')
mock_create.return_value = mock_response
result1 = analyze_sentiment("Same text")
result2 = analyze_sentiment("Same text")
assert result1 == result2
@pytest.mark.behavioral
class TestSummarizerBehavior:
"""Tests de comportamiento del summarizador."""
@patch("app.summarizer.client.chat.completions.create")
def test_summary_points_are_different_from_each_other(self, mock_create):
"""Los puntos del resumen no deben ser duplicados."""
mock_create.return_value = create_mock(
'{"points": ["Python es versátil", "Python domina AI", "Python tiene gran ecosistema"]}'
)
result = summarize("Texto largo sobre Python y AI en la industria")
# Behavioral: los puntos no deben ser duplicados
points = result["points"]
unique_points = set(points)
assert len(unique_points) == len(points), (
f"Los puntos del resumen contienen duplicados: {points}"
)
@patch("app.summarizer.client.chat.completions.create")
def test_summary_points_have_minimum_length(self, mock_create):
"""Cada punto del resumen debe tener una longitud mínima."""
mock_create.return_value = create_mock(
'{"points": ["Punto con suficiente información para ser útil", '
'"Otro punto con contenido significativo", '
'"El tercer punto también tiene contenido relevante"]}'
)
result = summarize("Texto largo")
for i, point in enumerate(result["points"]):
assert len(point) >= 10, (
f"Punto {i} demasiado corto ({len(point)} chars): {point!r}"
)
# Integration behavioral tests (con LLM real)
@pytest.mark.integration
@pytest.mark.behavioral
class TestSentimentBehaviorIntegration:
"""Tests de comportamiento con LLM real — tienen costo."""
def test_positive_text_detected_correctly(self):
"""Para texto claramente positivo, el sentiment debe ser positive."""
result = analyze_sentiment("I absolutely love this product! Amazing quality!")
# Behavioral: para texto inequívocamente positivo, el LLM debe detectar positive
assert result["sentiment"] == "positive", (
f"Expected positive sentiment for clearly positive text. Got: {result['sentiment']}"
)
def test_negative_text_detected_correctly(self):
"""Para texto claramente negativo, el sentiment debe ser negative."""
result = analyze_sentiment("Terrible experience. Worst product ever. Never again.")
assert result["sentiment"] == "negative"
Tipo 4: Regression Tests
Definición
Los regression tests verifican que bugs conocidos no vuelvan a aparecer. Se crean DESPUÉS de encontrar y corregir un bug: cuando corriges el bug, añades un test con el input exacto que lo causó. Si alguien inadvertidamente introduce el mismo bug de nuevo, el test falla.
Características
- Velocidad: Variable (depende del bug)
- Costo: $0 si el bug era en parsers; $X si era en la respuesta del LLM
- Determinísticos: Sí (con mocks para bugs de LLM)
- Cuándo correr: Siempre — en cada commit y PR
Proceso de crear un regression test
Bug encontrado en producción:
"Input con emojis causa UnicodeDecodeError en el parser"
1. Reproducir el bug:
>>> parse_json_from_llm_output('{"text": "I love 🎉 this!"}')
UnicodeDecodeError: ... ← Confirmado
2. Crear el regression test ANTES del fix:
def test_parser_regression_handles_emoji_in_json():
raw = '{"text": "I love 🎉 this!"}'
result = parse_json_from_llm_output(raw) ← Falla (expected)
assert result["text"] == "I love 🎉 this!"
3. Implementar el fix
4. Verificar que el regression test pasa:
pytest tests/regression/test_parser_regression.py -v ← Ahora pasa ✅
5. El test queda en la suite permanentemente
Ejemplos
# tests/regression/test_parser_regression.py
"""
Tests de regresión: bugs que se han encontrado y corregido.
NO se deben borrar estos tests — previenen que los bugs vuelvan.
Cada test debe tener un comentario con la fecha y descripción del bug.
"""
import pytest
from app.parsers import parse_json_from_llm_output
@pytest.mark.regression
class TestParserRegressions:
def test_handles_emojis_in_json_content(self):
"""
Regresión [2026-01-15]: El parser lanzaba UnicodeDecodeError
cuando el JSON contenía emojis. Fix: usar encoding='utf-8' en json.loads.
"""
raw = '{"text": "I love 🎉 this product! Amazing! 🚀"}'
result = parse_json_from_llm_output(raw)
assert "🎉" in result["text"]
assert "🚀" in result["text"]
def test_handles_unicode_characters(self):
"""
Regresión [2026-01-20]: Parser fallaba con caracteres españoles ñ, ü, é.
Bug relacionado con el de emojis.
"""
raw = '{"resumen": "El niño aprendió inglés y matemáticas"}'
result = parse_json_from_llm_output(raw)
assert "ñ" in result["resumen"]
assert "é" in result["resumen"]
def test_handles_nested_quotes_in_json(self):
"""
Regresión [2026-02-03]: El parser fallaba cuando el contenido del JSON
tenía comillas dobles escapadas (\\"). El LLM a veces produce esto.
"""
raw = '{"quote": "She said \\"hello\\" to me"}'
result = parse_json_from_llm_output(raw)
assert 'hello' in result["quote"]
def test_handles_newlines_in_json_values(self):
"""
Regresión [2026-02-10]: El parser fallaba cuando los values del JSON
contenían saltos de línea literales (no \\n escapados).
"""
# El LLM a veces produce JSON con newlines literales en los valores
raw = '{"text": "First line\\nSecond line\\nThird line"}'
result = parse_json_from_llm_output(raw)
assert "\n" in result["text"]
@pytest.mark.regression
class TestSentimentRegressions:
@patch("app.sentiment.client.chat.completions.create")
def test_handles_very_long_input_without_timeout(self, mock_create):
"""
Regresión [2026-01-25]: La función se "colgaba" con inputs muy largos
porque no había límite de tokens. Fix: truncar input a 5000 chars.
"""
mock_create.return_value = create_mock(
'{"sentiment": "neutral", "confidence": 0.5}'
)
very_long_text = "palabra " * 10000 # 80,000 caracteres
# Debe completar en tiempo razonable (no timeout)
result = analyze_sentiment(very_long_text)
assert result["sentiment"] in ["positive", "negative", "neutral"]
@patch("app.sentiment.client.chat.completions.create")
def test_does_not_leak_api_key_in_error_message(self, mock_create):
"""
Regresión [2026-02-01]: Un error en la gestión de excepciones
incluía el API key en el mensaje de error. Fix: sanitizar mensajes de error.
"""
mock_create.side_effect = Exception("Error with key sk-proj-abc123xyz")
with pytest.raises(Exception) as exc_info:
analyze_sentiment("texto")
# El mensaje de error NO debe contener credenciales
error_message = str(exc_info.value)
assert "sk-proj" not in error_message, (
"El mensaje de error no debe contener el API key"
)
Cuándo escribir cada tipo
Framework de decisión
Situación → Tipo de test a escribir
"Voy a hacer deploy en 1 hora y no hay tests"
→ Smoke tests primero (5 min), luego contract tests para el flujo crítico
"Acabo de cambiar el prompt principal"
→ Contract tests para verificar que la estructura del output no cambió
"El LLM produce outputs con diferentes formatos según el día"
→ Behavioral tests sobre propiedades invariantes (longitud, rango, tipo)
→ Contract tests con mocks que cubran los formatos posibles
"Encontré un bug en producción"
→ Regression test con el input exacto que causó el bug
→ Fix → regression test pasa → integrar a la suite
"Voy a migrar de gpt-3.5-turbo a gpt-4o-mini"
→ Regression tests con golden set (los behaviors más importantes)
→ Ejecutar antes y después de la migración
"Quiero saber si mi refactoring no rompió nada"
→ Todos los tests existentes (smoke + contract + behavioral + regression)
Prioridad con tiempo limitado
| Tiempo disponible | Qué escribir |
|---|---|
| 30 minutos | 2 smoke tests + 1 contract test del flujo más crítico |
| 2 horas | Smoke completo + contract tests para todos los prompts |
| 1 día | Todo lo anterior + behavioral tests + regression para bugs conocidos |
| 1 semana | Suite completa con todos los tipos para todos los componentes |
Markers de pytest para cada tipo
Configuración en pytest.ini
[pytest]
testpaths = tests
markers =
smoke: Tests de humo. Sin costo. Verifican que el sistema arranca y responde.
contract: Tests de contrato. Sin costo. Verifican estructura y tipos del output.
behavioral: Tests de comportamiento. Variable costo. Verifican propiedades del output.
regression: Tests de regresión. Sin costo (mocks). Previenen bugs conocidos.
unit: Tests con mocks. Sin costo. Rápidos.
integration: Tests con LLM real. Tienen costo. Lentos.
addopts = -v --tb=short
Estrategia de ejecución por contexto
# Desarrollo local — siempre correr:
pytest -m "smoke or contract or regression"
# Pre-commit — tests rápidos:
pytest -m "smoke or unit" --no-header -q
# CI en cada PR — sin costo:
pytest -m "not integration" -v
# CI en PR para rama main — incluir integration:
pytest -m "not regression" -v # Integration sí, regression no (costosos)
# CI semanal / pre-release — todo:
pytest --all -v
# Verificar después de cambio de prompt:
pytest -m "contract" -v
# Verificar después de migración de modelo:
pytest -m "regression or behavioral" -v
Organización de archivos
tests/
├── conftest.py # Fixtures globales
├── smoke/
│ └── test_smoke.py # Todos los smoke tests
├── unit/
│ ├── contracts/
│ │ ├── test_sentiment_contract.py
│ │ ├── test_summarizer_contract.py
│ │ └── test_parser_contract.py
│ ├── behavioral/
│ │ └── test_behavioral.py
│ └── regression/
│ ├── test_parser_regression.py
│ └── test_sentiment_regression.py
└── integration/
└── test_e2e.py
Comparación completa de los cuatro tipos
| Aspecto | Smoke | Contract | Behavioral | Regression |
|---|---|---|---|---|
| Pregunta | ¿Arranca? | ¿Estructura correcta? | ¿Propiedades OK? | ¿Volvió bug X? |
| Cuándo escribir | Primero | Segundo | Tercero | Al encontrar bug |
| Usa mock LLM | No (no hay LLM) | Sí | Sí/no | Sí |
| Velocidad | Muy rápido | Rápido | Variable | Variable |
| Costo | $0 | $0 | $0 (mock) | $0 (mock) |
| Determinist. | 100% | 100% | 100% (mock) | 100% |
| Cuándo correr | Siempre | Siempre | Siempre | Siempre |
| Número típico | 3-8 | 5-20 | 5-15 | Crece con el tiempo |
Troubleshooting
Problema: No sé si un test es "contract" o "behavioral".
Solución: Contract = verifica estructura exacta (claves, tipos, valores válidos). Behavioral = verifica propiedad (rango, longitud, relación entre valores). Si comparas con == → contract. Si comparas con >, <, in, isinstance → behavioral.
Problema: Tengo muchos regression tests y algunos son lentos.
Solución: Los regression tests de parsers/validators son rapidísimos (sin LLM). Los que requieren LLM real deben marcarse con @pytest.mark.integration además de @pytest.mark.regression para poder excluirlos del CI rápido.
Problema: Un behavioral test con LLM real falla intermitentemente. Solución: Si el behavioral test usa LLM real y verifica algo como "el sentiment es positive para texto positivo", puede fallar si el LLM cambia de comportamiento. Dos opciones: (1) convertirlo a unit test con mock (más robusto), o (2) aceptar que puede ser flaky y ejecutarlo solo en regression semanal.
Problema: No sé cómo manejar un smoke test que requiere conexión real. Solución: Los smoke tests deben ser lo más rápidos y baratos posible. Si el smoke test requiere LLM real, crea dos versiones: smoke con mock (siempre corre) y un integration smoke (solo en PR).
Problema: ¿Cómo organizar cuando un test parece ser de múltiples tipos?
Solución: Aplica múltiples markers. @pytest.mark.regression @pytest.mark.behavioral es perfectamente válido. El test es un regression porque surgió de un bug, y behavioral porque verifica propiedades del output.
Ejercicios
Ejercicio 1: Clasificar tests
Clasifica estos tests como smoke, contract, behavioral o regression:
a) test_app_imports_without_errors()
b) test_summarize_returns_dict_with_points_key()
c) test_summary_length_is_at_least_50_chars()
d) test_parser_handles_emoji_input()
e) test_health_endpoint_returns_200()
f) test_confidence_is_between_0_and_1()
Ver solución
a) test_app_imports_without_errors() → SMOKE
"El sistema arranca" — básico, antes de todo.
b) test_summarize_returns_dict_with_points_key() → CONTRACT
Verifica estructura exacta: ¿existe la key "points"?
c) test_summary_length_is_at_least_50_chars() → BEHAVIORAL
Verifica propiedad (longitud mínima), no valor exacto.
d) test_parser_handles_emoji_input() → REGRESSION
"handles" + caso específico (emoji) → Surgió de un bug específico.
e) test_health_endpoint_returns_200() → SMOKE
Verifica que el endpoint existe y responde — básico.
f) test_confidence_is_between_0_and_1() → CONTRACT
Verifica rango válido para un tipo específico — parte del contrato del output.
(Podría ser behavioral si verificas una propiedad de comportamiento semántico)
Ejercicio 2: Crear contract test desde especificación
El endpoint /classify debe retornar:
{
"category": "technology|sports|politics|entertainment",
"subcategory": "string (optional)",
"confidence": "float between 0 and 1"
}
Escribe un contrato test completo.
Ver solución
@pytest.mark.contract
@patch("app.classifier.client.chat.completions.create")
def test_classify_endpoint_contract(mock_create):
"""
Contrato del endpoint /classify:
- Retorna dict con key 'category'
- 'category' es uno de los valores válidos
- 'confidence' está en [0.0, 1.0]
- 'subcategory' es opcional pero si existe es string
"""
mock_create.return_value = create_mock(
'{"category": "technology", "subcategory": "AI", "confidence": 0.92}'
)
result = classify_text("OpenAI releases new model with enhanced reasoning capabilities.")
# 1. Es un dict
assert isinstance(result, dict)
# 2. Tiene key 'category'
assert "category" in result, f"Falta 'category'. Got: {result}"
# 3. 'category' tiene valor válido
valid_categories = {"technology", "sports", "politics", "entertainment"}
assert result["category"] in valid_categories, (
f"Categoría inválida: {result['category']!r}. Válidas: {valid_categories}"
)
# 4. Tiene key 'confidence'
assert "confidence" in result
# 5. 'confidence' es float en rango válido
assert isinstance(result["confidence"], float)
assert 0.0 <= result["confidence"] <= 1.0
# 6. Si tiene 'subcategory', debe ser string
if "subcategory" in result and result["subcategory"] is not None:
assert isinstance(result["subcategory"], str)
Ejercicio 3: De bug a regression test
Describes este bug: "Cuando el input tiene comillas simples ('), el parseo del JSON falla con JSONDecodeError porque el LLM las interpreta como delimitadores de string."
Escribe el regression test para este bug.
Ver solución
@pytest.mark.regression
def test_parser_regression_handles_single_quotes_in_input():
"""
Regresión [2026-02-15]: La función lanzaba JSONDecodeError cuando el
input del usuario contenía comillas simples.
Causa: El prompt construía el JSON con f-string y las comillas simples
en el input se escapaban incorrectamente.
Fix: Usar json.dumps() para serializar el texto del usuario en el prompt.
"""
# Input que causó el bug
problematic_input = "I'm really happy with this product! It's amazing!"
# Debe procesar correctamente, sin excepción
mock_response_content = '{"sentiment": "positive", "confidence": 0.95}'
with patch("app.sentiment.client.chat.completions.create") as mock_create:
mock_create.return_value = create_mock(mock_response_content)
result = analyze_sentiment(problematic_input)
# No debe haber lanzado excepción
assert result is not None
assert "sentiment" in result
assert result["sentiment"] == "positive"
Ejercicio 4: Behavioral test para propiedad de distribución
Escribe un behavioral test que verifique que, dado un texto largo (>500 palabras), el resumen sea significativamente más corto (menos del 50% de la longitud original).
Ver solución
@pytest.mark.behavioral
@patch("app.summarizer.client.chat.completions.create")
def test_summarizer_compresses_long_text(mock_create):
"""
Propiedad de comportamiento: el resumen debe ser más corto que el original.
Para texto >500 palabras, el resumen debe ser <50% de la longitud.
"""
# ─── ARRANGE ───
long_text = " ".join(["palabra"] * 600) # 600 palabras, ~3600 chars
mock_create.return_value = create_mock(
'{"points": ["Punto resumido 1", "Punto resumido 2", "Punto resumido 3"]}'
)
# ─── ACT ───
result = summarize(long_text)
# ─── ASSERT (behavioral) ───
# La suma de puntos no debe exceder 50% del original
summary_length = sum(len(p) for p in result["points"])
original_length = len(long_text)
assert summary_length < original_length * 0.5, (
f"El resumen ({summary_length} chars) no es suficientemente corto. "
f"Original: {original_length} chars. "
f"Ratio: {summary_length/original_length:.1%} (esperado <50%)"
)
Ejercicio 5: Priorización de tests
Tienes 4 horas antes de un deploy importante. Tu app tiene:
- 3 prompts distintos
- 5 parsers de output
- 1 endpoint FastAPI principal
- 2 bugs conocidos que ya fueron corregidos
¿Qué tests priorizas? Justifica el orden.
Ver guía
Hora 1: Smoke tests (5 min) + Contract tests de los 3 prompts (45 min)
─────────────────────────────────────────────────────────────────
- 1 smoke test: endpoint principal responde sin errores
- 1 smoke test: módulos se importan correctamente
- 3 contract tests: uno por prompt (estructura del output)
Hora 2: Contract tests de los 5 parsers (60 min)
─────────────────────────────────────────────────
- 5 contract tests: uno por parser
- Cubren el happy path + 1 edge case cada uno
- Total: ~10 tests en 60 min
Hora 3: Regression tests para los 2 bugs conocidos (30 min)
────────────────────────────────────────────────────────────
- 2 regression tests exactos (los inputs que causaron los bugs)
- Son los más fáciles de escribir: ya conoces el input problemático
Hora 4: Behavioral tests para los comportamientos más importantes (60 min)
──────────────────────────────────────────────────────────────────────────
- 2-3 behavioral tests para propiedades críticas (longitud, rango, formato)
Resultado: ~18-20 tests en 4 horas
- Cobertura: todos los flujos críticos tienen tests
- Zero riesgos conocidos: smoke + contracts
- Bugs previos documentados: regression tests
Resumen
- Smoke tests: "¿Arranca?" → Siempre primero, siempre corren, 3-8 tests
- Contract tests: "¿Estructura correcta?" → Segundo paso, con mocks, uno por prompt/parser
- Behavioral tests: "¿Propiedades esperadas?" → Tercero, assertions flexibles sobre propiedades invariantes
- Regression tests: "¿Volvió el bug?" → Se crean al encontrar bugs, nunca se borran
- Orden de escritura: smoke → contract → behavioral → regression (cuando hay bugs)
- Cada tipo tiene su marker:
pytest -m contractpara verificar prompts,pytest -m smokepara sanity check rápido - Los contract tests son el corazón del testing de apps LLM: protegen contra prompt fragility y model drift
Recursos adicionales
- Test Pyramid — Martin Fowler — La pirámide de tests original y su relevancia para AI
- Consumer-Driven Contract Testing — Pact — Contract testing en sistemas distribuidos (concepto aplicable)
- pytest markers documentation — Cómo usar markers para organizar y ejecutar subsets
- Regression Testing — Wikipedia — Fundamentos de regression testing
- Testing ML Systems — Google — Estrategias de testing para sistemas ML en producción
- Property-Based Testing with Hypothesis — Para behavioral tests avanzados (se profundiza en módulo 3)