Módulo 2: Unit Testing LLM Applications
4. Snapshot Testing
Descripción
Snapshot testing captura el output de un test la primera vez y en ejecuciones futuras lo compara con el snapshot guardado. Si el output cambia, el test falla. En el contexto de AI apps con mocks determinísticos, los snapshots son una forma eficaz de detectar regresiones: un cambio en el prompt, el parser o el output processor que altera el formato de salida se captura inmediatamente. La clave es no actualizar el snapshot sin revisar el diff.
El concepto en 3 pasos
Paso 1: Primera ejecución
→ Test corre, output generado
→ Snapshot guardado en archivo (tests/__snapshots__/...)
→ Test PASA (crea el snapshot)
Paso 2: Ejecuciones futuras
→ Test corre, output generado
→ Output comparado con snapshot guardado
→ Si es igual: PASA
→ Si es diferente: FALLA (muestra el diff)
Paso 3: Cuando el test falla
→ Revisa el diff: ¿el cambio es intencional?
→ ¿Sí? → pytest --snapshot-update → actualiza el snapshot
→ ¿No? → Bug → arregla el código, no el snapshot
¿Por qué snapshot testing en AI apps?
En apps LLM con mocks determinísticos, el snapshot parece redundante: "si el output es siempre igual, ¿para qué guardar el snapshot?". La respuesta: protección contra cambios accidentales.
Situación 1: Cambiaste el prompt
→ El mock devuelve siempre el mismo string
→ Pero cambiaste cómo se construye el prompt
→ ¿Cambiaste el parser también? ¿Olvidaste algo?
→ Si el output final cambió, el snapshot lo detecta
Situación 2: Refactorizaste el parser
→ "Solo simplifiqué el código"
→ Pero el output tiene un espacio extra o una key con nombre diferente
→ El snapshot captura el cambio exacto
Situación 3: Actualizaste una dependencia
→ pydantic v1 → v2: el dict() ahora se llama model_dump()
→ El formato del output cambió sutilmente
→ El snapshot detecta la diferencia
Snapshot sin librería: con fichero de referencia
La forma más simple: guardar el output esperado como archivo JSON y comparar.
# tests/snapshots/sentiment_output.json (lo creas tú la primera vez)
{
"sentiment": "positivo",
"score": 0.95,
"explanation": "El texto expresa claramente satisfacción con el producto.",
"keywords": ["encanta", "producto", "excelente"]
}
# test_sentiment_snapshot.py
import json
import pytest
from pathlib import Path
from tests.helpers import create_openai_chat_response
SNAPSHOTS_DIR = Path(__file__).parent / "snapshots"
def load_snapshot(name: str) -> dict:
path = SNAPSHOTS_DIR / f"{name}.json"
with open(path) as f:
return json.load(f)
def save_snapshot(name: str, data: dict) -> None:
SNAPSHOTS_DIR.mkdir(exist_ok=True)
path = SNAPSHOTS_DIR / f"{name}.json"
with open(path, "w") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
def test_sentiment_matches_snapshot(mocker):
"""El output del analizador de sentimiento no cambió respecto al snapshot."""
mock_create = mocker.patch("app.sentiment.client.chat.completions.create")
mock_create.return_value = create_openai_chat_response(
'{"sentiment": "positivo", "score": 0.95, "explanation": "El texto expresa claramente satisfacción con el producto.", "keywords": ["encanta", "producto", "excelente"]}'
)
result = analyze_sentiment("Me encanta este producto, es excelente", client=None)
snapshot = load_snapshot("sentiment_output")
assert result == snapshot, (
f"El output no coincide con el snapshot.\n"
f"Esperado: {json.dumps(snapshot, indent=2)}\n"
f"Actual: {json.dumps(result, indent=2)}"
)
Snapshot con pytest-snapshot
La librería pytest-snapshot automatiza el ciclo de creación y comparación:
pip install pytest-snapshot
# conftest.py — configurar el directorio de snapshots
def pytest_configure(config):
config.addinivalue_line(
"markers", "snapshot: mark test as snapshot test"
)
# pytest.ini — configurar snapshot dir
[pytest]
snapshot_default_extension = .json
# test_with_pytest_snapshot.py
import pytest
from tests.helpers import create_openai_chat_response
@pytest.mark.snapshot
def test_sentiment_output_snapshot(snapshot, mocker):
"""Snapshot del output completo del analizador de sentimiento."""
mock_create = mocker.patch("app.sentiment.client.chat.completions.create")
mock_create.return_value = create_openai_chat_response(
'{"sentiment": "positivo", "score": 0.95, "explanation": "Texto positivo", "keywords": ["bien", "producto"]}'
)
result = analyze_sentiment("Este producto está muy bien")
# Primera vez: crea el snapshot
# Siguientes: compara con el snapshot guardado
snapshot.assert_match(result, "sentiment_positive_case")
@pytest.mark.snapshot
def test_parser_output_snapshot(snapshot, mocker):
"""Snapshot del parser para formato JSON estándar."""
raw_json = '{"sentiment": "negativo", "score": 0.15, "explanation": "Texto negativo", "keywords": ["terrible", "malo"]}'
result = parse_sentiment_response(raw_json)
snapshot.assert_match(result, "parser_standard_json")
# Primera ejecución (crea snapshots):
pytest -m snapshot --snapshot-update
# Ejecuciones siguientes (compara con snapshots):
pytest -m snapshot
# Si el test falla, ver el diff y decidir:
# Cambio intencional → pytest -m snapshot --snapshot-update
# Bug → arregla el código
Snapshot con syrupy (la librería más popular)
syrupy es el plugin de snapshots más moderno para pytest, con mejores diffs y formatos:
pip install syrupy
# test_with_syrupy.py
from syrupy.assertion import SnapshotAssertion
def test_sentiment_syrupy(snapshot: SnapshotAssertion, mocker):
"""Snapshot con syrupy — mejor diff y formato."""
mock_create = mocker.patch("app.sentiment.client.chat.completions.create")
mock_create.return_value = create_openai_chat_response(
'{"sentiment": "neutral", "score": 0.5, "explanation": "Texto neutral", "keywords": ["normal"]}'
)
result = analyze_sentiment("Texto sin sentimiento particular")
assert result == snapshot # syrupy intercepta esta assertion
def test_parser_multiple_formats_syrupy(snapshot: SnapshotAssertion):
"""Snapshot para múltiples formatos de input."""
test_cases = [
'{"sentiment": "positivo", "score": 0.9}',
'```json\n{"sentiment": "positivo", "score": 0.9}\n```',
'El análisis: {"sentiment": "positivo", "score": 0.9}'
]
results = [parse_sentiment_response(tc) for tc in test_cases]
assert results == snapshot
Workflow correcto de snapshot testing
Este es el workflow que debes seguir — nunca lo saltes:
Cuando el snapshot falla
# 1. Ejecuta el test
pytest tests/test_sentiment.py::test_sentiment_snapshot -v
# Output del fallo:
# FAILED tests/test_sentiment.py::test_sentiment_snapshot
# AssertionError: snapshot does not match
# --- snapshot
# +++ actual
# @@ -3,4 +3,4 @@
# "sentiment": "positivo",
# -"score": 0.95,
# +"score": 0.9500000000000001, ← Diferencia por float precision
# "explanation": "..."
Analizar el diff
# Diferencia intencionada: mejoraste el prompt
# ANTES: {"summary": "Resumen corto"}
# AHORA: {"summary": "Resumen corto", "language": "es"} ← Añadiste un campo
# ¿Es intencional? Sí → actualiza el snapshot
# pytest --snapshot-update
# Diferencia accidental: refactorizaste y cambiaste un key por error
# ANTES: {"sentiment_label": "positivo"}
# AHORA: {"sentiment": "positivo"} ← Renombraste sin querer en el parser
# ¿Es intencional? No → arregla el código
Anti-patrón: actualizar sin revisar
# ❌ NUNCA hagas esto sin revisar:
pytest --snapshot-update # Actualiza TODOS los snapshots sin revisar el diff
# ✅ Hazlo test por test:
pytest tests/test_sentiment.py::test_specific_snapshot --snapshot-update
# Luego revisa git diff tests/__snapshots__/ para ver qué cambió exactamente
Snapshot con normalización
Algunos outputs tienen campos que varían naturalmente y no deben incluirse en el snapshot:
import pytest
from typing import Any
def normalize_for_snapshot(result: dict, exclude_keys: list[str] = None) -> dict:
"""
Normaliza un resultado para snapshot:
- Excluye campos que varían (timestamps, IDs)
- Ordena listas para comparación consistente
- Redondea floats para evitar problemas de precisión
"""
if exclude_keys is None:
exclude_keys = ["timestamp", "id", "created_at", "updated_at", "request_id"]
normalized = {}
for key, value in result.items():
if key in exclude_keys:
continue
if isinstance(value, float):
normalized[key] = round(value, 4) # 4 decimales para consistency
elif isinstance(value, list):
normalized[key] = sorted(value) if all(isinstance(x, str) for x in value) else value
else:
normalized[key] = value
return normalized
def test_with_normalization(snapshot, mocker):
"""Snapshot con normalización de campos variables."""
mock_create = mocker.patch("app.sentiment.client.chat.completions.create")
mock_create.return_value = create_openai_chat_response(
'{"sentiment": "positivo", "score": 0.9500000000000001, "explanation": "Positivo", "keywords": ["bien", "excelente"], "timestamp": "2024-01-15T10:30:00", "request_id": "abc-123"}'
)
result = analyze_sentiment("texto")
# Normalizar antes del snapshot
normalized = normalize_for_snapshot(result, exclude_keys=["timestamp", "request_id"])
assert normalized == snapshot
# El snapshot no incluye timestamp ni request_id — evita fallos por variación
Snapshot para outputs complejos
Cuando el output es complejo (objetos anidados, listas largas), el snapshot es más valioso que assertions manuales:
# Output complejo de un pipeline de análisis de documento
COMPLEX_OUTPUT = {
"document_analysis": {
"language": "es",
"word_count": 1250,
"readability_score": 0.75,
"sections": [
{
"title": "Introducción",
"sentiment": "neutral",
"key_concepts": ["Python", "API", "REST"],
"importance": 0.8
},
{
"title": "Conclusión",
"sentiment": "positivo",
"key_concepts": ["resultados", "mejoras"],
"importance": 0.9
}
],
"overall_sentiment": {
"label": "positivo",
"score": 0.72,
"breakdown": {
"positivo": 0.72,
"negativo": 0.05,
"neutral": 0.23
}
}
}
}
def test_document_analysis_snapshot(snapshot, mocker):
"""
Para outputs complejos, snapshot es más eficiente que
escribir 20 assertions manuales.
"""
mock_create = mocker.patch("app.analyzer.client.chat.completions.create")
mock_create.return_value = create_openai_chat_response(json.dumps(COMPLEX_OUTPUT))
result = analyze_document("Documento de prueba largo...")
# Un snapshot captura todo — cualquier cambio se detecta
assert normalize_for_snapshot(result) == snapshot
# Complementar con assertions sobre lo más crítico:
assert result["document_analysis"]["overall_sentiment"]["label"] in ["positivo", "negativo", "neutral"]
assert 0 <= result["document_analysis"]["overall_sentiment"]["score"] <= 1
Snapshot por sección: estrategia para outputs muy grandes
Cuando el output es muy grande, un solo snapshot dificulta leer el diff:
def test_large_output_snapshot_by_section(snapshot, mocker):
"""
Para outputs muy grandes: snapshot por sección.
Así el diff es legible cuando una sección cambia.
"""
mock_create = mocker.patch("app.analyzer.client.chat.completions.create")
mock_create.return_value = create_openai_chat_response(json.dumps(COMPLEX_OUTPUT))
result = analyze_document("Documento...")
analysis = result["document_analysis"]
# Snapshot por sección
assert analysis["sections"] == snapshot(name="sections")
assert analysis["overall_sentiment"] == snapshot(name="overall_sentiment")
# Metadata básica con assertions directas (más claro que snapshot)
assert analysis["language"] == "es"
assert isinstance(analysis["word_count"], int)
Cuándo usar snapshot vs assert manual
| Situación | Recomendación | Razón |
|---|---|---|
| Output con 2-3 keys simples | Assert manual | Más claro, fácil de entender |
| Output con 5-10 keys y valores esperados conocidos | Assert manual | Control explícito sobre lo que verificas |
| Output con 10+ keys o estructura anidada profunda | Snapshot | Evita 20+ assertions, diff claro |
| Output que incluye listas largas | Snapshot (normalizado) | Comparación automática |
| Output con campos variables (timestamp, ID) | Snapshot + normalización | Excluir los campos variables |
| Prompts que cambian frecuentemente | Cuidado con snapshot | Muchos false positives |
| Datos sensibles o PII | NO snapshot | Los datos quedan en el repo |
Snapshot testing en CI/CD
# .github/workflows/tests.yml
jobs:
test:
steps:
- name: Run unit tests (including snapshots)
run: pytest -m "unit or snapshot" --tb=short
# ❌ NUNCA en CI:
# run: pytest --snapshot-update
# Si los snapshots fallan en CI, es un fallo real — no actualices automáticamente
Regla para CI: Los snapshots NUNCA se actualizan automáticamente en CI. Si un snapshot falla en CI, significa que el output cambió — que puede ser un bug. El developer debe revisar el diff localmente y decidir si actualizar.
Comparación: pytest-snapshot vs syrupy vs archivo JSON manual
| Característica | Archivo JSON manual | pytest-snapshot | syrupy |
|---|---|---|---|
| Instalación | Ninguna (stdlib) | pip install pytest-snapshot | pip install syrupy |
| Actualización automática | Manual | --snapshot-update | --snapshot-update |
| Formato del snapshot | JSON (manual) | Configurable | Amber (.ambr) |
| Calidad del diff | Básico (tú defines) | Básico | Excelente |
| Popularidad | Baja | Media | Alta (recomendado) |
| Flexibilidad | Alta (control total) | Media | Alta |
Recomendación: Para proyectos nuevos, usa syrupy. Para proyectos existentes con archivos JSON de referencia, el enfoque manual funciona bien.
Ejercicios
Ejercicio 1: Crear tu primer snapshot
Para la siguiente función, crea un test de snapshot usando el enfoque de archivo JSON manual:
def format_sentiment_result(raw: dict) -> dict:
return {
"label": raw["sentiment"].upper(),
"confidence": round(raw["score"], 2),
"summary": f"Sentimiento: {raw['sentiment']} ({raw['score']:.0%})"
}
Ver solución
# tests/snapshots/formatted_sentiment.json
{
"label": "POSITIVO",
"confidence": 0.95,
"summary": "Sentimiento: positivo (95%)"
}
# test_format.py
import json
from pathlib import Path
def test_format_sentiment_snapshot():
input_data = {"sentiment": "positivo", "score": 0.9500}
result = format_sentiment_result(input_data)
snapshot_path = Path("tests/snapshots/formatted_sentiment.json")
if not snapshot_path.exists():
# Primera vez: crear el snapshot
snapshot_path.parent.mkdir(exist_ok=True)
with open(snapshot_path, "w") as f:
json.dump(result, f, indent=2, ensure_ascii=False)
pytest.skip("Snapshot creado — corre el test de nuevo para verificar")
with open(snapshot_path) as f:
expected = json.load(f)
assert result == expected, f"Diff:\nEsperado: {expected}\nActual: {result}"
Ejercicio 2: Normalizar el snapshot
El output de tu función incluye un request_id único y un timestamp. Escribe la función de normalización y el test de snapshot:
Ver solución
def normalize_output(result: dict) -> dict:
"""Normaliza para snapshot: excluye campos variables."""
volatile_fields = {"request_id", "timestamp", "created_at", "processing_time_ms"}
return {k: v for k, v in result.items() if k not in volatile_fields}
def test_analysis_with_volatile_fields(snapshot, mocker):
mock_create = mocker.patch("app.analyzer.client.chat.completions.create")
mock_create.return_value = create_openai_chat_response(
'{"sentiment": "positivo", "score": 0.9, "request_id": "abc-123", "timestamp": "2024-01-15"}'
)
result = analyze("texto")
# Normalizar antes de comparar
assert normalize_output(result) == snapshot
# Verificar los campos variables por separado (sin snapshot)
assert "request_id" in result # Debe existir aunque no sea en snapshot
assert "timestamp" in result
Ejercicio 3: Snapshot que falla — identificar si es bug o cambio intencional
Analiza estos dos escenarios y decide: ¿actualizar el snapshot o arreglar el código?
Escenario A:
- {"sentiment": "positivo", "score": 0.9, "explanation": "Texto alegre"}
+ {"sentiment": "positivo", "score": 0.9, "explanation": "Texto alegre", "language": "es"}
Escenario B:
- {"sentiment": "positivo", "score": 0.9}
+ {"setiment": "positivo", "scor": 0.9}
Ver solución
Escenario A → Actualizar snapshot
El campo "language" fue añadido como mejora al prompt — ahora el output es más rico. Este es un cambio intencional. Acciones:
- Verificar que el cambio es intencional (revisar el commit del prompt)
pytest --snapshot-updatepara actualizar el snapshot- Actualizar también el modelo Pydantic para incluir el campo
language - Revisar el contrato del prompt
Escenario B → Bug — arreglar el código
"setiment" y "scor" son typos en los key names del parser (faltó una letra). Un refactor del parser introdujo un bug. Acciones:
- NO actualizar el snapshot
- Encontrar el cambio en el parser que introdujo los typos
- Arreglar el parser
- Correr el test de nuevo — debe pasar
Ejercicio 4: ¿Snapshot o assert manual?
Para cada caso, decide si usar snapshot o assertions manuales:
- Output:
{"ok": True, "count": 5} - Output: Una lista de 50 recomendaciones con 8 campos cada una
- Output:
{"categories": ["tech", "science"], "confidence": 0.87} - Output: Un documento completo parseado con secciones, subsecciones, y metadata
Ver guía
- Assert manual: Solo 2 keys simples.
assert result["ok"] is True; assert result["count"] == 5 - Snapshot: 50 items × 8 campos = 400 valores. Snapshot captura todo, diff mostrará cambios específicos.
- Assert manual: 2 fields conocidos.
assert result["categories"] == ["tech", "science"]; assert 0 <= result["confidence"] <= 1 - Snapshot por sección: El output completo es grande. Snapshot para cada sección separada + assertions sobre fields críticos.
Ejercicio 5: Workflow completo
Describe el workflow completo que seguirías cuando un snapshot falla en CI después de que otro developer hizo un cambio en el parser:
Ver guía
Workflow:
-
Revisar el CI: Ver el diff del snapshot en el log de CI.
-
Reproducir localmente:
git pull # Obtener el cambio del parser pytest tests/test_snapshots.py -v # Reproducir el fallo -
Analizar el diff:
# El output del test muestra el diff # Ej: "score" cambia de 0.9 a "0.90" (string en lugar de float) -
Investigar la causa:
- Revisar el
git diffdel parser - ¿El cambio fue intencional?
- ¿El parser ahora retorna strings en lugar de floats?
- Revisar el
-
Decidir:
- Si es bug: arreglar el parser, no el snapshot
- Si es intencional: discutir con el developer, luego actualizar snapshot
-
Si es bug — arreglar:
# Arreglar el parser para que retorne float pytest tests/test_snapshots.py # Verificar que pasa git commit -m "fix: parser returns float for score field" -
Si es intencional — actualizar:
pytest tests/test_snapshots.py --snapshot-update # Revisar git diff tests/__snapshots__/ git commit -m "update: snapshot reflects new score format (string)"
Resumen
- Snapshot captura el output exacto la primera vez y detecta cualquier cambio en ejecuciones futuras
- Siempre revisar el diff antes de
--snapshot-update— nunca actualizar ciegamente - Con mocks determinísticos, el snapshot es estable porque el mock siempre devuelve lo mismo
- Normalizar campos variables (timestamp, ID) antes del snapshot para evitar fallos falsos
- Para CI: nunca
--snapshot-updateautomático — si falla, es señal de revisión - Snapshot vs assert manual: snapshot para outputs complejos; assert para estructuras simples
Recursos adicionales
- syrupy — Snapshot testing para pytest — La librería recomendada, excelentes diffs
- pytest-snapshot — Alternativa más simple
- Jest Snapshot Testing — La inspiración original (JavaScript), buenos conceptos
- Snapshot Testing Pros & Cons — Kent C. Dodds — Cuándo y cuándo no usar snapshots
- Approval Tests / Golden Master Testing — Concepto relacionado, con diferente interfaz
- Python difflib — Para implementar diffs manuales si lo necesitas
- Testing file I/O en pytest — Para manejar archivos temporales en tests