Módulo 1: Testing Fundamentals para AI
1. Introducción al Módulo: Testing Fundamentals para AI
Descripción
Este es el primer módulo de la guía Production Best Practices. Antes de implementar guardrails, logging o reliability, necesitas internalizar una verdad incómoda: tu aplicación AI probablemente no tiene tests, y eso es un problema. La mayoría de AI Engineers viene de un mundo de prototipado rápido donde "funciona" = "está listo." Este módulo rompe esa mentalidad con hechos concretos y código ejecutable desde la primera sesión.
El objetivo no es darte un tutorial de pytest. Ese ya lo conoces. El objetivo es enseñarte a adaptar testing a las particularidades de los sistemas LLM: non-determinism en outputs, costo por llamada a la API, prompt fragility, y model drift. Estas características hacen que testear AI sea diferente — no imposible, sino diferente.
Al terminar este módulo tendrás una infraestructura de testing funcional para tu propia app LLM, con pytest configurado correctamente, fixtures para mocking del LLM, markers para separar tests rápidos de lentos, y los primeros smoke tests y contract tests escritos. No 100% coverage — una base sólida sobre la que construir.
Contexto: ¿Dónde estamos en la guía?
Production Best Practices Guide
│
├── Phase 1: Testing AI Systems ← ESTÁS AQUÍ
│ ├── Módulo 1: Testing Fundamentals ← ESTE MÓDULO
│ ├── Módulo 2: Unit Testing LLM Applications
│ └── Módulo 3: Integration Testing & Estrategias No-Determinísticas
│
├── Phase 2: Safety & Quality
│ ├── Módulo 4: Guardrails
│ ├── Módulo 5: Structured Logging
│ └── Módulo 6: Code Quality Patterns
│
└── Phase 3: Production Readiness
├── Módulo 7: Reliability Patterns
└── Módulo 8: Proyecto Integrador Production-Ready
Este módulo es la base de todo lo que sigue. La infraestructura de testing que configuras aquí se reutiliza en cada módulo posterior. Cuando implementes guardrails (módulo 4), los probarás con pytest. Cuando construyas el sistema de logging (módulo 5), validarás que funciona con tests. Cuando implementes el reliability layer (módulo 7), verificarás el comportamiento con tests automatizados.
No puedes saltar este módulo.
Prerequisitos de esta guía
Antes de empezar, verifica que cumples estos requisitos:
Técnicos:
- ✅ Python 3.10+ instalado
- ✅ Familiaridad con pytest a nivel básico (puedes correr
pytest, sabes qué es una fixture) - ✅ Experiencia con OpenAI API o similar (ya usaste
client.chat.completions.create) - ✅ Conoces Pydantic (lo usarás en módulos 2 y 4)
- ✅ Completaste la guía #12 (Evaluation Frameworks) o entiendes la diferencia entre LLM evaluation y testing de código
De experiencia:
- ✅ Tienes al menos una app que usa un LLM (chatbot, RAG, agent — lo que sea)
- ✅ Entiendes que los outputs de LLMs son no-determinísticos
- ✅ Has visto fallar algún sistema en producción (o lo intuyes)
Si no tienes una app LLM propia, no te preocupes. En el Proyecto del Módulo 1 te proveo una app de referencia completa sobre la que trabajar.
Comparación: Testing tradicional vs Testing AI
Esta es la diferencia más importante que necesitas entender antes de escribir tu primer test:
| Aspecto | Software tradicional | Apps AI/LLM |
|---|---|---|
| Output | Determinístico | No-determinístico |
| Costo por test | $0 | Cada llamada a la API cuesta dinero |
| Qué falla | Lógica, cálculos, estados | Prompts, parsers, estructura de output |
| Cuándo falla | Inmediatamente (crash) | Silenciosamente (output incorrecto que parece correcto) |
| Fixtures | Datos estáticos simples | Mocks de respuestas LLM con estructura compleja |
| CI/CD | Todos los tests en cada PR | Solo tests rápidos/baratos en cada PR; costosos semanalmente |
El punto más crítico: las fallas en AI son silenciosas. Si una función suma mal, el programa crashea. Si un prompt produce un output con estructura incorrecta, el sistema puede "funcionar" pero devolver datos incorrectos a usuarios reales durante días.
Por qué testing PRIMERO
La tentación natural es "primero termino el feature, después agrego tests." Para AI apps, esto es especialmente peligroso por tres razones:
Razón 1: Prompt fragility Un cambio mínimo en un prompt puede cambiar completamente el output. Sin tests, nunca sabes si tu cambio rompió algo hasta que un usuario se queja.
# Prompt original → Output: {"sentiment": "positive", "confidence": 0.92}
prompt_v1 = "Analyze the sentiment of this text and return JSON."
# Prompt modificado (pequeño cambio) → Output: "The sentiment is positive." (string, no JSON)
prompt_v2 = "Analyze the sentiment of this text."
Sin un contract test que verifique que el output es JSON con las keys correctas, este cambio pasa desapercibido.
Razón 2: Model drift
Los proveedores de LLMs actualizan sus modelos periódicamente. gpt-3.5-turbo de enero 2025 no es idéntico a gpt-3.5-turbo de noviembre 2025. Sin regression tests, no sabrás si una actualización del modelo cambió el comportamiento de tu app.
Razón 3: Cadena de efectos En sistemas RAG y agentes, un cambio en un componente puede afectar varios downstream. Un test que falla en el parser de outputs del LLM puede indicar que el problema está tres pasos antes en el pipeline.
La excusa del non-determinism
La objeción más común: "No puedo testear outputs del LLM porque son no-determinísticos."
Es una excusa, no una limitación técnica.
La realidad:
Tu app LLM típica
│
├── Determinístico (70-80%):
│ ├── Parsers de output del LLM
│ ├── Validators (Pydantic, custom)
│ ├── Business logic que procesa el output
│ ├── Config loading
│ ├── Chain/pipeline logic
│ └── Error handling
│
└── No-determinístico (20-30%):
└── El output del LLM en sí
(pero su ESTRUCTURA puede ser determinística)
El 70-80% de tu código es completamente determinístico y testeable con tests normales. Para el 20-30% restante, hay tres estrategias que verás en los módulos 2 y 3:
- Mockear el LLM → Test determinístico completo (módulo 2)
- Semantic similarity assertions → Verificar propiedades del output sin match exacto (módulo 3)
- Property-based testing → Verificar invariantes del output (módulo 3)
No necesitas renunciar a testing por el non-determinism. Necesitas estrategias más sofisticadas.
Diferencia con Evaluation Frameworks (Guía #12)
Si completaste la guía #12, esta distinción es crítica:
| Aspecto | Evaluation (guía #12) | Testing (este módulo) |
|---|---|---|
| Pregunta central | ¿Qué tan buena es la respuesta? | ¿El código se comporta como espero? |
| Foco | Calidad semántica del output del LLM | Comportamiento del código que rodea al LLM |
| Herramientas | Métricas (coherencia, groundedness), golden datasets, LLM-as-judge | pytest, mocks, assertions estructurales |
| Cuándo corre | Evaluaciones batch, offline, periódicas | Tests en cada commit/PR (CI/CD) |
| Ejemplo | "El 87% de las respuestas tienen coherencia >0.8" | "El parser extrae correctamente el JSON del output" |
Son complementarios. Evaluation te dice si el LLM responde bien. Testing te dice si tu código maneja esa respuesta correctamente.
Setup técnico inicial
Antes de empezar las cápsulas técnicas, configura tu entorno:
Instalación base
# Crea entorno virtual (recomendado)
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
# Instala dependencias de testing
pip install pytest pytest-asyncio pytest-mock pytest-cov
# Dependencias del proyecto (si no las tienes)
pip install openai pydantic python-dotenv
# Verifica instalación
pytest --version
# pytest 8.x.x
Variables de entorno
# .env (crear en raíz del proyecto)
OPENAI_API_KEY=sk-proj-...
# Verificar que carga
python -c "from dotenv import load_dotenv; import os; load_dotenv(); print(os.getenv('OPENAI_API_KEY', 'NOT SET')[:10])"
Estructura de directorios objetivo
Al terminar el Módulo 1 tendrás esta estructura:
mi-app-ai/
├── src/
│ └── app/
│ ├── __init__.py
│ ├── llm.py # Cliente LLM
│ └── parsers.py # Parsers de output
├── tests/
│ ├── conftest.py # Fixtures compartidas
│ ├── unit/
│ │ ├── test_parsers.py
│ │ └── test_llm_chain.py
│ └── integration/
│ └── test_e2e.py
├── pytest.ini # Configuración de pytest
├── .env # API keys
└── requirements.txt
Objetivos del módulo
Al terminar este módulo serás capaz de:
- Articular por qué las apps AI necesitan testing diferente (prompt fragility, non-determinism, cost)
- Configurar pytest con
conftest.py, markers yparametrizeadaptados para proyectos AI - Escribir tests siguiendo el patrón Arrange-Act-Assert adaptado para LLM apps
- Categorizar tests por tipo: smoke, contract, behavioral, regression
- Mockear respuestas LLM para hacer tests determinísticos
- Estructurar fixtures reutilizables para proyectos AI
- Tener un Test Suite Setup funcional corriendo para tu app LLM
Roadmap: las 8 cápsulas del módulo
| # | Cápsula | Tipo | Contenido | Duración est. |
|---|---|---|---|---|
| 01 | Introducción | Intro | Esta cápsula: contexto, setup, objetivos | 30 min |
| 02 | Por qué AI necesita testing diferente | Técnica | Non-determinism, prompt fragility, model drift, cost | 45 min |
| 03 | Configuración de pytest | Técnica | conftest.py, markers, parametrize, scopes | 60 min |
| 04 | Anatomía del test | Técnica | Arrange-Act-Assert adaptado, assertions para LLM | 60 min |
| 05 | Fixtures para apps LLM | Técnica | Mock LLM, fixture factories, shared fixtures | 60 min |
| 06 | Taxonomía de tests | Técnica | Smoke, contract, behavioral, regression | 45 min |
| 07 | Proyecto: Test Suite Setup | Proyecto | Hands-on: configurar testing para app LLM real | 90 min |
| 08 | Troubleshooting y resumen | Cierre | Errores comunes, resumen del módulo, puente al módulo 2 | 30 min |
Duración total estimada: 7 horas (lectura + práctica)
Qué construirás en este módulo
El mini-proyecto del Módulo 1 es el Test Suite Setup: tomar una app LLM (la tuya o la de referencia provista) y configurar:
pytest.inicon markers y configuración apropiadaconftest.pycon fixtures para mock del LLM y shared utilitiestests/unit/con smoke tests y contract tests básicostests/integration/con estructura lista (los tests de integración vienen en el módulo 3)- Una primera corrida exitosa de
pytest -m "not integration"con todos los tests pasando
No es el proyecto más glamoroso de la guía, pero es la infraestructura que hace posible todo lo demás.
Qué NO cubre este módulo
Para gestionar expectativas:
- ❌ Semantic similarity assertions → Módulo 3 (requiere estrategias avanzadas para non-determinism)
- ❌ Property-based testing con Hypothesis → Módulo 3
- ❌ Tests end-to-end con LLM real → Módulo 3 (tienen implicaciones de costo y estrategia)
- ❌ Testing de endpoints FastAPI → Se menciona pero no es el foco
- ❌ CI/CD pipeline → Se menciona el comando para CI pero la configuración de GitHub Actions va en el módulo 7
- ❌ Snapshot testing avanzado → Módulo 2
Este módulo es "foundations": las herramientas básicas para que todo lo demás funcione.
Evidencia de éxito al terminar este módulo
Sabrás que completaste exitosamente el módulo si:
- ✅ Puedes correr
pytest -m unity todos los tests pasan (sin errores de import) - ✅ Puedes correr
pytest -m smokey los smoke tests del proyecto pasan - ✅ Tu
conftest.pytiene al menos un mock de LLM reutilizable - ✅ Puedes explicar la diferencia entre un smoke test, un contract test y un behavioral test
- ✅ Puedes diferenciar qué partes de tu app son determinísticas vs no-determinísticas
Ejercicios
Ejercicio 1: Inventario de tu app — ¿qué es determinístico?
Toma tu app LLM actual (o la de referencia del proyecto). Dibuja un diagrama simple del flujo de datos y marca cada componente como D (determinístico) o ND (no-determinístico).
Ver guía
Determinístico (D):
- Código que valida el formato del input (¿es string vacío? ¿supera límite de tokens?)
- Parsers que extraen JSON del output del LLM
- Validators que verifican que el output tiene las keys correctas
- Business logic que procesa el output parseado
- Cualquier función que no llame al LLM
No-determinístico (ND):
- La llamada al LLM en sí (
client.chat.completions.create(...)) - Cualquier función que dependa directamente del output semántico del LLM
Regla práctica: Si puedes reemplazar la función con un mock sin que cambien los tests de comportamiento del código que la llama, probablemente es determinística (o el non-determinism está encapsulado y puede mockearse).
Ejemplo de diagrama:
Input: "Analiza este contrato"
│
├── [D] validate_input() → verifica que no está vacío
├── [D] build_prompt() → construye el prompt con template
├── [ND] call_llm() → llama a OpenAI API
├── [D] parse_llm_response() → extrae JSON del output
├── [D] validate_response_schema() → verifica keys requeridas
└── [D] format_for_user() → formatea para el usuario
En este ejemplo, 5 de 6 funciones son determinísticas y testables normalmente.
Ejercicio 2: ¿Qué rompería tu app?
Imagina que cambias una palabra en tu prompt principal. Lista 3 cosas que podrían romperse y cómo te enterarías sin tests vs con tests.
Ver guía
Ejemplo de análisis:
| Qué cambia | Qué se rompe | Sin tests (cómo te enteras) | Con tests (cómo te enteras) |
|---|---|---|---|
| "Return JSON" → quitarlo | LLM devuelve texto en vez de JSON | Usuario ve error en producción | Contract test falla en CI |
| "Confidence: 0-1" → quitarlo | Campo confidence desaparece o cambia escala | Parser crashea silenciosamente | Schema validation test falla |
| Añadir "Be concise" | Output más corto, tal vez truncado | Usuarios se quejan de respuestas incompletas | Behavioral test falla (longitud mínima) |
Patrón: Sin tests te enteras por usuarios o logs de errores. Con tests te enteras antes del deploy.
Ejercicio 3: ROI de testing — la cuenta concreta
Estima: si un cambio de prompt rompe algo en producción, ¿cuánto tiempo pierdes en debugging? Compáralo con el tiempo de escribir un contract test.
Ver guía
Cuentas reales:
Debugging un bug de producción silencioso:
- Detectar el problema: 30 min - 2 horas (si tienes logging)
- Reproducir localmente: 1-3 horas
- Identificar causa raíz: 1-4 horas
- Fix + verificar: 30 min
- Total: 3-10 horas (más si fue en fin de semana)
Contract test que habría detectado esto:
- Escribir el test: 10-15 minutos
- Mantenimiento futuro: ~2 min por cambio
- Total amortizado: 15-30 minutos
ROI:
- Invertiste 15 min en el test
- Te ahorraste 3-10 horas de debugging
- ROI: 12-40x en la primera falla detectada
En AI, el ROI es incluso mayor porque los failures son sutiles: el sistema no crashea, devuelve datos incorrectos que parecen correctos. Pueden pasar días sin que nadie se dé cuenta.
Ejercicio 4: Diferencia entre Testing y Evaluation
Describe con tus palabras la diferencia entre:
- Un test que verifica que el output del LLM es JSON con las keys
sentimentyconfidence - Una métrica de evaluation que mide si el sentiment detectado es correcto
Ver solución
Test (pytest):
def test_sentiment_output_structure(mock_llm):
result = analyze_sentiment("I love this product")
# Verifica ESTRUCTURA, no calidad
assert isinstance(result, dict)
assert "sentiment" in result
assert "confidence" in result
assert isinstance(result["confidence"], float)
assert 0.0 <= result["confidence"] <= 1.0
Metric (evaluation):
# Evaluation batch (offline)
correct = 0
for text, expected_sentiment in golden_dataset:
result = analyze_sentiment(text)
if result["sentiment"] == expected_sentiment:
correct += 1
accuracy = correct / len(golden_dataset)
# "Accuracy en golden dataset: 87%"
La diferencia:
- El test verifica que el código se comporta como espero (tiene las keys, tipos correctos)
- La métrica verifica que el LLM responde con calidad (el sentiment es correcto)
Puedes tener 100% de tests pasando y 60% de accuracy en evaluation — significa que el código es correcto pero el LLM no es lo suficientemente bueno en esta tarea. Ambas dimensiones son necesarias.
Ejercicio 5: Primer test mental
Sin escribir código, describe en palabras cómo sería el primer test que escribirías para tu app LLM. Considera: ¿qué mockeas? ¿qué afirmas? ¿es smoke, contract, behavioral o regression?
Ver guía
Para una app de análisis de sentimiento:
Test: "El analizador de sentimiento retorna la estructura correcta"
Tipo: Contract test
Arrange:
- Mock del LLM que retorna '{"sentiment": "positive", "confidence": 0.9}'
Act:
- Llama a analyze_sentiment("I love this!")
Assert:
- El resultado es un dict
- Tiene keys "sentiment" y "confidence"
- "sentiment" es string (no verifica el valor semántico)
- "confidence" es float entre 0 y 1
Por qué es contract test: Verifica que el prompt "cumple su contrato"
de devolver una estructura específica, sin evaluar si el sentiment
detectado es correcto.
Para cualquier app: El primer test siempre debería ser un smoke test: "la app se importa sin errores y el cliente LLM se inicializa." Si esto falla, nada más puede funcionar.
Test: "La app se importa correctamente"
Tipo: Smoke test
Arrange: nada
Act: import app.main
Assert: no lanza ImportError, AttributeError ni NameError
Resumen
- Testing AI no es lo mismo que testing de software tradicional: non-determinism, costo por test, y fallas silenciosas cambian la estrategia
- El 70-80% de tu app LLM es completamente determinístico y testeable normalmente
- El non-determinism del LLM se maneja con mocking (módulo 2) o assertions de propiedades (módulo 3)
- Testing y Evaluation son complementarios: testing verifica comportamiento del código, evaluation mide calidad del output del LLM
- Este módulo configura la infraestructura de testing que usarás en todos los módulos siguientes
- Empieza simple: smoke tests primero, contract tests segundo, behavioral tests después
Recursos adicionales
- pytest Documentation — Documentación oficial de pytest (la base de todo)
- unittest.mock — Mocking en Python estándar
- pytest-asyncio — Testing async (crítico para apps que usan async LLM calls)
- Testing Best Practices (Google) — Blog de testing de Google Engineering
- Guía #12: Evaluation Frameworks — Prerequisito de esta guía (diferencia evaluation vs testing)
- The Practical Test Pyramid (Martin Fowler) — Estrategia de testing en capas (aplica a AI con adaptaciones)
- pytest-mock — Integración más limpia de mocking con pytest