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:

AspectoSoftware tradicionalApps AI/LLM
OutputDeterminísticoNo-determinístico
Costo por test$0Cada llamada a la API cuesta dinero
Qué fallaLógica, cálculos, estadosPrompts, parsers, estructura de output
Cuándo fallaInmediatamente (crash)Silenciosamente (output incorrecto que parece correcto)
FixturesDatos estáticos simplesMocks de respuestas LLM con estructura compleja
CI/CDTodos los tests en cada PRSolo 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:

  1. Mockear el LLM → Test determinístico completo (módulo 2)
  2. Semantic similarity assertions → Verificar propiedades del output sin match exacto (módulo 3)
  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:

AspectoEvaluation (guía #12)Testing (este módulo)
Pregunta central¿Qué tan buena es la respuesta?¿El código se comporta como espero?
FocoCalidad semántica del output del LLMComportamiento del código que rodea al LLM
HerramientasMétricas (coherencia, groundedness), golden datasets, LLM-as-judgepytest, mocks, assertions estructurales
Cuándo correEvaluaciones batch, offline, periódicasTests 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:

  1. Articular por qué las apps AI necesitan testing diferente (prompt fragility, non-determinism, cost)
  2. Configurar pytest con conftest.py, markers y parametrize adaptados para proyectos AI
  3. Escribir tests siguiendo el patrón Arrange-Act-Assert adaptado para LLM apps
  4. Categorizar tests por tipo: smoke, contract, behavioral, regression
  5. Mockear respuestas LLM para hacer tests determinísticos
  6. Estructurar fixtures reutilizables para proyectos AI
  7. Tener un Test Suite Setup funcional corriendo para tu app LLM

Roadmap: las 8 cápsulas del módulo

#CápsulaTipoContenidoDuración est.
01IntroducciónIntroEsta cápsula: contexto, setup, objetivos30 min
02Por qué AI necesita testing diferenteTécnicaNon-determinism, prompt fragility, model drift, cost45 min
03Configuración de pytestTécnicaconftest.py, markers, parametrize, scopes60 min
04Anatomía del testTécnicaArrange-Act-Assert adaptado, assertions para LLM60 min
05Fixtures para apps LLMTécnicaMock LLM, fixture factories, shared fixtures60 min
06Taxonomía de testsTécnicaSmoke, contract, behavioral, regression45 min
07Proyecto: Test Suite SetupProyectoHands-on: configurar testing para app LLM real90 min
08Troubleshooting y resumenCierreErrores comunes, resumen del módulo, puente al módulo 230 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.ini con markers y configuración apropiada
  • conftest.py con fixtures para mock del LLM y shared utilities
  • tests/unit/ con smoke tests y contract tests básicos
  • tests/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 unit y todos los tests pasan (sin errores de import)
  • ✅ Puedes correr pytest -m smoke y los smoke tests del proyecto pasan
  • ✅ Tu conftest.py tiene 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é cambiaQué se rompeSin tests (cómo te enteras)Con tests (cómo te enteras)
"Return JSON" → quitarloLLM devuelve texto en vez de JSONUsuario ve error en producciónContract test falla en CI
"Confidence: 0-1" → quitarloCampo confidence desaparece o cambia escalaParser crashea silenciosamenteSchema validation test falla
Añadir "Be concise"Output más corto, tal vez truncadoUsuarios se quejan de respuestas incompletasBehavioral 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:

  1. Un test que verifica que el output del LLM es JSON con las keys sentiment y confidence
  2. 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

  1. pytest Documentation — Documentación oficial de pytest (la base de todo)
  2. unittest.mock — Mocking en Python estándar
  3. pytest-asyncio — Testing async (crítico para apps que usan async LLM calls)
  4. Testing Best Practices (Google) — Blog de testing de Google Engineering
  5. Guía #12: Evaluation Frameworks — Prerequisito de esta guía (diferencia evaluation vs testing)
  6. The Practical Test Pyramid (Martin Fowler) — Estrategia de testing en capas (aplica a AI con adaptaciones)
  7. pytest-mock — Integración más limpia de mocking con pytest