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.
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.tsimport { 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.tsimport { 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);});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.ymlname: e2eon: [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/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.