Módulo 2: Unit Testing LLM Applications
5. Testing Parsers y Output Processors
Descripción
Los parsers y output processors son la lógica más fácil y valiosa de testear en una app AI: son 100% determinísticos, no necesitan mocks, y representan la mayor parte del código que puede fallar cuando el LLM produce variaciones. Esta cápsula cubre cómo testear funciones que transforman el raw output del LLM en datos estructurados. Es el "testing aburrido" que la mayoría ignora — y donde más ROI hay.
¿Por qué parsers son tan importantes?
El LLM puede producir output "correcto" semánticamente pero con variaciones de formato que rompen tu app:
# El LLM debe retornar JSON pero a veces produce:
'{"sentiment": "positivo"}' # Formato ideal
'```json\n{"sentiment": "positivo"}\n```' # Con markdown
'El resultado es: {"sentiment": "positivo"}' # Con texto previo
'{"sentiment": "positivo",}' # Trailing comma (JSON inválido)
'{"sentiment":"positivo"}' # Sin espacios
'{"Sentiment": "Positivo"}' # Capitalización diferente
'\n\n{"sentiment": "positivo"}\n\n' # Con whitespace extra
Sin un parser robusto, cualquiera de estos formatos puede romper tu app. Con tests de parser, puedes verificar que manejas todos correctamente.
Qué es un parser en contexto AI
Un parser toma el output crudo (string) del LLM y lo convierte en datos estructurados:
# Input: string crudo del LLM (puede tener markdown, texto extra, variaciones)
# Output: estructura de datos (dict, list, Pydantic model)
def parse_sentiment_response(raw: str) -> dict:
"""
Extrae y parsea el JSON de la respuesta del LLM.
Maneja múltiples formatos que el LLM puede producir:
- JSON directo: '{"sentiment": "positivo"}'
- JSON en markdown: '```json\\n{...}\\n```'
- JSON con texto previo: 'El análisis: {...}'
"""
if not raw or not raw.strip():
raise ValueError("La respuesta del LLM está vacía")
# Intentar parsear directamente primero
try:
return json.loads(raw.strip())
except json.JSONDecodeError:
pass
# Buscar JSON en markdown code blocks
markdown_pattern = r'```(?:json)?\s*\n?(.*?)\n?```'
match = re.search(markdown_pattern, raw, re.DOTALL)
if match:
try:
return json.loads(match.group(1).strip())
except json.JSONDecodeError:
pass
# Buscar cualquier objeto JSON en el texto
json_pattern = r'\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}'
matches = re.findall(json_pattern, raw, re.DOTALL)
for match in matches:
try:
return json.loads(match)
except json.JSONDecodeError:
continue
raise ValueError(f"No se encontró JSON válido en la respuesta: {raw[:100]!r}")
Estructura de tests para parsers
La estructura óptima es parametrize con múltiples formatos de input:
import pytest
import json
# Casos de test para el parser
VALID_PARSER_CASES = [
pytest.param(
'{"sentiment": "positivo", "score": 0.9}',
{"sentiment": "positivo", "score": 0.9},
id="json_directo"
),
pytest.param(
'```json\n{"sentiment": "positivo", "score": 0.9}\n```',
{"sentiment": "positivo", "score": 0.9},
id="json_en_markdown"
),
pytest.param(
'```\n{"sentiment": "positivo", "score": 0.9}\n```',
{"sentiment": "positivo", "score": 0.9},
id="json_en_code_block_sin_lenguaje"
),
pytest.param(
'El análisis de sentimiento es: {"sentiment": "positivo", "score": 0.9}',
{"sentiment": "positivo", "score": 0.9},
id="json_con_texto_previo"
),
pytest.param(
'\n\n{"sentiment": "positivo", "score": 0.9}\n\n',
{"sentiment": "positivo", "score": 0.9},
id="json_con_whitespace"
),
pytest.param(
'{"sentiment": "positivo", "score": 0.9, "extra_field": "ignored"}',
{"sentiment": "positivo", "score": 0.9, "extra_field": "ignored"},
id="json_con_campos_extra"
),
pytest.param(
'{"sentiment": "positivo", "score": 0.9, "keywords": ["bien", "excelente"]}',
{"sentiment": "positivo", "score": 0.9, "keywords": ["bien", "excelente"]},
id="json_con_array"
),
]
@pytest.mark.parametrize("raw_input,expected", VALID_PARSER_CASES)
def test_parse_sentiment_valid_formats(raw_input, expected):
"""El parser maneja todos los formatos válidos que el LLM puede producir."""
result = parse_sentiment_response(raw_input)
assert result == expected
Tests de edge cases y errores
Los edge cases son donde más fallan los parsers en producción:
ERROR_CASES = [
pytest.param("", ValueError, id="input_vacio"),
pytest.param(" ", ValueError, id="input_solo_espacios"),
pytest.param("Texto sin JSON", ValueError, id="sin_json"),
pytest.param("No hay estructura", ValueError, id="texto_libre"),
]
@pytest.mark.parametrize("raw_input,expected_exception", ERROR_CASES)
def test_parse_sentiment_invalid_inputs(raw_input, expected_exception):
"""El parser lanza excepciones apropiadas para inputs inválidos."""
with pytest.raises(expected_exception):
parse_sentiment_response(raw_input)
def test_parse_malformed_json_raises():
"""JSON malformado lanza ValueError."""
with pytest.raises((json.JSONDecodeError, ValueError)):
parse_sentiment_response('{"sentiment": "positivo", "score": 0.9') # Sin cerrar
def test_parse_truncated_response():
"""Respuesta truncada (LLM cortado por token limit) lanza error apropiado."""
truncated = '{"sentiment": "positivo", "score": 0.9, "explanation": "El texto es muy'
with pytest.raises((json.JSONDecodeError, ValueError)):
parse_sentiment_response(truncated)
def test_parse_handles_unicode():
"""El parser maneja caracteres Unicode correctamente."""
raw = '{"sentiment": "positivo", "keywords": ["excelente", "fantástico", "müde"]}'
result = parse_sentiment_response(raw)
assert "fantástico" in result["keywords"]
assert "müde" in result["keywords"]
def test_parse_handles_nested_quotes():
"""El parser maneja comillas dentro del JSON."""
raw = '{"sentiment": "positivo", "explanation": "El producto es \\"increíble\\""}'
result = parse_sentiment_response(raw)
assert result["explanation"] == 'El producto es "increíble"'
Output processors: post-procesamiento
Un output processor toma el dict del parser y lo normaliza, valida y estructura:
# app/processors.py
def process_sentiment_output(raw_dict: dict) -> dict:
"""
Normaliza y valida el output parseado del LLM.
Garantías de output:
- sentiment: siempre uno de ["positivo", "negativo", "neutral"]
- score: siempre float entre 0.0 y 1.0
- keywords: siempre lista (puede estar vacía)
- explanation: siempre string (puede estar vacío)
"""
VALID_SENTIMENTS = {"positivo", "negativo", "neutral"}
# Normalizar sentiment: case-insensitive, con fallback
raw_sentiment = raw_dict.get("sentiment", "").strip().lower()
sentiment = raw_sentiment if raw_sentiment in VALID_SENTIMENTS else "neutral"
# Clamp score al rango [0, 1]
try:
score = float(raw_dict.get("score", 0.5))
score = max(0.0, min(1.0, score))
except (ValueError, TypeError):
score = 0.5
# Keywords: convertir a lista si es string, filtrar vacíos
raw_keywords = raw_dict.get("keywords", [])
if isinstance(raw_keywords, str):
keywords = [k.strip() for k in raw_keywords.split(",") if k.strip()]
elif isinstance(raw_keywords, list):
keywords = [str(k).strip() for k in raw_keywords if k and str(k).strip()]
else:
keywords = []
# Explanation: string con strip y truncación
explanation = str(raw_dict.get("explanation", "")).strip()[:500]
return {
"sentiment": sentiment,
"score": score,
"keywords": keywords,
"explanation": explanation
}
# Tests del output processor:
def test_process_normalizes_sentiment():
"""El processor normaliza sentiment a lowercase."""
assert process_sentiment_output({"sentiment": "POSITIVO", "score": 0.9})["sentiment"] == "positivo"
assert process_sentiment_output({"sentiment": "Negativo", "score": 0.1})["sentiment"] == "negativo"
assert process_sentiment_output({"sentiment": "NEUTRAL", "score": 0.5})["sentiment"] == "neutral"
def test_process_invalid_sentiment_defaults_to_neutral():
"""Sentimientos no reconocidos se mapean a neutral."""
assert process_sentiment_output({"sentiment": "muy_positivo", "score": 0.9})["sentiment"] == "neutral"
assert process_sentiment_output({"sentiment": "", "score": 0.5})["sentiment"] == "neutral"
assert process_sentiment_output({})["sentiment"] == "neutral"
def test_process_clamps_score():
"""Score fuera de rango se clampea a [0, 1]."""
assert process_sentiment_output({"sentiment": "positivo", "score": 1.5})["score"] == 1.0
assert process_sentiment_output({"sentiment": "negativo", "score": -0.1})["score"] == 0.0
assert process_sentiment_output({"sentiment": "neutral", "score": 999})["score"] == 1.0
def test_process_handles_missing_score():
"""Score faltante usa valor default (0.5)."""
result = process_sentiment_output({"sentiment": "positivo"})
assert result["score"] == 0.5
def test_process_handles_invalid_score_type():
"""Score con tipo inválido usa valor default."""
assert process_sentiment_output({"sentiment": "positivo", "score": "no-es-float"})["score"] == 0.5
assert process_sentiment_output({"sentiment": "positivo", "score": None})["score"] == 0.5
def test_process_normalizes_keywords():
"""Keywords se normalizan a lista limpia."""
# Lista normal
result = process_sentiment_output({"sentiment": "positivo", "score": 0.9, "keywords": ["bien", " excelente "]})
assert result["keywords"] == ["bien", "excelente"] # Whitespace removido
# String comma-separated (a veces el LLM hace esto)
result = process_sentiment_output({"sentiment": "positivo", "score": 0.9, "keywords": "bien, excelente, increíble"})
assert result["keywords"] == ["bien", "excelente", "increíble"]
# Lista vacía
result = process_sentiment_output({"sentiment": "positivo", "score": 0.9, "keywords": []})
assert result["keywords"] == []
# Faltante
result = process_sentiment_output({"sentiment": "positivo", "score": 0.9})
assert result["keywords"] == []
def test_process_truncates_long_explanation():
"""Explanation muy larga se trunca a 500 caracteres."""
long_explanation = "texto " * 200 # 1200 caracteres
result = process_sentiment_output({"sentiment": "positivo", "score": 0.9, "explanation": long_explanation})
assert len(result["explanation"]) <= 500
Testear el pipeline completo: parser + processor
El pipeline completo es: raw string → parser → processor → output final. Puedes testear cada paso aislado Y el pipeline completo:
def process_llm_response(raw_string: str) -> dict:
"""Pipeline completo: parse + process."""
parsed = parse_sentiment_response(raw_string)
return process_sentiment_output(parsed)
# Test del pipeline completo (sin mock necesario):
@pytest.mark.parametrize("raw_input,expected_sentiment,expected_score_range", [
('{"sentiment": "POSITIVO", "score": 0.9}', "positivo", (0.8, 1.0)),
('```json\n{"sentiment": "negativo", "score": 0.2}\n```', "negativo", (0.0, 0.3)),
('{"sentiment": "invalid", "score": 5.0}', "neutral", (1.0, 1.0)), # Score clampeado
('El análisis: {"sentiment": "neutral", "score": 0.5}', "neutral", (0.4, 0.6)),
])
def test_full_pipeline(raw_input, expected_sentiment, expected_score_range):
"""Pipeline completo: parser + processor para múltiples formatos."""
result = process_llm_response(raw_input)
assert result["sentiment"] == expected_sentiment
assert expected_score_range[0] <= result["score"] <= expected_score_range[1]
Usar outputs reales del LLM como test cases
La forma más efectiva de mejorar tus tests de parser: capturar outputs reales del LLM y usarlos como casos:
# En desarrollo: loguea el raw output antes de parsear
import logging
logger = logging.getLogger(__name__)
def analyze_sentiment(text: str, client) -> dict:
response = client.chat.completions.create(...)
raw = response.choices[0].message.content
# En desarrollo: loguea el raw output
logger.debug(f"Raw LLM output: {raw!r}")
return parse_and_process(raw)
# Después de capturar outputs reales, úsalos como test cases:
# tests/data/real_llm_outputs.json
[
{
"id": "real_001",
"input": "Este producto es fantástico",
"raw_output": "{\n \"sentiment\": \"positivo\",\n \"score\": 0.95,\n \"explanation\": \"El texto usa adjetivos claramente positivos.\",\n \"keywords\": [\n \"fantástico\"\n ]\n}",
"expected_sentiment": "positivo"
},
{
"id": "real_002",
"input": "El servicio fue horrible",
"raw_output": "```json\n{\"sentiment\": \"negativo\", \"score\": 0.05, \"explanation\": \"Adjetivo muy negativo.\", \"keywords\": [\"horrible\"]}\n```",
"expected_sentiment": "negativo"
}
]
# Test usando los outputs reales capturados:
import json
from pathlib import Path
def load_real_outputs():
path = Path("tests/data/real_llm_outputs.json")
with open(path) as f:
return json.load(f)
@pytest.mark.parametrize("case", load_real_outputs(), ids=lambda c: c["id"])
def test_parser_handles_real_outputs(case):
"""El parser maneja correctamente los outputs reales capturados del LLM."""
result = process_llm_response(case["raw_output"])
assert result["sentiment"] == case["expected_sentiment"]
Coverage del parser: alcanzar 100%
Los parsers son excelentes candidatos para cobertura del 100%:
# Ejecutar con coverage para el parser:
pytest tests/test_parsers.py --cov=app.parsers --cov-report=term-missing -v
# Output esperado:
# Name Stmts Miss Cover Missing
# -----------------------------------------------
# app/parsers.py 45 2 96% 38-39
# Si hay líneas no cubiertas, añade el test que las ejecuta:
# Línea 38-39: el branch de "JSON anidado con subobjetos"
def test_parse_nested_json():
"""Cubre el branch de JSON con objetos anidados."""
raw = '{"sentiment": "positivo", "metadata": {"source": "twitter", "length": 50}}'
result = parse_sentiment_response(raw)
assert result["metadata"]["source"] == "twitter"
Anti-patrones en testing de parsers
# ❌ Anti-patrón 1: Testear solo el happy path
def test_parser_bad():
result = parse_sentiment_response('{"sentiment": "positivo"}')
assert result["sentiment"] == "positivo"
# Solo prueba el formato más simple — no el markdown, no los errores
# ✅ Correcto: probar múltiples formatos
@pytest.mark.parametrize("raw,expected", VALID_PARSER_CASES)
def test_parser_good(raw, expected):
assert parse_sentiment_response(raw) == expected
# ❌ Anti-patrón 2: No testear errores
def test_processor_bad():
result = process_sentiment_output({"sentiment": "positivo", "score": 0.9})
assert result["sentiment"] == "positivo"
# No prueba score fuera de rango, sentiment inválido, fields faltantes
# ✅ Correcto: testear todos los edge cases
def test_processor_good():
assert process_sentiment_output({"sentiment": "invalid"})["sentiment"] == "neutral"
assert process_sentiment_output({"score": 1.5})["score"] == 1.0
assert process_sentiment_output({})["keywords"] == []
# ❌ Anti-patrón 3: Testear parser + LLM juntos
def test_full_integration_bad():
# Llama al LLM real para testear el parser
real_client = openai.OpenAI()
result = analyze_sentiment("texto", client=real_client)
assert "sentiment" in result
# Mezcla responsabilidades: el test puede fallar por el LLM, no por el parser
# ✅ Correcto: testear el parser con inputs directos
def test_parser_isolated():
raw_from_llm = '{"sentiment": "positivo", "score": 0.9}'
result = parse_sentiment_response(raw_from_llm)
assert result["sentiment"] == "positivo"
# Testea solo el parser — sin dependencia del LLM
Comparación: Parser vs Chain completa
| Componente | Cómo testear | Necesita mock | Velocidad | Complejidad |
|---|---|---|---|---|
| Parser (raw string → dict) | Inputs directos | No | <1ms | Baja |
| Output processor (dict → dict) | Inputs directos | No | <1ms | Baja |
| Validator (dict → dict validado) | Inputs directos | No | <1ms | Baja |
| Prompt builder (text → string) | Inputs directos | No | <1ms | Baja |
| LLM call wrapper | Mock necesario | Sí | <1ms (mock) | Media |
| Chain completa (todo junto) | Mock para LLM | Sí | <5ms (mock) | Alta |
Conclusión: Testear parser y processor aislados es más rápido, más barato, y cubre más casos que testear la chain completa.
Ejercicios
Ejercicio 1: Parser de entidades con regex
El LLM retorna texto con este formato: "Entidades: Juan García (PERSONA), 15 de enero (FECHA), Madrid (LUGAR)". Escribe el parser y 5 tests.
Ver solución
import re
from typing import Optional
def parse_entities_response(raw: str) -> list[dict]:
"""
Parsea respuestas del LLM que listan entidades.
Formato esperado: "Nombre (TIPO), Nombre2 (TIPO2)"
Retorna: [{"entity": "Nombre", "type": "TIPO"}, ...]
"""
if not raw or not raw.strip():
return []
# Remover prefijos como "Entidades: ", "Las entidades son: ", etc.
text = re.sub(r'^[^:]+:\s*', '', raw.strip())
# Patrón: texto (TIPO)
pattern = r'([^,(]+?)\s*\(([^)]+)\)'
matches = re.findall(pattern, text)
return [
{"entity": entity.strip(), "type": entity_type.strip().upper()}
for entity, entity_type in matches
if entity.strip() and entity_type.strip()
]
# Tests:
@pytest.mark.parametrize("raw,expected", [
(
"Juan García (PERSONA), 15 de enero (FECHA)",
[{"entity": "Juan García", "type": "PERSONA"}, {"entity": "15 de enero", "type": "FECHA"}]
),
(
"Entidades: Madrid (LUGAR)",
[{"entity": "Madrid", "type": "LUGAR"}]
),
(
"",
[]
),
(
"Texto sin entidades",
[]
),
(
"persona (tipo1), otra entidad (tipo2), tercera (tipo3)",
[
{"entity": "persona", "type": "TIPO1"},
{"entity": "otra entidad", "type": "TIPO2"},
{"entity": "tercera", "type": "TIPO3"}
]
),
])
def test_parse_entities(raw, expected):
assert parse_entities_response(raw) == expected
Ejercicio 2: Processor con truncado graceful
Escribe el output processor para análisis de tickets de soporte. Debe manejar:
prioritydebe ser uno de["high", "medium", "low"], default"medium"titlemáximo 100 caracteres, truncado con"..."si es más largotagslista de strings, máximo 5 elementos, vacía por default
Ver solución
def process_ticket_analysis(raw: dict) -> dict:
VALID_PRIORITIES = {"high", "medium", "low"}
# Priority con fallback
priority = str(raw.get("priority", "")).strip().lower()
priority = priority if priority in VALID_PRIORITIES else "medium"
# Title con truncación graceful
title = str(raw.get("title", "")).strip()
if len(title) > 100:
title = title[:97] + "..."
# Tags: lista de strings, máximo 5
raw_tags = raw.get("tags", [])
if isinstance(raw_tags, list):
tags = [str(t).strip() for t in raw_tags if t and str(t).strip()][:5]
else:
tags = []
return {"priority": priority, "title": title, "tags": tags}
# Tests:
def test_process_ticket_valid():
result = process_ticket_analysis({"priority": "high", "title": "Server down", "tags": ["prod", "critical"]})
assert result == {"priority": "high", "title": "Server down", "tags": ["prod", "critical"]}
def test_process_ticket_invalid_priority():
assert process_ticket_analysis({"priority": "urgent"})["priority"] == "medium"
def test_process_ticket_long_title():
long_title = "A" * 150
result = process_ticket_analysis({"title": long_title})
assert len(result["title"]) == 100
assert result["title"].endswith("...")
def test_process_ticket_too_many_tags():
result = process_ticket_analysis({"tags": ["t1", "t2", "t3", "t4", "t5", "t6"]})
assert len(result["tags"]) == 5
def test_process_ticket_empty():
result = process_ticket_analysis({})
assert result == {"priority": "medium", "title": "", "tags": []}
Ejercicio 3: Test con outputs reales capturados
Imagina que capturaste estos 3 outputs reales del LLM. Escribe los tests parametrizados:
REAL_OUTPUTS = [
{
"raw": "{\n \"sentiment\": \"positivo\",\n \"score\": 0.92,\n \"explanation\": \"Usa adjetivos positivos.\"\n}",
"expected_sentiment": "positivo",
"expected_score_range": (0.8, 1.0)
},
{
"raw": "```json\n{\"sentiment\": \"NEGATIVO\", \"score\": 0.08}\n```",
"expected_sentiment": "negativo",
"expected_score_range": (0.0, 0.2)
},
{
"raw": "El análisis: {\"sentiment\": \"neutral\", \"score\": 0.5, \"extra\": \"ignored\"}",
"expected_sentiment": "neutral",
"expected_score_range": (0.4, 0.6)
}
]
Ver solución
@pytest.mark.parametrize("case", REAL_OUTPUTS, ids=[f"real_{i}" for i in range(len(REAL_OUTPUTS))])
def test_parser_with_real_captured_outputs(case):
"""
Tests parametrizados con outputs reales del LLM.
Estos casos fueron capturados en desarrollo para asegurar
que el parser maneja los formatos reales correctamente.
"""
result = process_llm_response(case["raw"])
assert result["sentiment"] == case["expected_sentiment"]
min_score, max_score = case["expected_score_range"]
assert min_score <= result["score"] <= max_score, \
f"Score {result['score']} fuera de rango [{min_score}, {max_score}]"
Ejercicio 4: Coverage
Ejecuta coverage en el parser y explica cómo llegarías al 100%:
Ver guía
pytest tests/test_parsers.py --cov=app.parsers --cov-report=term-missing -v
Para llegar al 100%:
-
Identificar líneas sin cubrir en el reporte (columna "Missing")
-
Tipos de líneas típicamente sin cubrir:
- Branches de except (error handlers)
- Condiciones
ifcon valores edge (None, tipo inesperado) - Fallbacks de default values
-
Añadir tests para cada branch sin cubrir:
# Si línea 45 es: "if not isinstance(x, (str, bytes)): ..." # Añade: def test_parse_non_string_input(): with pytest.raises(TypeError): parse_sentiment_response(12345) -
Meta alcanzable: 95-100% de cobertura en parsers (son determinísticos y sin I/O externo)
Ejercicio 5: Testear el pipeline completo
Escribe un test que prueba el pipeline completo parse + process para un output de clasificación de tickets:
El raw output puede venir en cualquiera de estos formatos:
'{"priority": "HIGH", "title": "Server down", "tags": ["prod"]}''```json\n{"priority": "medium", "title": "Login issue"}\n```'
Ver solución
@pytest.mark.parametrize("raw,expected_priority,expected_title_contains", [
(
'{"priority": "HIGH", "title": "Server down", "tags": ["prod"]}',
"high",
"Server down"
),
(
'```json\n{"priority": "medium", "title": "Login issue"}\n```',
"medium",
"Login issue"
),
])
def test_ticket_pipeline_complete(raw, expected_priority, expected_title_contains):
"""Pipeline completo: parse JSON de ticket + process."""
# Paso 1: Parser
parsed = parse_ticket_response(raw)
assert isinstance(parsed, dict)
# Paso 2: Processor
result = process_ticket_analysis(parsed)
# Assert: pipeline completo
assert result["priority"] == expected_priority
assert expected_title_contains in result["title"]
assert isinstance(result["tags"], list)
Resumen
- Parsers y processors son 100% determinísticos — testéalos sin mocks, sin costo de API
parametrizepara múltiples formatos — el LLM produce variaciones reales de formato- Edge cases críticos: vacío, malformado, truncado, con markdown, con texto previo
- Output processors: normalización, clamping de rangos, defaults para campos faltantes
- Coverage al 100% es alcanzable para parsers — es el componente ideal para esto
- Captura outputs reales del LLM y úsalos como test cases para coverage realista
Recursos adicionales
- pytest.mark.parametrize — Para múltiples casos de input
- pytest.raises — Para testear excepciones esperadas
- Python json module — Reference del módulo JSON estándar
- Python re module — Regex para parsing de texto
- Pydantic v2 — Validators — Para validación estructurada
- pytest-cov — Para medir cobertura de los parsers
- Hypothesis — Property-based testing para parsers (se ve en Módulo 3)