El assert que no puedes escribir
Escribes tu primer servicio con un modelo dentro, llegas a los tests y te quedas mirando el cursor. assert respuesta == "..." no vale: la misma entrada da salidas distintas. assert "factura" in respuesta aguanta dos semanas y luego el modelo dice “recibo”. Y llamar a la API de verdad en cada pytest significa una suite lenta, cara y roja los días en que el proveedor flojea.
La salida acaba en el limbo: sin cubrir. Y es justo donde estaba el bug.
La trampa es de encuadre. Estás intentando testear el modelo, y el modelo no es tuyo. Lo tuyo es el código que lo rodea, y ese código es determinista.
Lo aleatorio es una franja estrecha
Una llamada a un LLM en producción rara vez es una llamada. Es un pipeline: montas el prompt, recuperas contexto, serializas, llamas, parseas, validas, aplicas reglas de negocio, decides si reintentar. De todo eso, solo un paso es no determinista.
Si esa franja atraviesa toda tu función, no tienes un problema de testing: tienes un problema de diseño. La primera medida no es elegir una librería de mocks, es partir la función en tres.
| Capa | Qué contiene | Cómo se testea | Corre en CI |
|---|---|---|---|
| 1. Alrededor | Construcción del prompt, recuperación, parseo, validación, reglas | Tests unitarios normales, con aserciones exactas | Sí, en cada commit |
| 2. Frontera | La llamada HTTP al proveedor | Respuestas grabadas y reproducidas, o dobles de prueba | Sí, en cada commit |
| 3. Modelo | La calidad de lo que genera | Evaluación con métricas y umbrales | No: en un job aparte |
Casi todos los fallos que he visto en producción viven en la primera fila. Un chunk que se cuela sin recortar y revienta la ventana. Un JSON con un campo de más que tumba el parser. Un reintento que duplica el mensaje del usuario. Nada de eso necesita un modelo para reproducirse, y todo eso se testea con assert de los de siempre.
Capa 1: haz que el prompt sea un valor, no un efecto
La regla práctica: la función que construye el prompt no debe llamar a nadie. Recibe datos, devuelve texto o una lista de mensajes. Así puedes afirmar cosas exactas sobre ella.
Ahí es donde compruebas lo aburrido y lo que más duele: que los fragmentos recuperados van en el orden esperado, que el texto del usuario está escapado, que el mensaje de sistema no incluye un datetime.now() que te invalida la caché de prompt en cada petición.
Capa 2: la frontera se graba, no se imagina
Aquí hay dos escuelas y conviene no mezclarlas.
Parchear el cliente del SDK (monkeypatch sobre client.messages.create) es rápido y miente bien. Sustituyes el objeto por uno que devuelve lo que tú quieras. A cambio, nunca detectas un cambio en el formato real de la respuesta ni pruebas tu manejo de errores de red.
Interceptar el HTTP es un poco más de trabajo y bastante más honesto. Los SDK de Anthropic y OpenAI en Python van sobre httpx, así que puedes montar las respuestas a nivel de transporte con respx. Tu código pasa por el parseo del SDK, por el modelo de datos y por la lógica de reintentos, igual que en producción. Grabar tráfico real con VCR.py es la otra vía, aunque su soporte para httpx es más reciente que el de requests y con clientes asíncronos conviene probarlo antes de apostar la suite entera.
import httpx
import pytest
import respx
from soporte.servicio import RespuestaIncompleta, responder
# Respuesta real capturada una vez y recortada a mano.
# Se versiona con el test: si el proveedor cambia el formato, lo verás
# al actualizarla, no en producción un domingo.
RESPUESTA_OK = {
"id": "msg_01",
"type": "message",
"role": "assistant",
"model": "claude-sonnet-5",
"content": [{"type": "text", "text": '{"intencion":"factura","urgencia":2}'}],
"stop_reason": "end_turn",
"usage": {"input_tokens": 412, "output_tokens": 23},
}
@respx.mock
def test_clasificacion_devuelve_urgencia_valida():
respx.post("https://api.anthropic.com/v1/messages").mock(
return_value=httpx.Response(200, json=RESPUESTA_OK)
)
resultado = responder("no me llega la factura de julio")
# Aserciones sobre el contrato, no sobre la prosa del modelo.
assert resultado.intencion in {"factura", "envio", "otro"}
assert 1 <= resultado.urgencia <= 5
@respx.mock
def test_reintenta_una_vez_ante_un_429():
ruta = respx.post("https://api.anthropic.com/v1/messages")
# Primera llamada: límite de tasa. Segunda: correcta.
ruta.mock(side_effect=[
httpx.Response(429, headers={"retry-after": "0"}),
httpx.Response(200, json=RESPUESTA_OK),
])
resultado = responder("no me llega la factura de julio")
assert resultado.intencion == "factura"
assert ruta.call_count == 2 # el bug clásico: reintentar sin fin, o no reintentar nunca
@respx.mock
def test_salida_truncada_no_se_da_por_buena():
truncada = RESPUESTA_OK | {
"content": [{"type": "text", "text": '{"intencion":"fact'}],
"stop_reason": "max_tokens",
}
respx.post("https://api.anthropic.com/v1/messages").mock(
return_value=httpx.Response(200, json=truncada)
)
# Un 200 con JSON a medias es un fallo silencioso habitual.
with pytest.raises(RespuestaIncompleta):
responder("no me llega la factura de julio")
El tercer test es el que suele faltar. Una respuesta cortada por max_tokens llega con código 200 y cara de éxito; si tu parser hace json.loads dentro de un try que devuelve None, el error aparece cinco capas más arriba con un mensaje que no dice nada.
Capa 3: aserciones sobre propiedades, no sobre texto
Cuando de verdad quieras ejecutar el modelo, cambia la pregunta. En lugar de “¿Ha dicho esto?”, pregunta “¿Cumple lo que tiene que cumplir?”.
Sobre una salida estructurada eso es directo: valida el esquema, comprueba rangos, verifica que las citas apuntan a documentos que existían en el contexto, mide la longitud. Son invariantes, no opiniones, y fallan de forma reproducible.
Sobre texto libre funciona lo negativo: que no aparezca el prompt del sistema, que no se filtre un identificador interno, que no responda en otro idioma. Detectar lo que nunca debe pasar es mucho más estable que describir lo que debería.
La evaluación no es un test
Y no debería bloquear un pull request.
Un test tiene una respuesta binaria y estable. Una evaluación te da un número —tasa de acierto, fidelidad al contexto, formato válido— que se mueve por causas que no controlas: una versión nueva del modelo, un cambio de temperatura, ruido del propio muestreo. Si eso corre en cada commit, en un mes el equipo ignora el rojo, y un rojo ignorado no protege nada.
Lo que funciona: la suite unitaria en cada commit, rápida y sin red; la evaluación en un job nocturno o al tocar prompts, sobre un conjunto fijo, comparando contra la ejecución anterior y avisando por umbral. Y con el número guardado en algún sitio, porque el valor de una evaluación está en la serie temporal, no en la foto.
Un aviso sobre el LLM como juez: es una herramienta razonable para puntuar a escala, pero mete un segundo modelo no determinista en tu criterio de fallo. Sirve en el job nocturno, con la versión del juez fijada. No sirve como puerta de un merge.
Por dónde empezar el lunes
Coge la función que llama al modelo y saca de ella la construcción del prompt y el parseo de la respuesta. Dos funciones puras, dos tests con aserciones exactas: media hora.
Después graba una respuesta real, guárdala junto al test y escribe tres casos con ella: el camino feliz, un 429 y una respuesta con stop_reason igual a max_tokens. Con eso ya tienes cubierto lo que más se rompe.
La evaluación viene luego, y viene sola: en cuanto tengas trazas de producción, los casos que te preocupan salen de ahí. Lo que no viene solo es la disciplina de no meter la aleatoriedad del modelo dentro de tu suite verde.