95% de cobertura en unit tests mide funciones aisladas. El registro, el login y el pago pasan por frontend, API, autenticación y sesión al mismo tiempo, y ese camino sigue sin test. Los bugs que llegan al usuario aparecen en las integraciones entre componentes y en los flujos que nadie corrió de punta a punta.

Playwright junto a Cypress

Ambos manejan un navegador real e interceptan red. Playwright da ejecución paralela por worker, Chromium, Firefox y WebKit en el mismo setup, varias pestañas e iframes desde una sola API. Los equipos que ya escriben tests en Vitest o Jest reconocen la API de Playwright en la primera sesión. Cypress sigue como referencia de DX en el ecosistema Node, y migrar cuesta plata en ambos sentidos, así que elige una vez.

// playwright.config.ts
import { defineConfig } from '@playwright/test'

export default defineConfig({
  testDir: './e2e',
  timeout: 30000,
  retries: process.env.CI ? 2 : 0,
  use: {
    baseURL: 'http://localhost:3000',
    screenshot: 'only-on-failure',
    trace: 'on-first-retry',
  },
  webServer: {
    command: 'pnpm dev',
    port: 3000,
    reuseExistingServer: !process.env.CI,
  },
})

Tu primer test: el login completo

El flujo de login valida frontend, API, autenticación y sesión en un solo test. Empieza por ahí.

// e2e/login.spec.ts
import { test, expect } from '@playwright/test'

test('user can log in and see dashboard', async ({ page }) => {
  await page.goto('/login')

  await page.fill('[data-testid="email"]', 'user@example.com')
  await page.fill('[data-testid="password"]', 'password123')
  await page.click('[data-testid="submit"]')

  await expect(page).toHaveURL('/dashboard')
  await expect(page.locator('h1')).toContainText('Welcome')
})

La página de best practices de Playwright pide que el test dependa de atributos visibles para el usuario y evite la estructura del DOM. Orden de preferencia: rol (getByRole), label, texto y test id. La guía de locators confirma que data-testid es el recurso cuando rol y texto no identifican al elemento solos. Las clases CSS de layout se rompen con el primer ajuste de diseño.

Page Object para selectores repetidos

Veinte tests apuntando al mismo formulario de login se vuelven caros de mantener cuando cambia el markup. Los Page Objects concentran los selectores en una clase por página:

// e2e/pages/LoginPage.ts
import { Page, expect } from '@playwright/test'

export class LoginPage {
  constructor(private readonly page: Page) {}

  async goto() {
    await this.page.goto('/login')
  }

  async login(email: string, password: string) {
    await this.page.fill('[data-testid="email"]', email)
    await this.page.fill('[data-testid="password"]', password)
    await this.page.click('[data-testid="submit"]')
  }

  async expectError(message: string) {
    await expect(this.page.locator('[data-testid="error"]'))
      .toContainText(message)
  }
}

// e2e/login.spec.ts
test('failed login shows error', async ({ page }) => {
  const loginPage = new LoginPage(page)
  await loginPage.goto()
  await loginPage.login('wrong@example.com', 'bad')
  await loginPage.expectError('Credenciales inválidas')
})

Cuando cambia el frontend, arreglas un archivo y los veinte tests siguen verdes.

Los estados de error exigen interceptar la red

Timeout de API, caída de conexión y estado de loading no aparecen en un unit test. page.route intercepta la llamada y devuelve la falla:

test('shows error when API is down', async ({ page }) => {
  // Intercepta la API y aborta la conexión
  await page.route('**/api/orders', route => route.abort('connectionrefused'))

  await page.goto('/orders')

  await expect(page.locator('[data-testid="error-message"]'))
    .toContainText('No fue posible cargar los pedidos')
  await expect(page.locator('[data-testid="retry-button"]'))
    .toBeVisible()
})

El test afirma el mensaje y el botón de retry, que es lo que ve el usuario cuando cae el backend.

Datos de test creados y borrados por la fixture

E2E necesita datos predecibles. Las fixtures de Playwright crean el usuario por API antes del test y lo borran después:

// e2e/fixtures.ts
import { test as base } from '@playwright/test'

export const test = base.extend<{ testUser: User }>({
  testUser: async ({ request }, use) => {
    // Crea el usuario de test por API
    const res = await request.post('/api/test/users', {
      data: { email: `test-${Date.now()}@example.com`, password: 'test123' }
    })
    const user = await res.json()

    await use(user)

    // Borra después del test
    await request.delete(`/api/test/users/${user.id}`)
  },
})

Dato creado por test elimina dependencias entre casos y vuelve irrelevante el orden de ejecución.

CI con browsers y artifacts

El pipeline instala los browsers, corre la suite y publica el reporte cuando algo falla:

- name: Install Playwright
  run: pnpm exec playwright install --with-deps

- name: Run E2E tests
  run: pnpm test:e2e

- name: Upload test report
  if: failure()
  uses: actions/upload-artifact@v4
  with:
    name: playwright-report
    path: playwright-report/

Con trace: 'on-first-retry', el CI graba la sesión que reproduce el bug. Abres el trace en lugar de reproducir el escenario a mano.

Dónde deja de valer la pena el E2E

Los tests E2E corren lento y cuestan plata de mantener. John Micco, en Flaky Tests at Google and How We Mitigate Them (Google Testing Blog, 2016), reportó 1,5% de resultados flaky en el corpus de Google y algún grado de flakiness en el 16% de los tests. Ham Vocke, en The Practical Test Pyramid (martinfowler.com, 2018), defiende muchos unit tests, algunos integration tests y pocos tests de punta a punta. Reserva E2E para login, checkout, creación de cuenta y pago. El resto se queda con unit e integration.

Referencias