Playwright: guía completa de automatización de pruebas end-to-endBuenas gente de Underc0de. En esta sección se habla bastante de automatización, así que armé una guía sobre
Playwright, que en los últimos años se convirtió en una de las herramientas más sólidas para testing end-to-end. La idea es cubrir desde qué es y cómo funciona por dentro, hasta cómo configurarlo, escribir pruebas mantenibles y meterlo en un pipeline de CI.
1. ¿Qué es Playwright?Playwright es un framework de automatización de navegadores desarrollado por Microsoft. Permite controlar
Chromium, Firefox y WebKit (el motor de Safari) con una única API, lo que significa que una misma suite de pruebas corre sobre los tres motores de renderizado principales sin reescribir nada.
Aunque su uso más común es el testing end-to-end, también sirve para scraping, automatización de tareas repetitivas en la web, generación de PDFs o capturas, y monitoreo sintético de aplicaciones en producción.
Tiene bindings oficiales para
JavaScript/TypeScript, Python, .NET y Java. En esta guía voy a usar TypeScript porque es donde el ecosistema está más maduro (el test runner propio, @playwright/test, sólo existe para JS/TS), pero los conceptos aplican igual a los demás lenguajes.
2. ¿Por qué no Selenium?La diferencia clave es
arquitectónica. Selenium se comunica con el navegador mediante el protocolo WebDriver, que funciona por HTTP: cada comando es una petición y una respuesta independientes. Eso introduce latencia y, sobre todo, deja al framework ciego respecto de lo que pasa dentro de la página entre comando y comando.
Playwright usa un
único canal WebSocket persistente hacia el navegador (basado en el Chrome DevTools Protocol y equivalentes). Esto le permite:
- Auto-waiting real: antes de interactuar con un elemento, verifica que esté visible, estable (sin animaciones), habilitado y capaz de recibir eventos. Adiós a los sleep(3) desparramados por todo el código.
- Interceptar red: mockear respuestas de APIs, simular fallos o modo offline, sin tocar el backend.
- Aislamiento por contexto: cada test corre en un BrowserContext, que es como un perfil de navegador limpio (cookies, localStorage y caché propios) pero que arranca en milisegundos, no en segundos. Esto es lo que permite paralelizar de verdad.
- Manejo nativo de iframes, pestañas, descargas, permisos y shadow DOM, que en otras herramientas suelen ser un dolor de cabeza.
Comparado con Cypress, la ventaja principal es que Playwright no corre dentro del navegador, con lo cual no sufre las limitaciones de same-origin, soporta múltiples pestañas y dominios en un mismo test, y tiene soporte real de WebKit.
3. InstalaciónLo más simple es usar el inicializador, que crea la estructura, el archivo de configuración y un workflow de CI de ejemplo:
npm init playwright@latestTe va a preguntar si querés TypeScript o JavaScript, dónde poner los tests y si querés el workflow de GitHub Actions. Si preferís hacerlo a mano:
npm install -D @playwright/test
npx playwright install --with-depsEse segundo comando es importante: Playwright
descarga sus propias builds de los navegadores en lugar de usar los que tenés instalados. Puede parecer pesado, pero es lo que garantiza que el test que pasa en tu máquina pase igual en CI. El flag --with-deps instala además las librerías de sistema que los navegadores necesitan en Linux, que es lo que suele romper dentro de los contenedores.
4. Configuración: playwright.config.tsAcá está el corazón de la herramienta. Una configuración razonable para un proyecto real:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 2 : undefined,
reporter: [['html'], ['list']],
use: {
baseURL: process.env.BASE_URL || 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
actionTimeout: 10000,
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
{ name: 'mobile', use: { ...devices['Pixel 7'] } },
],
webServer: {
command: 'npm run start',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});Repaso lo que más importa:
- fullyParallel: corre los tests de un mismo archivo en paralelo, no sólo los archivos entre sí.
- retries: reintentos sólo en CI. Ojo, esto es una red de contención para inestabilidad de infraestructura, no una excusa para dejar tests flaky. Si un test necesita 2 reintentos siempre, está roto.
- forbidOnly: hace fallar el build si alguien se olvidó un test.only en el código. Simple y salva mucho.
- trace: 'on-first-retry': mi favorito, lo explico más abajo.
- projects: cada entrada es una corrida completa de la suite con distinta configuración. Sirve para multi-navegador, para viewports móviles, o para separar tests por entorno.
- webServer: levanta tu aplicación automáticamente antes de correr y la baja al terminar. Evita el clásico "me falla porque no levanté el server".
5. Escribiendo pruebas: locators y assertionsUn test básico:
import { test, expect } from '@playwright/test';
test('un usuario puede iniciar sesión', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Usuario').fill('antrax');
await page.getByLabel('Contraseña').fill('hunter2');
await page.getByRole('button', { name: 'Ingresar' }).click();
await expect(page.getByRole('heading', { name: 'Panel' })).toBeVisible();
await expect(page).toHaveURL('/dashboard');
});Notá que
no hay una sola espera explícita. Cada acción espera sola a que el elemento esté listo, y cada expect reintenta hasta que se cumple la condición o vence el timeout.
El punto más importante para la mantenibilidad a largo plazo es
cómo seleccionás los elementos. El orden de preferencia recomendado es:
[list=1]
- getByRole() — por rol de accesibilidad y nombre accesible. Es el más robusto porque refleja cómo un usuario (y un lector de pantalla) percibe la página.
- getByLabel(), getByPlaceholder(), getByText() — semánticos y legibles.
- getByTestId() — para cuando no hay nada semántico a mano. Requiere agregar data-testid en el código de la app.
- Selectores CSS o XPath — último recurso. Un div > div:nth-child(3) > span se rompe con el próximo cambio de maquetado.
Un detalle conceptual importante: un locator en Playwright es
perezoso. Escribir page.getByRole('button') no busca nada en ese momento; sólo describe cómo encontrar el elemento. La búsqueda ocurre recién al usarlo. Por eso podés guardar un locator en una variable y reutilizarlo aunque el DOM se haya vuelto a renderizar, sin el temido StaleElementReferenceException de Selenium.
6. Page Object ModelCuando la suite crece, conviene encapsular la interacción con cada pantalla:
export class LoginPage {
constructor(private page: Page) {}
usuario = () => this.page.getByLabel('Usuario');
password = () => this.page.getByLabel('Contraseña');
submit = () => this.page.getByRole('button', { name: 'Ingresar' });
async login(user: string, pass: string) {
await this.page.goto('/login');
await this.usuario().fill(user);
await this.password().fill(pass);
await this.submit().click();
}
}Así, si mañana cambia el formulario, tocás un solo archivo y no cincuenta tests.
7. Las tres herramientas que más tiempo ahorranCodegen. Abre un navegador, grabás lo que hacés a mano y te escribe el test:
npx playwright codegen https://tu-sitio.comNo lo uses como salida final, porque genera código verboso, pero para descubrir qué locator usar en un componente raro es rapidísimo.
Trace Viewer. Esta es la killer feature. Con trace: 'on-first-retry', cuando un test falla en CI se genera un archivo con la
línea de tiempo completa: un snapshot del DOM antes y después de cada acción, el log de red, la consola y el código fuente sincronizado. Lo abrís con:
npx playwright show-trace trace.zipPodés retroceder al momento exacto del fallo e inspeccionar el DOM como si tuvieras las DevTools abiertas ahí. Se acabó el "en mi máquina anda" y el debuggear a fuerza de screenshots.
Modo UI. Para desarrollo local:
npx playwright test --uiWatch mode, viaje en el tiempo entre pasos y selección visual de locators.
8. Interceptar la redPara tests deterministas conviene aislar el frontend del backend:
test('muestra un mensaje si la API falla', async ({ page }) => {
await page.route('**/api/usuarios', route =>
route.fulfill({ status: 500, body: '{"error":"boom"}' })
);
await page.goto('/usuarios');
await expect(page.getByText('No pudimos cargar')).toBeVisible();
});Poder probar los caminos de error sin tener que tirar abajo un servicio real es algo que en Selenium requiere infraestructura aparte.
9. Autenticación reutilizableLoguearse por UI en cada test es lento. La solución es hacerlo una vez en un setup, guardar el estado de sesión y reusarlo:
// auth.setup.ts
setup('autenticar', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Usuario').fill(process.env.TEST_USER);
await page.getByLabel('Contraseña').fill(process.env.TEST_PASS);
await page.getByRole('button', { name: 'Ingresar' }).click();
await page.context().storageState({ path: 'playwright/.auth/user.json' });
});Después, en el config, los proyectos que dependan de ese setup arrancan con storageState ya cargado y autenticados. En suites grandes esto baja los tiempos de minutos a segundos.
10. Integración con CIUn workflow mínimo de GitHub Actions:
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: 20 }
- 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 if: always() es clave: querés el reporte y los traces
especialmente cuando la corrida falla.
Para proyectos grandes se puede paralelizar con sharding (--shard=1/4) repartiendo la suite entre varias máquinas y juntando los reportes al final.
11. Buenas prácticas- Tests independientes. Ninguno debe depender de que otro haya corrido antes. Si necesitan datos previos, creálos vía API en un fixture, no por UI.
- Nada de esperas fijas. waitForTimeout es casi siempre el síntoma de que estás esperando lo que no corresponde. Esperá una condición concreta: un elemento, una respuesta de red, una URL.
- Respetá la pirámide de testing. E2E es la capa más cara y más lenta. Cubrí los flujos críticos de negocio acá, y el resto con tests unitarios y de integración.
- Un concepto por test. Los tests largos que validan diez cosas son imposibles de diagnosticar cuando fallan.
- Secretos por variables de entorno, nunca hardcodeados en el repo. Acá en Underc0de no hace falta aclararlo, pero por las dudas queda dicho.
En resumen: Playwright resuelve de raíz los problemas que históricamente hicieron odiar al testing E2E, que eran la inestabilidad, la lentitud y lo difícil que resultaba diagnosticar un fallo en CI. La curva de aprendizaje es corta si venís de cualquier otro framework, y el Trace Viewer por sí solo ya justifica la migración.
¿Alguien lo está usando en producción? Me interesa leer experiencias con suites grandes, sobre todo cómo manejan los tiempos de ejecución y el sharding.
Saludos!