End-to-end con Playwright: islas hidratadas, transiciones de vista y CI
La cúspide de la pirámide: un navegador real que recorre la aplicación como un usuario. Configurar Playwright para que levante el servidor con webServer, navegar y afirmar con getByRole, y probar lo que ninguna otra herramienta alcanza —que una isla client:load reacciona tras hidratarse y que el clientRouter navega sin recarga dura entre páginas—. Por último, cablear la suite en integración continua con GitHub Actions: instalar navegadores, construir, previsualizar y publicar el reporte.
Hay verdades sobre una aplicación web que solo existen dentro de un navegador. Que un botón responde al clic después de que su isla se hidrate, que una transición de vista cambia la página sin recargarla, que un formulario progresivo funciona con JavaScript y sin él: nada de eso vive en el HTML de servidor que probamos con la Container API, porque todo eso nace cuando el cliente despierta. Playwright ocupa por eso la cúspide de la pirámide —el instrumento que arranca un navegador de verdad, carga tu sitio y lo recorre como lo haría una persona—. Es la prueba más cara y más frágil, y también la única capaz de afirmar que la experiencia completa, hidratación incluida, se comporta como prometiste.
- Configurar
playwrightpara que levante el servidor conwebServery unabaseURL. - Navegar y afirmar como un usuario con
page.goto,getByRoleyexpect. - Probar una isla
client:loadque reacciona tras hidratarse y una transición delclientRouter. - Cablear la suite en integración continua con GitHub Actions y publicar el reporte.
Levantar el servidor: la config de Playwright
Un test de extremo a extremo necesita una aplicación viva a la que apuntar. Playwright puede arrancarla él mismo mediante el bloque webServer, que ejecuta el comando de tu proyecto, espera a que la URL responda y comparte ese servidor entre todos los tests. Para Astro, lo fiel a producción es construir y previsualizar, no correr el servidor de desarrollo.
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
use: { baseURL: 'http://localhost:4321' },
webServer: {
command: 'npm run build && npm run preview',
url: 'http://localhost:4321',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
});
Dos decisiones merecen atención. Previsualizar el build en lugar del dev server garantiza que pruebas el artefacto que desplegarás, con su HTML final y sus assets optimizados, no una versión de desarrollo que se comporta distinto. Y reuseExistingServer en función de process.env.CI reutiliza un servidor ya abierto en tu máquina —iteras rápido— pero fuerza uno limpio en integración continua, donde la reproducibilidad manda sobre la velocidad. La baseURL completa el cuadro: te deja escribir rutas relativas en los tests y cambiar el destino sin tocarlos.
Navegar y afirmar como un usuario
Con el servidor en pie, cada test recibe una page, la pestaña del navegador que gobiernas. La filosofía de Playwright es afirmar desde la perspectiva del usuario: no buscas nodos por su clase interna, sino por su rol accesible y su texto, lo mismo que percibe una persona o un lector de pantalla. Esa elección hace los tests legibles y resistentes al refactor del marcado.
// e2e/home.spec.ts
import { test, expect } from '@playwright/test';
test('la home muestra el titulo principal', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('heading', { level: 1 }))
.toHaveText('Astro 7');
await page.getByRole('link', { name: 'Blog' }).click();
await expect(page).toHaveURL(/\/blog/);
});
Las afirmaciones de Playwright son auto-esperantes: expect(...).toHaveText reintenta hasta que la condición se cumple o expira el plazo, de modo que no salpicas el test de esperas manuales frágiles. Consultar por getByRole en lugar de por selectores CSS es, además, una prueba de accesibilidad encubierta: si un botón no tiene rol ni nombre accesible, tu test no lo encuentra, y esa fricción te avisa de un problema real de la interfaz antes que ningún usuario.
Islas hidratadas y transiciones de vista
Aquí está el valor que justifica el coste de Playwright: probar lo que solo ocurre en el cliente. Una isla marcada con client:load se renderiza primero como HTML estático —eso ya lo cubre la Container API— pero solo reacciona cuando su JavaScript se hidrata. Un test de navegador lo comprueba interactuando: si el clic cambia el estado, la hidratación ocurrió.
test('el contador reacciona una vez hidratado', async ({ page }) => {
await page.goto('/contador');
const boton = page.getByRole('button', { name: /sumar/ });
await expect(boton).toHaveText('sumar 0');
await boton.click();
await expect(boton).toHaveText('sumar 1');
});
Las transiciones de vista son el otro comportamiento genuinamente de cliente. Cuando activas el clientRouter, la navegación entre páginas deja de ser una recarga dura y pasa a ser un intercambio suave del DOM. Probarlo consiste en detectar que no hubo recarga completa: marcas una variable en el objeto window, navegas y compruebas que la marca sobrevivió, porque una recarga dura la habría borrado.
test('el clientRouter navega sin recarga dura', async ({ page }) => {
await page.goto('/');
await page.evaluate(() => ((window as any).__vivo = true));
await page.getByRole('link', { name: 'Blog' }).click();
await expect(page).toHaveURL(/\/blog/);
const sobrevivio = await page.evaluate(() => (window as any).__vivo === true);
expect(sobrevivio).toBe(true);
});
El error clásico del principiante en e2e es dormir el test un número mágico de milisegundos con la esperanza de que para entonces la isla ya reaccione. Es una apuesta que pierdes de dos maneras: si el número es corto, el test falla de forma intermitente; si es largo, la suite se arrastra. La cura es afirmar sobre el efecto observable, no sobre el reloj. expect(boton).toHaveText('sumar 1') tras el clic ya espera por ti a que la hidratación surta efecto, reintentando hasta el plazo. Si necesitas una señal explícita de que una isla está viva, emite un atributo o un evento al hidratarse y espéralo con page.waitForSelector. Esperar por causas, nunca por tiempo.
En integración continua
Una suite de e2e que solo corre en tu máquina es media red de seguridad. Su lugar natural es la integración continua, donde protege a cada cambio antes de fusionarlo. En GitHub Actions el flujo tiene un paso que la gente olvida —instalar los navegadores con sus dependencias del sistema— y termina publicando el reporte como artefacto para inspeccionar los fallos.
# .github/workflows/e2e.yml
name: e2e
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- run: npx playwright install --with-deps
- run: npx playwright test
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
webServer
Playwright levanta tu app con build y preview y espera a la URL antes de lanzar los tests.
getByRole
Consulta por rol y nombre accesible: tests legibles, resistentes y con a11y de regalo.
Hidratacion
Un clic que cambia el estado demuestra que la isla client:load cobro vida en el cliente.
CI reproducible
npm ci y playwright install --with-deps, servidor limpio y reporte como artefacto.
El paso install --with-deps no es opcional: los runners de CI vienen sin los navegadores ni las librerías del sistema que necesitan, y omitirlo produce el fallo más desconcertante del principiante, un lanzamiento que se cae antes del primer test. Con if: always() el reporte se sube incluso cuando la suite falla —que es justo cuando más lo necesitas— y te deja revisar capturas, trazas y vídeos del error sin reproducirlo a mano.
flowchart TD CI[push o pull request] --> DEP[npm ci y playwright install with-deps] DEP --> WS[webServer build y preview] WS --> NAV[navegar hidratar y afirmar] NAV --> REP[reporte y artefactos aunque falle] style CI fill:#89b4fa,color:#11111b style WS fill:#cba6f7,color:#11111b style REP fill:#a6e3a1,color:#11111b
Toda esta serie ha predicado la misma economía: empujar cada afirmación al nivel más bajo de la pirámide donde aún sea significativa, porque los tests de arriba son lentos, frágiles y costosos de mantener. Playwright es, sin disimulo, ese nivel de arriba, y la pregunta honesta no es cómo tener muchos e2e sino cómo tener los justos. La respuesta nace de entender qué compra este nivel que ningún otro alcanza: la integración real de todas las piezas dentro del único entorno donde el usuario las encuentra, un navegador. La Container API te dijo que el HTML de servidor era correcto; Vitest, que tu lógica lo era; los tests de endpoints, que tu frontera rechazaba lo hostil. Pero solo un navegador puede confirmar que la isla se hidrató de verdad, que el clientRouter navegó sin recargar, que el clic disparó la acción, que la animación no rompió el estado, que el conjunto —servidor, cliente, red, hidratación— coopera. Esa confianza es irremplazable, y por eso pagas su precio; pero pagarlo con sabiduría significa reservarlo para el puñado de recorridos críticos cuyo fallo sería un desastre —el registro, la compra, el flujo que sostiene el negocio— y no malgastarlo reprobando lo que ya cubriste barato más abajo. Un e2e que comprueba que un título dice cierto texto es un desperdicio de la herramienta más cara para hacer lo que la Container API hacía en milisegundos; un e2e que comprueba que un usuario puede completar la compra de principio a fin es justamente para lo que Playwright existe. Cerrar la pirámide con e2e no es coronarla de tests, sino colocar en la cima solo aquellas afirmaciones que, de romperse, te habrías enterado por un cliente enfadado y no por tu suite. La madurez del testing es esta mesura: mucho abajo, poco arriba, y cada nivel probando exactamente lo que solo él puede probar.
- Configura
playwright.config.tscon unwebServerque ejecute build y preview, unabaseURLyreuseExistingServersegúnprocess.env.CI. - Escribe un test que navegue a una página, afirme un encabezado con
getByRoley siga un enlace comprobando la URL de destino. - Prueba una isla
client:load: afirma su estado inicial, interactúa y verifica el cambio, demostrando que se hidrató; luego comprueba que elclientRouternavega sin recarga dura marcando una variable enwindow. - Añade un workflow de GitHub Actions con
npm ci,playwright install --with-deps, la ejecución de los tests y la subida del reporte conif: always().