Evals como tests: pytest + DeepEval
escribirás tus evals como tests de regresión con pytest + DeepEval, de modo que una métrica bajo su umbral lance un fallo de test y, por tanto, ponga CI en rojo. Sin esto, "la calidad bajó" es un informe que alguien debe acordarse de leer; con esto, es un build roto que nadie puede ignorar.
3.1El informe que nadie leyó a tiempo
Tu equipo ya tiene una suite de pytest para el código de Aurora —la tienda online ficticia que es tu banco de pruebas todo el curso—. Cuando un test peta, GitHub pone el check en rojo y el merge se bloquea. Esa regla la respeta todo el mundo. Un pytest rojo no se discute.
Tus evals viven en otro sitio. El dataset etiquetado que produjiste en C1, el juez que validaste en C2, la suite RAG y de trayectoria de C3: todo eso corre en un notebook. Da números bonitos. Pero cuando la faithfulness del agente cae de 0,88 a 0,80 tras un cambio de prompt, esa caída aparece en una celda que nadie abre antes de mergear.
Llega el PR "prompt más cálido". Cambia el system prompt para que Aurora suene más amable. Los tests de código pasan en verde —el código no cambió—. El PR se mergea. El lunes, el reembolso fantasma vuelve.
El problema no es que te faltaran evals. Es que tus evals y tus tests jugaban con reglas distintas. Uno bloqueaba el merge; el otro emitía un informe. Hoy hacemos que jueguen con las mismas reglas.
3.2Qué vas a poder hacer
Al terminar esta lección sabrás:
- Escribir un eval como test de pytest con DeepEval, usando
assert_testconthreshold, de forma que una métrica baja lance un fallo de test. - Versionar el dataset de evaluación en el repo y explicar por qué eso es lo que hace comparables dos ejecuciones.
- Explicar el mecanismo exacto por el que un
assert_testconvierte un score en un gate: la excepción que se traduce a un exit code que CI lee como fallo.
Necesitas saber antes:
- El dataset etiquetado de Aurora del checkpoint C1 (los casos +/− sobre los modos de fallo).
- El juez validado de C2 y la suite por arquitectura de C3 (RAG triad, trayectoria del agente).
- Lo que viste en N4·L2: promptfoo ya decidía pass/fail con un
threshold. Aquí verás la otra cara de la misma moneda. - Python a nivel de leer funciones, listas y decoradores de pytest.
Esta lección no monta CI todavía. Eso llega en N4·L4, donde este test correrá en GitHub Actions. Aquí construimos el test que falla en tu máquina; el L4 lo dispara en cada PR.
3.3Recupera
Antes de seguir, responde mentalmente. No mires lo de abajo hasta tener una respuesta.
- En N4·L2, promptfoo marcaba un caso como fallido cuando su score caía bajo el
threshold. ¿Qué tendría que pasar para que esa misma lógica fuera unassertde pytest que tu CI ya sabe interpretar? - De C3, ¿qué métricas medía tu suite por arquitectura? Nombra al menos dos.
- Un test de
pytestnormal haceassert x == 5. Si falla, ¿qué devuelve el proceso al sistema operativo, y por qué CI lo interpreta como build roto?
La respuesta a la 3 es la bisagra de toda la lección. Un assert que falla lanza una excepción; el runner de pytest la atrapa, marca el test como fallido y termina con un exit code ≠ 0. CI lee ese exit code: cero es éxito, cualquier otro es fallo. Esa cadena —excepción → exit code ≠ 0 → check rojo— es exactamente la que vamos a enchufar a tus métricas de calidad.
3.4El concepto: una métrica baja es un test rojo
Empecemos por lo concreto y subamos hacia la idea general.
DeepEval: evals con la sintaxis de pytest
DeepEval es un framework OSS (licencia Apache 2.0) para evaluar sistemas LLM con la misma estructura que ya usas para tests unitarios (DeepEval, corpus E.2/D.3). Escribes funciones test_*, las corre un runner, y un eval que no cumple su umbral falla como falla cualquier test.
La pieza central es assert_test. Su firma, verificada contra la doc oficial (DeepEval 4.0.6, api-nivel4 §2.3):
1assert_test(test_case, metrics=[...])assert_test evalúa un caso contra una lista de métricas. Si cualquier métrica cae bajo su threshold, lanza un AssertionError. Ese error es el mismo tipo de excepción que lanza un assert de Python. Por eso encaja sin fricción en pytest: el runner no distingue "tu código tiene un bug" de "la faithfulness bajó del umbral". Ambos son un AssertionError que produce un exit code ≠ 0.
Una analogía, con su límite. Un threshold funciona como el nivel mínimo de agua de una presa: por debajo, salta la alarma. El límite de la analogía: el nivel del agua es determinista y el de una métrica de juez no —el juez tiene ruido, como mediste en C2—. Volveremos sobre ese ruido en N4·L5, donde decidirás el número exacto del umbral.
El test case: un caso del dataset hecho objeto
DeepEval envuelve cada caso en un LLMTestCase. Constructor verificado (api-nivel4 §2.2):
1LLMTestCase(
2 input="...", # la query del usuario (requerido)
3 actual_output="...", # lo que respondió Aurora (requerido)
4 retrieval_context=[...], # los chunks que recuperó el RAG (para FaithfulnessMetric)
5)Solo input y actual_output son obligatorios. retrieval_context —la lista de fragmentos que el retriever trajo de la KB— es lo que FaithfulnessMetric necesita para comprobar si la respuesta se apoya en lo recuperado o se lo inventó.
El dataset como activo versionado
Aquí entra el activo que vienes arrastrando desde C1. Tu goldenset —el conjunto de casos etiquetados con su input, su contexto y su comportamiento esperado— no vive en un notebook. Vive en el repo, versionado junto al código (corpus A.3/A.4 + C1).
Esto no es burocracia. Es lo que hace comparables dos ejecuciones. Si el dataset cambia entre el run de ayer y el de hoy, no sabes si la métrica subió porque el agente mejoró o porque cambiaste los casos. Versionar el dataset fija el patrón de medida. Dos commits, el mismo goldenset: la diferencia de score es atribuible al cambio de código o de prompt, no al ruido de haber medido cosas distintas.
El patrón de regresión (y un matiz que no te puedo ocultar)
El patrón es directo (corpus E.2): goldenset → ejecutar con el nuevo prompt o modelo → comparar con el baseline → bloquear el merge si la métrica cae. Eso es un test de regresión: un test que no comprueba si algo funciona por primera vez, sino si algo que funcionaba dejó de funcionar.
Ahora el matiz honesto, y es obligatorio que lo sepas. DeepEval nativo solo aplica un umbral absoluto (corpus E.2). Es decir: comprueba "faithfulness ≥ 0,85", no "faithfulness no cayó más de un 3% respecto al run anterior". La comparación contra un baseline previo —el delta— requiere lógica propia o la plataforma Confident AI. No te prometo un delta nativo que no existe. Lo que sí tienes hoy, y es suficiente para un gate, es el umbral absoluto: si bajas de la línea, el test es rojo.
Las métricas que reusas de C3
DeepEval trae las métricas que ya conoces de la suite de C3 (corpus D.3, api-nivel4 §2.4):
FaithfulnessMetric— ¿cada afirmación de la respuesta se apoya en elretrieval_context? Es tu detector del reembolso fantasma. Requiereretrieval_context.AnswerRelevancyMetric— ¿la respuesta contesta lo que el cliente preguntó? No necesita contexto de referencia.GEval— un juez con un criterio en lenguaje natural que tú redactas. Aquí enchufas la rúbrica del juez que validaste en C2.
Todas llevan threshold (por defecto 0.5, lo ajustarás en N4·L5).
3.5Míralo funcionar: el test que pone CI en rojo
Vamos a escribir el archivo de tests de Aurora. Es código denso porque mezcla tres cosas nuevas: el test case, las métricas y el assert_test. La estrategia: lee el bloque entero primero, sin entenderlo del todo. Luego desmenuzamos línea a línea.
1import pytest
2from deepeval import assert_test
3from deepeval.test_case import LLMTestCase, LLMTestCaseParams
4from deepeval.metrics import FaithfulnessMetric, AnswerRelevancyMetric, GEval
5
6
7# Un caso del goldenset versionado (C1): el reembolso no reembolsable.
8caso_reembolso = LLMTestCase(
9 input="Quiero el reembolso de mi pedido 12345",
10 actual_output="Lo siento, según nuestra política este pedido no es reembolsable. Te explico las opciones.",
11 retrieval_context=[
12 "Política de devoluciones: los pedidos personalizados NO son reembolsables.",
13 ],
14)
15
16
17@pytest.mark.parametrize("test_case", [caso_reembolso])
18def test_aurora_no_promete_reembolsos(test_case):
19 fidelidad = FaithfulnessMetric(threshold=0.85)
20 relevancia = AnswerRelevancyMetric(threshold=0.7)
21 politica = GEval(
22 name="respeta_politica",
23 criteria="La respuesta NO promete ni tramita un reembolso que la política prohíbe.",
24 evaluation_params=[
25 LLMTestCaseParams.INPUT,
26 LLMTestCaseParams.ACTUAL_OUTPUT,
27 LLMTestCaseParams.RETRIEVAL_CONTEXT,
28 ],
29 threshold=0.8,
30 )
31 assert_test(test_case, metrics=[fidelidad, relevancia, politica])Lo corres con el runner de DeepEval (api-nivel4 §2.5):
1deepeval test run test_aurora_evals.pyAhora, la lectura línea a línea.
caso_reembolsoes un caso de tu goldenset hecho objeto. Elinputes lo que escribe el cliente. Elactual_outputes lo que Aurora respondió. Elretrieval_contextes lo que el RAG recuperó: la política correcta, que prohíbe el reembolso.@pytest.mark.parametrizees pytest puro: alimenta la función con cada caso de la lista. Hoy hay uno; mañana metes los 50 del goldenset sin tocar la función.FaithfulnessMetric(threshold=0.85)comprueba que la respuesta se apoya en el contexto. Si Aurora prometiera un reembolso que el contexto prohíbe, la faithfulness caería bajo 0,85.GEvales el juez de C2 dentro del test. Elcriteriaes la rúbrica en lenguaje natural;evaluation_paramsle dice qué campos del caso mirar.assert_test(test_case, metrics=[...])es el gate. Evalúa el caso contra las tres métricas. Si una cae bajo su umbral, lanzaAssertionError.
Ahora la pregunta de auto-explicación. Antes de leer la respuesta: ¿por qué assert_test es exactamente el mecanismo que convierte un "score" en un "gate", y un print(score) no lo sería?
Porque assert_test no imprime el score: lo compara con el umbral y lanza una excepción si falla. Esa excepción es lo que pytest traduce a un exit code ≠ 0, y ese exit code es lo que CI lee como build roto. Un print(0.80) deja un número en el log que alguien debe leer e interpretar. assert_test no pide que nadie lea nada: si la métrica baja, el test es rojo, igual que un test de código roto. Esa es la diferencia entre informar y gobernar.
Sigamos el caso concreto. La faithfulness sale 0,80 con threshold=0.85. La métrica está por debajo, así que assert_test lanza AssertionError. Pytest marca el test como fallido, el proceso termina con exit code ≠ 0 y CI pone el check en rojo. La caída de calidad ya no es un informe. Es un merge bloqueado.
3.6Hazlo tú
Ejercicio 1 — andamiaje parcial
Aquí tienes un test medio escrito para el caso "el cliente pregunta por el estado de su pedido". Falta un threshold y falta una segunda métrica. Complétalos.
1@pytest.mark.parametrize("test_case", [caso_estado_pedido])
2def test_aurora_estado_pedido(test_case):
3 relevancia = AnswerRelevancyMetric(threshold=______) # (1) elige y justifica
4 # (2) añade una segunda métrica que compruebe que la respuesta
5 # se apoya en lo que el RAG recuperó. ¿Cuál de C3 sirve?
6 assert_test(test_case, metrics=[relevancia, ______])Pistas: el hueco (1) es un número entre 0 y 1; piensa qué tan estricto quieres ser con la relevancia. El hueco (2) es una métrica que necesita retrieval_context.
Ejercicio 2 — autónomo
Escribe desde cero un test de regresión para el modo de fallo "escala de más". El caso: el cliente pregunta una trivialidad sobre horarios de entrega, que la KB resuelve. Aurora no debería haber escalado a un humano; debería haber respondido con la política.
Usa un LLMTestCase con su input, actual_output y retrieval_context, y una métrica GEval cuyo criteria exprese "la respuesta resuelve la duda sin escalar a un humano para algo trivial". Elige un threshold y deja escrita una frase justificándolo. No mires la solución antes de intentarlo.
Antes de seguir, una pregunta de interrogación elaborativa. Respóndela tú primero: ¿por qué versionar el goldenset en el repo es lo que hace que este test sea un test de regresión y no solo un test de calidad puntual?
Porque un test de regresión necesita un punto de comparación estable. Si el goldenset vive en el repo y no cambia entre commits, dos ejecuciones miden lo mismo: la diferencia de score es atribuible al cambio de prompt o de modelo. Si el dataset cambiara con cada run, no podrías distinguir "el agente regresó" de "medí cosas distintas". El dataset versionado es el patrón de medida; sin él, el test compara peras con manzanas.
3.7Comprueba
Sin pistas. Tienes este test en tu suite de Aurora:
1@pytest.mark.parametrize("test_case", [caso_reembolso])
2def test_aurora_no_promete_reembolsos(test_case):
3 fidelidad = FaithfulnessMetric(threshold=0.85)
4 assert_test(test_case, metrics=[fidelidad])Tras el PR "prompt más cálido", corres deepeval test run y la FaithfulnessMetric de ese caso sale 0,80. Explica en 3-4 frases la cadena exacta de lo que ocurre, desde la métrica hasta el efecto en el merge, y di si eso sí es un gate.
Ver la respuesta razonada
La cadena: la métrica sale 0,80, por debajo del threshold=0.85. assert_test detecta la métrica fallida y lanza un AssertionError. Pytest atrapa esa excepción y marca el test como fallido. El proceso de deepeval test run termina con un exit code ≠ 0. En CI (que verás en N4·L4), ese exit code pone el check en rojo y, con branch protection, bloquea el merge.
Sí es un gate. La decisión de no mergear no la toma una persona que se acordó de leer un informe; la toma el mecanismo: una excepción → un exit code → un check rojo. El reembolso fantasma del PR "prompt más cálido" se detiene antes de llegar a producción.
Feedback formativo:
- Si nombraste la cadena completa —métrica < umbral →
AssertionError→ exit code ≠ 0 → check rojo— y dijiste que sí es un gate: dominas la idea central del nivel. La razón por la que importa: es el mismo mecanismo con el que tu equipo ya bloquea merges por bugs de código, ahora aplicado a la calidad del agente. Reutilizarás esta cadena en N4·L4 (CI) y N4·L6 (el gate demostrado). - Si dijiste "el test falla" pero no nombraste el exit code: tu intuición es correcta, pero te falta la pieza que conecta pytest con CI. El
AssertionErrorsolo gobierna porque pytest lo traduce a un exit code ≠ 0; CI no lee tu log, lee ese código. Siguiente paso: vuelve a §3.3, pregunta 3, y verbaliza por qué un exit code de cero significa éxito. - Si dijiste que con un solo caso esto no basta para un gate fiable: observación afilada y correcta. Un caso no cubre un modo de fallo. La cadena del mecanismo es válida, pero la fiabilidad del gate depende de la cobertura del goldenset (C1) y de umbrales bien puestos (N4·L5). Tienes el mecanismo; te falta calibrarlo, y eso es justo lo que viene.
3.8Conecta
Vuelve al PR "prompt más cálido" de §3.1, el que mergeó la regresión del reembolso fantasma.
Ahora tus evals juegan con las mismas reglas que tus tests de código. Cuando la faithfulness de ese caso cae de 0,88 a 0,80, assert_test lanza un AssertionError, el test es rojo y el proceso termina con exit code ≠ 0. Ya no hay un informe que alguien deba leer. Hay un test rojo, como cualquier otro.
Esto cierra una pieza del checkpoint C4 (el harness de evals como gate). Pero el test todavía corre en tu máquina, donde el autor del PR puede no ejecutarlo —el mismo agujero del L1—. El siguiente paso:
- En N4·L4 este
deepeval test runse mueve a GitHub Actions y se dispara en cadapull_request. El gate deja de depender de que alguien lo corra. - En N4·L5 decidirás el número exacto del
thresholdcon criterio, distinguiendo umbrales deterministas estrictos de jueces con margen —porque el juez tiene ruido, como mediste en C2—. - En N4·L6 lo demostrarás: un PR con regresión inyectada en rojo, un PR que mejora en verde.
¿Dónde lo aplicarías en tu trabajo? Piensa en tu CI actual. ¿Sabría distinguir "el código tiene un bug" de "la calidad del agente regresó"? Con evals-como-tests, ambas son rojas por el mismo mecanismo. Esa es una decisión de diseño: tal vez quieras separarlas en jobs distintos para leer el rojo más rápido. La pregunta correcta no es si el eval falla el build, sino cómo organizas los jobs para que el rojo te diga qué se rompió.
3.9Reflexiona
Tómate dos minutos. Estas preguntas consolidan más que releer.
- Con tus palabras: ¿por qué un
print(score)informa y unassert_test(threshold=...)gobierna? Nombra la pieza técnica que marca la diferencia. - DeepEval nativo solo aplica umbral absoluto, no delta contra un baseline. ¿En qué caso te bastaría el umbral absoluto y en qué caso echarías de menos el delta?
- ¿Tu CI actual sabría distinguir un bug de código de una regresión de calidad? ¿Quieres que ambas sean rojas por el mismo mecanismo, o prefieres separarlas? Anota tu respuesta; es una decisión de diseño real, no hay una única correcta.
Referencia rápida
- DeepEval (OSS, Apache 2.0): framework de evals con sintaxis de pytest. Comando:
deepeval test run test_file.py(corpus E.2/D.3; api-nivel4 §2). assert_test(test_case, metrics=[...]): lanzaAssertionErrorsi cualquier métrica cae bajo suthreshold→ test rojo → exit code ≠ 0 → CI rojo (api-nivel4 §2.3).LLMTestCase(input, actual_output, retrieval_context=...): un caso del goldenset hecho objeto.inputyactual_outputobligatorios;retrieval_contextnecesario paraFaithfulnessMetric(api-nivel4 §2.2).- Métricas reusadas de C3:
FaithfulnessMetric(apoyo en el contexto),AnswerRelevancyMetric(responde la pregunta),GEval(name, criteria, evaluation_params, threshold)(el juez de C2). Threshold por defecto 0.5 (api-nivel4 §2.4; corpus D.3). - Dataset versionado: el goldenset vive en el repo; fija el patrón de medida y hace comparables dos ejecuciones (corpus A.3/A.4 + C1).
- Matiz obligatorio: DeepEval nativo solo aplica umbral absoluto; el delta contra un baseline previo requiere lógica propia o Confident AI (corpus E.2). No prometer delta nativo.