INGENIERÍA

Tests end-to-end con agentes: qué dejé en manos del modelo y qué no

Migré los tests end-to-end de un CRM a un runner que escribe los pasos en lenguaje natural. Lo que más valor dio no fue el agente, y en el CRM terminé sin usarlo.

Hasta agosto, los frontends de Conecta no tenían ni un test. El backend tenía TDD desde el primer día, pero en el CRM, la app de técnicos y el resto la verificación era lint, typecheck, build y mirar. Un criterio de aceptación de UI lo comprobaba alguien a ojo durante la revisión: no se podía repetir, no corría en CI y no protegía de la siguiente regresión.

Dos meses después, el CRM tiene 94 archivos de test y cinco suites end-to-end con 20 casos que corren igual contra el servidor local que contra el entorno de pruebas. Por el camino migré esas suites de Playwright a un runner que deja escribir los pasos como objetivos en lenguaje natural, que ejecuta un agente de IA.

Este artículo cuenta cómo repartí las pruebas entre capas, qué se rompió en la migración y una conclusión que no esperaba: en el CRM, la función estrella del runner —los pasos con agente— terminó sin usarse ni una vez. El valor vino de otro sitio.

Cuatro capas, cada una para lo que solo ella ve

Lo que hace que una suite cueste más de lo que devuelve casi nunca es el framework, sino probar cada cosa en la capa equivocada. Un recorrido de navegador tarda unas cien veces más que un test de componente, y un test de componente no puede ver nada que dependa del layout. Antes de escribir el primer test fijé qué va en cada sitio:

QuéDóndePor qué ahí
Lógica pura: formateo, cálculos, mapeo de datosVitestMilisegundos y sin DOM de por medio.
Comportamiento de un componente: qué se pinta, qué pasa al hacer clic, estados de error y vacíoVitest + jsdom + Testing LibraryRápido, determinista, corre en cada PR.
El camino completo: UI → API → datos → UI, sesión y permisosE2E en un navegador realEs donde aparecen los fallos que un test con la red simulada no puede ver.
Cómo se ve: layout, foco, scroll, un popover recortadoEl componente aislado en el navegadorjsdom no tiene layout: todo mide 0×0.

La última fila es la que más se confunde. jsdom no renderiza: no hay cascada de CSS, ni alturas, ni scroll. Un test en jsdom que intenta comprobar que un anillo de foco no queda recortado pasa en verde para siempre sin comprobar nada.

Dos reglas más evitan los tests que pasan con la feature rota. Los elementos se buscan por rol y texto, nunca por clase: un test atado a .btn-primary se rompe cuando alguien toca estilos y no se rompe cuando el botón deja de ser accesible, justo al revés de lo que sirve. Y se simula solo el borde, el cliente HTTP, con respuestas que respetan el contrato real de la API. Cuando se simulan los hooks o los componentes hijos, el test acaba comprobando que los mocks se llaman entre sí.

Objetivos en lenguaje natural, verificaciones exactas

Los primeros E2E del CRM se escribieron en Playwright a finales de septiembre. Funcionaban, pero tenían el problema de siempre: cada cambio de layout rompe un selector, nadie mantiene los tests que se rompen por motivos que no son bugs, y la suite se abandona. Por eso el 8 de octubre las migré a e2e, un runner que corre sobre Playwright pero mezcla dos tipos de paso en el mismo test.

Los pasos de agente describen un objetivo —«crea un movimiento de 3 unidades y guárdalo»— y el modelo decide los clics. Cuando pasan, se graban, y en las siguientes ejecuciones se reproducen sin llamar al modelo hasta que la UI cambia. Los pasos deterministas son los de siempre: buscar una fila por su texto, esperar una respuesta de red, comparar una URL.

ts
test('AC-03 — the list shows the newly created movement', async ({ app, agent, screen, browser }) => {
  await signIn(browser)
  await app.open('/inventory/movements')

  // When: el agente decide cómo llegar. La espera de red se arma antes de la acción.
  const saved = browser.waitForResponse(/\/movements$/)
  await agent.act('create a movement of 3 units and save it')

  // Then: exacto, sin juicio del modelo. Red y UI, porque un optimistic update
  // pinta la fila aunque el POST haya devuelto 500.
  expect((await saved).status).toBe(201)
  await expect(screen.getByRole('row', { name: /3 unidades/ })).toBeVisible()
})
La plantilla de un test E2E: el agente se encarga del «cuando» y el «entonces» es una comprobación exacta sobre la red y la pantalla.

La regla que más me importaba es que el «entonces» nunca lo juzga el modelo. El runner permite verificar con una frase («comprueba que se guardó»), pero eso pone a decidir si el criterio se cumple al mismo tipo de juez que el resto del flujo intenta evitar, y además llama al modelo en cada ejecución, haya caché o no. Queda como último recurso para lo que no tiene un locator estable, como la forma de una gráfica.

La caché de grabaciones se commitea al repo, y en CI corre en modo estricto: si una grabación dejó de servir porque la UI cambió, el test falla con REPLAY_STALE en vez de volver a llamar al modelo en silencio en cada ejecución.

Lo que se rompió en la migración

La migración del CRM fue un solo PR de 13 archivos, y casi todo lo que costó no tuvo nada que ver con los agentes.

Una sesión compartida que cerraba todas las sesiones

La documentación del runner recomienda iniciar sesión una vez en un paso de preparación, guardar la sesión y restaurarla en cada test. Lo hice así, y el primer test pasaba mientras los siguientes acababan en la pantalla de login. Peor aún: también me cerraba la sesión en mi propio navegador.

La causa estaba en la API. Rota el refresh token en cada renovación, y el CRM renueva en cada carga de página. Si llega un token que ya se usó, lo trata como un robo y revoca todas las sesiones del usuario1. La sesión guardada se restauraba en cada test y otra vez en cada reintento, así que a partir del segundo se volvía a presentar un token ya rotado.

Test 1 restaura la sesión guardada y la API rota el refresh token R1 por R2. Test 2 restaura otra vez R1; la API lo ve reutilizado, lo trata como robo y revoca todas las sesiones del usuario, así que Test 2 termina en el login.Sesión guardadatest.setupTest 1navegadorTest 2navegadorAPIrota el tokenrestaura R1POST /auth/refresh · R1R2 · R1 queda usadorestaura R1 otra vezPOST /auth/refresh · R1401 · revoca todas las sesiones✓ pasaR1 ya se usó → se asume robadotermina en el login
Por qué fallaba a partir del segundo test: la sesión guardada vuelve a presentar un refresh token que la API ya rotó, y la detección de reutilización revoca la sesión de todos.

La API hacía exactamente lo que debía; el error era compartir la sesión. Ahora cada intento inicia sesión por su cuenta y obtiene su propia familia de tokens. Y uso un usuario dedicado a los E2E en vez de la cuenta de nadie: si algo vuelve a reutilizar un token, solo se cierran sus sesiones.

Un login que bloqueaba la evidencia

Lo natural era iniciar sesión rellenando el formulario. Pero en cuanto el runner escribe una contraseña en la página, deja de permitir capturas durante el resto del intento, para no filtrarla en una imagen. Y la captura es justo la evidencia que necesita el evaluador para dar por verificado un criterio.

El login de los tests va por la API: un POST /auth/login desde Node, que luego pone las cookies de sesión en el navegador. La contraseña viaja de Node a la API y nunca pasa por la página ni por el modelo. El formulario de login tiene su propio test de componente.

Arreglar la etiqueta, no el test

En la primera versión del login por formulario, buscar el campo de correo por su etiqueta no encontraba nada. La salida fácil era cambiar el selector por un id o una clase, y el test habría pasado.

Pero el test tenía razón. El htmlFor de la etiqueta apuntaba a un div que envolvía el input, no al input, así que ni el correo ni la contraseña tenían nombre accesible. Un lector de pantalla tampoco los encontraba. El arreglo fue en el componente, con un test de componente que lo fija, y la regla quedó escrita en la guía del plugin: cuando una búsqueda por etiqueta no encuentra nada, se arregla la etiqueta, no el test.

Una API que se parece a Playwright pero no es

  • Los nombres se comparan exactos. Buscar el botón «Guardar» no encuentra «Guardar cambios». Lo grave es el caso negativo: una consulta mal escrita no encuentra nada, así que comprobar que un botón no aparece pasa siempre. Ahora cada consulta se afirma primero en positivo en algún sitio.
  • No hay pestaña hasta la primera navegación. Armar una espera de red antes de abrir la primera página falla en el acto. Abrir about:blank antes no dispara peticiones y conserva las cookies.
  • Las esperas de red no aceptan una función. Solo un texto o una expresión regular. Para esperar «el listado con estos filtros y sin este otro» escribí un helper que construye la expresión con un lookahead por parámetro.

Donde el agente no puede mirar

Cada paso de agente envía al proveedor del modelo el árbol de accesibilidad de la pantalla, y a veces una captura. Y las pantallas del CRM son, casi todas, listados de clientes, leads y PQRS: nombres, teléfonos, direcciones. Son datos protegidos por la ley colombiana de protección de datos personales, y no tienen por qué salir del equipo.

La regla es simple: contra el entorno desplegado, los pasos de agente solo van en pantallas sin datos personales a la vista, como la navegación, los formularios vacíos o la configuración. En cualquier listado de clientes, el test usa locators deterministas, que no envían nada a ningún modelo. Y la caché no sirve de excusa: un paso que se reproduce desde la caché no llama al modelo, pero si la UI cambió vuelve a llamarlo sin avisar.

Al aplicar la regla al CRM, el resultado fue que ninguna de sus cinco suites tiene un solo paso de agente. Cada archivo empieza con un comentario que lo explica: solo locators deterministas, porque esas pantallas muestran clientes reales. La función por la que elegí el runner es la que no pude usar.

Aun así, la migración valió la pena, aunque por otros motivos. El mismo test corre contra el servidor local y contra el entorno de pruebas con solo cambiar una variable, así que antes de pedirle a gerencia que pruebe una feature, la suite ya la recorrió en ese entorno. El runner deja capturas y trazas de cada fallo sin pedírselo, y genera un resumen que se puede pegar en el PR. Los pasos de agente siguen disponibles para las apps sin datos personales en pantalla, y para cuando probar contra datos ficticios sea viable.

Un test que verifica es un test que puede fallar

En el flujo de Conecta, quien implementa no puede marcar un criterio como verificado; lo hace un evaluador independiente. Antes, para un criterio de UI, el evaluador abría el navegador y lo recorría a mano. Ahora, si el criterio se puede automatizar y no tiene test, el evaluador escribe el test en vez de recorrerlo: con los pasos en lenguaje natural cuesta casi lo mismo, y un recorrido a mano verifica una vez mientras que un test verifica siempre.

  • Que falle primero. Si un test pasa a la primera, se cambia a propósito el valor esperado y se comprueba que se pone en rojo. Un test que no puede fallar no verifica nada.
  • Un criterio sobre un rol se prueba con ese rol. Una sesión de administrador pasa cualquier comprobación de permisos sin demostrar nada. Cada perfil tiene su usuario, y si no está configurado el test se omite con el motivo, no se da por bueno.
  • Montar el estado de partida de verdad. Probar contra datos que ya cumplían el criterio no demuestra que el código los crea.

Lo de omitir con motivo se ve en la práctica. Un criterio del cierre mensual de inventario aparece hoy como omitido con una razón concreta: el entorno de pruebas todavía no tiene ningún cierre, porque el primero se genera el día 1. Eso es información útil. Un criterio marcado como verificado sin haberlo comprobado es una mentira que se descubre en producción.

Lo que sigue

La app de técnicos todavía no tiene E2E. Cuando los tenga, lo primero será ver qué pantallas suyas quedan libres de datos personales, porque solo ahí tienen sentido los pasos de agente. Tampoco tengo aún el dato que justificaría el runner por sí solo: si los tests escritos con objetivos se rompen menos que los escritos con locators cuando cambia el layout. Está apuntado en el documento del harness como la forma de comprobar si esa pieza sigue haciendo falta, y lo voy a medir con los PRs que toquen UI en los próximos meses.

Lo que me llevo es que la parte difícil de los E2E no es escribir los pasos, sino todo lo de alrededor: la sesión, la evidencia, la red, los permisos y qué datos puede ver quién. Un agente puede escribir los clics por mí. Decidir qué tiene que ser exacto, qué puede ver el modelo y cuándo un test en verde no prueba nada sigue siendo trabajo de ingeniería.

Notas

  1. Rotar el refresh token en cada uso y revocar toda la familia de tokens cuando uno ya usado reaparece es la práctica recomendada en las guías de seguridad de OAuth 2.0: si un token viejo vuelve, se asume que alguien lo robó. ↩
  • Testing
  • E2E
  • Playwright
  • Agentes de IA
  • Accesibilidad
  • Habeas Data

MÁS ARTÍCULOS