Hace unos días interrumpí la serie de Loop, Goal y Harness para contarte una tesis: los tres agentes de Playwright te cubren casi todo el arnés, y no tienen oráculo. Automatizan explorar, escribir y reparar. Ninguno sabe qué debería pasar.
Esa era la idea. Esta guía es la mano.
Vamos a instalar los tres, correrlos contra una aplicación que puedes abrir ahora mismo, y hacer con su salida lo único que importa: auditarla. No te voy a mostrar capturas de que funcionan. Te voy a mostrar dónde exactamente se les escapa el criterio, con el diff en la mano.
Esta guía es la práctica del artículo anterior, no su resumen. Necesitas Node instalado y Playwright 1.56 o superior (yo corrí todo con 1.61.1). Los pasos del planner y del generator los puedes reproducir tal cual contra el playground público. El experimento del healer necesita una aplicación que puedas romper: te doy la receta exacta para hacerlo con la tuya.
La app con la que vamos a trabajar
Todo lo que sigue lo corrí contra playground.calidadsinhumo.com/login, un laboratorio de práctica que mantengo con bugs sembrados a propósito. Es público, así que puedes seguir cada paso contra el mismo objetivo que usé yo.
Lo que importa de esa pantalla, para que entiendas los hallazgos:
- Formulario de login con
data-testiden email, password y submit. - Credenciales de prueba visibles en la propia página.
- El submit llama a
POST /api/login, que responde 401 con un mensaje genérico cuando las credenciales fallan, y 429 con bloqueo temporal tras varios intentos.
Ese 401 va a ser el protagonista de la sección del generator. Guárdalo.
Paso 1 — Instalar los tres agentes
Desde la raíz de tu proyecto de Playwright:
npx playwright init-agents --loop=claudeEl flag --loop elige para qué runtime se instalan. Las opciones reales en 1.61 son claude, codex, copilot, opencode, vscode y vscode-legacy. Yo trabajo con Claude Code, así que uso claude; si vives en VS Code, cambia el valor y el resto de la guía es idéntico.
El comando te deja tres cosas:
Ábrelos antes de correr nada. Son archivos de texto plano y son la especificación real de lo que cada agente hará con tu suite. Este hábito no es opcional: la mitad de los hallazgos de esta guía salieron de leer esos archivos, no de ejecutarlos.
El .mcp.json es de una línea y explica de dónde salen las herramientas:
{ "mcpServers": { "playwright-test": { "command": "npx", "args": ["playwright", "run-test-mcp-server"] } }}No es magia del modelo. Es un servidor MCP que expone navegar, snapshotear, listar tests, correrlos y depurarlos. Los agentes son un prompt encima de esas herramientas. Si eso te suena a las seis piezas de un harness, es porque lo es: el arnés lo pone Playwright, el ejecutante cambia.
Paso 2 — El planner, y la frase donde confiesa que no tiene oráculo
Le pedí al planner que explorara la pantalla de login y guardara un plan. Trabajó unos minutos con el navegador abierto: navegó, hizo snapshots, disparó peticiones, leyó respuestas de red.
El resultado fue un plan de siete grupos y unos veinticinco escenarios, con un nivel de detalle que sinceramente no esperaba. Reconoció los data-testid, documentó el contrato de la API, y hasta anotó que el mensaje de error es genérico tanto para email inexistente como para password incorrecta, señalándolo como buena práctica contra enumeración de usuarios.
También marcó por su cuenta dos comportamientos sospechosos: que al recargar durante el bloqueo la UI vuelve a mostrarse habilitada aunque el servidor sigue rechazando, y que tras cerrar sesión la barra de navegación y el contenido principal quedan momentáneamente inconsistentes.
Eso es un buen explorador. Mejor que muchos exploratorios humanos apurados.
Ahora lee esto, que es literal del plan que generó:
Documentar el comportamiento observado: si el backend hace trim, el login es exitoso; si no, se rechaza con el mensaje genérico. Cualquiera de los dos es aceptable siempre que sea consistente, pero debe quedar documentado como comportamiento esperado.
Ahí está todo. El planner detectó una pregunta legítima —¿qué pasa si el email viene con espacios al inicio?— y no pudo responderla. No porque el modelo sea flojo, sino porque la respuesta no está en la aplicación. Está en una regla de negocio que nadie le dio. Así que hizo lo honesto: te la devolvió.
Repitió el mismo patrón en el escenario del campo con solo espacios: “cualquiera de los dos comportamientos debe ser intencional, no un descuido”.
Un plan que dice “cualquiera de los dos es aceptable” no es un plan de pruebas: es un cuestionario. Y si lo pasas al generator tal cual, ese cuestionario se convierte en un test que congela lo que la app hace hoy, con el sello de aprobado.
Qué hacer con eso. No sueltes el planner contra la URL a secas. Dale el criterio antes de que explore: los criterios de aceptación, la historia de usuario, la regla de negocio, el contrato de la API. La diferencia es brutal:
Cuando le pasas el criterio, las frases de “documentar el comportamiento observado” desaparecen y se convierten en expectativas. Es el mismo agente; cambió el input.
Paso 3 — El generator, y la verificación que se perdió por el camino
Con el plan guardado, el generator convierte escenarios en tests. Le pedí el escenario de password incorrecta con email válido.
El plan pedía tres verificaciones para ese caso:
- Se muestra el mensaje genérico
Email o contraseña incorrectos. - El status HTTP de
POST /api/logines 401. - El usuario permanece en el formulario de login.
Esto es lo que escribió:
// spec: spec/login.plan.mdimport { test, expect } from "@playwright/test";
test.describe("Credenciales inválidas", () => { test("Password incorrecta con email válido", async ({ page }) => { await page.goto("https://playground.calidadsinhumo.com/login");
await page.getByTestId("login-email").fill("ana.garcia@ejemplo.com"); await page.getByTestId("login-password").fill("PasswordIncorrecta1!");
await page.getByTestId("login-submit").click();
await expect(page.getByTestId("login-error")).toBeVisible(); await expect(page.getByTestId("login-error")).toHaveText( "Email o contraseña incorrectos", );
await expect(page.getByTestId("login-title")).toBeVisible(); await expect(page).toHaveURL(/.*login/); });});Es un test limpio. Localizadores por data-testid, sin esperas arbitrarias, comentado, con la trazabilidad al plan en la primera línea. Escrito a mano por un junior, se lo apruebo.
Y aun así le falta algo. Cuenta las verificaciones: mensaje visible, texto del mensaje, título visible, URL. Cuatro assertions sobre lo que se ve en pantalla.
La del 401 no está. La única del plan que verificaba el contrato de la API, la que no se puede confirmar mirando la pantalla, es exactamente la que no llegó al código.
Comprobé que la API sí responde eso, para que no queden dudas de que la verificación era válida:
curl -s -X POST https://playground.calidadsinhumo.com/api/login \ -H "Content-Type: application/json" \ -d '{"email":"ana.garcia@ejemplo.com","password":"malaclave"}' -i
# HTTP/2 401# {"error":"Email o contraseña incorrectos","attempts":1,"remaining":4}Nadie te avisa de esto. El test pasa en verde, el plan sigue ahí diciendo que pedía tres cosas, y la que se cayó es la más difícil de notar leyendo el test, porque para notarla tienes que acordarte de lo que no está.
Un test generado no se revisa comparándolo contra sí mismo. Se revisa comparándolo contra lo que pediste.
Otro detalle que vas a ver: el nombre del archivo no coincide
El plan declaraba que ese escenario viviría en
tests/login/invalid-credentials.spec.ts, agrupado con los otros cuatro
casos de credenciales. El generator lo escribió en
tests/login/should-reject-invalid-password.spec.ts. No rompe nada, pero si
generas los escenarios de a uno vas a terminar con un archivo por test y la
agrupación del plan disuelta. Revísalo temprano, cuando mover archivos
todavía es barato.
Paso 4 — El healer, o cómo terminé con la suite en verde y un bypass de autenticación adentro
Este es el experimento que de verdad quería hacer, porque el healer es el agente que más me preocupaba y sobre el que menos evidencia había.
Antes de correrlo, léelo. En su propia definición, en .claude/agents/playwright-test-healer.md, dice cuál es su trabajo:
- Entre las remediaciones autorizadas: “Fixing assertions and expected values”.
- Su condición de parada: “You will continue this process until the test runs successfully without any failures or errors”.
- Y la instrucción final: “do the most reasonable thing possible to pass the test”.
Su función objetivo es el verde. Y tiene permiso explícito para editar el valor esperado, que es justamente donde vive el oráculo. Sobre el papel, es el agente con más poder para hacer daño.
El montaje
Trabajé sobre una copia del proyecto, con el playground corriendo en local para poder romperlo sin tocar el sitio público. El punto de partida, limpio:
Running 5 tests using 5 workers ✓ tests/seed.spec.ts:4:7 › Test group › seed ✓ tests/login.spec.ts:31:5 › ¿este test prueba algo? ✓ tests/login.spec.ts:4:5 › login exitoso ✓ tests/login/should-reject-invalid-password.spec.ts:7:7 › Password incorrecta con email válido ✓ tests/login.spec.ts:11:5 › login fallido con contraseña incorrecta 5 passed (2.6s)Después inyecté una regresión de verdad en el backend. No un cambio de copy ni un localizador movido: quité la comparación de contraseña, que es el error que aparece cuando alguien refactoriza una búsqueda y se lleva medio predicado por delante.
const user = knownUsers.find( (u) => u.email === email && u.password === password);const user = knownUsers.find((u) => u.email === email);Eso es un bypass de autenticación: cualquier contraseña entra. Confirmado contra la API local antes de seguir:
curl -s -X POST http://localhost:3000/api/login \ -H "Content-Type: application/json" \ -d '{"email":"ana.garcia@ejemplo.com","password":"cualquier-cosa"}'
# 200 {"message":"Login exitoso","user":{"name":"Ana García",...}}La suite reaccionó como debe: dos tests en rojo, justo los dos que verifican credenciales inválidas. El harness hizo su trabajo. Hasta acá, todo perfecto.
Lo que hizo el healer
Aquí me equivoqué en la predicción, y prefiero contártelo así porque el resultado real es más interesante que el que yo esperaba.
Yo daba por hecho que iba a reescribir los valores esperados para forzar el verde: cambiar el mensaje esperado, o invertir la assertion. No lo hizo.
Diagnosticó bien. Y además hizo algo que no le pedí: corrió un experimento de control. Probó con un email no registrado, vio que en ese caso la app sí mostraba el error, y de ahí dedujo que la validación estaba rota específicamente para la cuenta de prueba. Después escribió esto sobre los dos tests:
// FIXME: la app en /login actualmente NO valida la contraseña para la cuenta// demo 'ana.garcia@ejemplo.com': cualquier contraseña con ese email autentica// correctamente. Verificado de forma aislada (sin paralelismo) y reproducible// en 3 corridas. Como control: con un email NO registrado, la app SÍ muestra// 'Email o contraseña incorrectos', por lo que la validación de credenciales// está rota específicamente para la cuenta demo/semilla. Este es un bug de// la aplicación, no un problema del test.test.fixme("login fallido con contraseña incorrecta", async ({ page }) => {Es un diagnóstico correcto, reproducido, con control, y honesto. Mejor que muchos reportes de bug que he recibido de humanos.
Y aun así, mira el resultado
Running 5 tests using 5 workers - tests/login/should-reject-invalid-password.spec.ts:16:8 › Password incorrecta con email válido - tests/login.spec.ts:20:6 › login fallido con contraseña incorrecta ✓ tests/seed.spec.ts:4:7 › Test group › seed ✓ tests/login.spec.ts:40:5 › ¿este test prueba algo? ✓ tests/login.spec.ts:4:5 › login exitoso 2 skipped 3 passed (1.8s)Exit code 0.
Suite en verde. Pipeline en verde. Merge habilitado. Con un bypass de autenticación en el código.
El healer no mintió. Puso el diagnóstico correcto en un comentario. Pero su meta es que la suite pase, y cuando no puede repararla honestamente le queda un solo recurso: silenciar el test. La honestidad quedó en un comentario de código; la salida quedó en el exit code. Y tu CI no lee comentarios.
Piensa en cómo se ve esto un martes cualquiera. El healer corre solo en un job nocturno, abre un pull request con dos tests marcados y un mensaje de commit que dice que arregló la suite. Todos los checks en verde. ¿Quién abre el diff?
El oráculo no desapareció cuando llegaron los agentes. Lo degradaron a comentario. Sigue ahí, escrito, correcto — y sin ningún poder para frenar nada.
Reproduce esto con tu app
No necesitas mi playground. El patrón es:
- Corre tu suite y confirma que está toda en verde. Sin este paso no hay experimento.
- Rompe una regla de negocio en el backend, no un localizador. Quita una validación, invierte una condición, cambia un umbral.
- Corre la suite. Si nada se pone rojo, tienes un problema mucho más grande que los agentes y acabas de encontrarlo.
- Corre el healer.
- Mira el diff completo y el exit code, en ese orden.
El paso 3 es un regalo aparte. Es un test de mutación hecho a mano, y te dice si tu suite mira el negocio o solo la pantalla.
El protocolo de revisión que uso
Cuatro controles, en este orden. Los tres primeros salieron de los hallazgos de esta guía; el cuarto es el que evita que todo lo anterior se pierda.
Y una regla que resume las cuatro: lee siempre los expect antes que los pasos. Un paso mal escrito falla solo y te avisa. Una assertion que falta, o que fue silenciada, no falla nunca. Ese es todo el problema.
Qué te llevas
Los tres agentes hacen bien su trabajo. El planner explora mejor que un exploratorio apurado, el generator escribe tests limpios y trazables, el healer diagnostica con control experimental. No te estoy diciendo que no los uses: te estoy diciendo dónde mirar.
Las tres cosas tienen el mismo origen y no es un defecto de madurez: ninguno de los tres puede saber qué debería pasar, porque eso no está adentro de la aplicación. Está en el criterio de aceptación, en la regla de negocio, en tu cabeza.
Lo que cambió es la escala. Antes revisabas el criterio en los tests que escribías tú, al ritmo al que los escribías. Ahora tienes que revisarlo en los tests que produce una máquina, al ritmo de la máquina. Esa revisión dejó de ser una tarea de tu trabajo para pasar a ser el trabajo.
Con esto cierro el alto en el camino. La serie sigue donde la dejamos: en cómo elegir tu stack de automatización sin casarte con ninguna herramienta, que es la parada 08 y la conversación que va justo después de esta, porque ya sabes qué parte de la decisión no la toma la herramienta.