Playwright: El Fin de la Era de los Tests Inestables

Playwright: El Fin de la Era de los Tests Inestables
Photo by UX Indonesia / Unsplash

En mis seis años desarrollando software, he visto morir más proyectos de automatización por falta de confianza que por falta de talento. Herramientas tradicionales como Selenium, e incluso versiones tempranas de Cypress, suelen sufrir de un mal endémico: las ejecuciones inestables (flaky tests).

El culpable casi siempre es el timing. Intentar interactuar con un botón que el DOM ya reporta como existente, pero que el motor de renderizado del navegador aún no ha terminado de pintar o habilitar. Aquí es donde Playwright, el framework de Microsoft, cambia las reglas del juego con una premisa clara: resiliencia por defecto.

¿Por qué Playwright es diferente?

A diferencia de sus predecesores, Playwright no es solo un "wrapper" de comandos. Su arquitectura se basa en:

  • Auto-waiting: Olvídate de los sleep() o esperas manuales. Playwright espera a que los elementos sean accionables antes de ejecutar cualquier acción.
  • Browser Contexts: Permite aislar pruebas de forma ultra rápida, simulando múltiples sesiones independientes en una sola instancia del navegador.
  • Multi-motor nativo: Soporte real para Chromium (Chrome, Edge), WebKit (Safari) y Firefox, incluyendo emulación de dispositivos móviles sin configuraciones redundantes.

Manos al código: Setup Inicial

La barrera de entrada es mínima. Para inicializar un proyecto desde cero, solo necesitamos ejecutar:

Bash

npm init playwright@latest

Este comando no solo instala las dependencias necesarias, sino que genera una estructura profesional:

  1. playwright.config.ts: El cerebro de la configuración.
  2. tests/: Carpeta dedicada para nuestros archivos de especificación.
  3. Reportes y flujos de CI/CD pre-configurados.

Configuración Estratégica

En el archivo playwright.config.ts, podemos definir el comportamiento global. Un consejo de experiencia: aunque Playwright brilla ejecutando pruebas en paralelo para optimizar el tiempo, durante la fase de desarrollo es útil limitar los workers para depurar con precisión.

TypeScript

import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  fullyParallel: true, // ¡Potencia máxima en CI!
  timeout: 30000,
  expect: { timeout: 5000 },
  reporter: 'html',
  use: {
    baseURL: 'http://localhost:5173',
    trace: 'on-first-retry', // Vital para depurar errores
    video: 'on',
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'webkit', use: { ...devices['Desktop Safari'] } },
  ],
});

Caso de Estudio: Automatizando una TODO List

Para demostrar su potencia, utilizaremos una aplicación React sencilla. Lo interesante aquí no es solo la interacción, sino cómo Playwright utiliza los data-testid para crear pruebas desacopladas del estilo CSS o la estructura HTML.

// src/App.tsx
import { useState } from 'react'

interface Todo {
  id: number
  text: string
  completed: boolean
}

function App() {
  const [todos, setTodos] = useState<Todo[]>([])
  const [inputValue, setInputValue] = useState('')

  const addTodo = (e: React.FormEvent) => {
    e.preventDefault()
    if (inputValue.trim() === '') return

    const newTodo: Todo = {
      id: Date.now(),
      text: inputValue.trim(),
      completed: false,
    }
    setTodos([...todos, newTodo])
    setInputValue('')
  }

  return (
    <div className="todo-app">
      <h1>Todo List</h1>

      <form onSubmit={addTodo} className="todo-form">
        <input
          type="text"
          value={inputValue}
          onChange={(e) => setInputValue(e.target.value)}
          placeholder="What needs to be done?"
          data-testid="todo-input"
        />
        <button type="submit" data-testid="add-button">
          Add
        </button>
      </form>

      <ul data-testid="todo-list">
        {todos.map((todo) => (
          <li key={todo.id} data-testid="todo-item">
            <span data-testid="todo-text">{todo.text}</span>
          </li>
        ))}
      </ul>

      {todos.length === 0 && (
        <p data-testid="empty-message">No todos yet. Add one above!</p>
      )}
    </div>
  )
}

La Suite de Pruebas

Aquí aplicamos el patrón de pruebas resilientes. Observa cómo el código es declarativo:

// tests/todo.spec.ts
import { test, expect } from '@playwright/test'

test.describe('Adding Todos', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/')
  })

  test('should add a new todo', async ({ page }) => {
    // Escribir en el input
    await page.getByTestId('todo-input').fill('Buy groceries')
    
    // Click en el botón Add
    await page.getByTestId('add-button').click()

    // Verificar que se agregó el todo
    await expect(page.getByTestId('todo-item')).toHaveCount(1)
    await expect(page.getByTestId('todo-text')).toHaveText('Buy groceries')
  })

  test('should add todo by pressing Enter', async ({ page }) => {
    await page.getByTestId('todo-input').fill('Learn Playwright')
    await page.getByTestId('todo-input').press('Enter')

    await expect(page.getByTestId('todo-item')).toHaveCount(1)
    await expect(page.getByTestId('todo-text')).toHaveText('Learn Playwright')
  })

  test('should clear input after adding todo', async ({ page }) => {
    await page.getByTestId('todo-input').fill('Test todo')
    await page.getByTestId('add-button').click()

    // El input debe estar vacío después de agregar
    await expect(page.getByTestId('todo-input')).toHaveValue('')
  })

  test('should not add empty todo', async ({ page }) => {
    // Click sin escribir nada
    await page.getByTestId('add-button').click()

    // No debe haber ningún todo
    await expect(page.getByTestId('todo-item')).toHaveCount(0)
    await expect(page.getByTestId('empty-message')).toBeVisible()
  })

  test('should not add whitespace-only todo', async ({ page }) => {
    await page.getByTestId('todo-input').fill('   ')
    await page.getByTestId('add-button').click()

    await expect(page.getByTestId('todo-item')).toHaveCount(0)
  })

  test('should add multiple todos', async ({ page }) => {
    const todos = ['First todo', 'Second todo', 'Third todo']

    for (const todo of todos) {
      await page.getByTestId('todo-input').fill(todo)
      await page.getByTestId('add-button').click()
    }

    await expect(page.getByTestId('todo-item')).toHaveCount(3)
  })
})

Diagnóstico de Errores: Visualización de Fallos

Uno de los puntos más fuertes de Playwright es su capacidad de observabilidad. Cuando una prueba falla, el framework genera automáticamente:

  1. Capturas de pantalla (Screenshots) del momento exacto del error.
  2. Trazas (Traces) que permiten hacer un "viaje en el tiempo" por cada paso ejecutado.
  3. Videos de la interacción del usuario.
Nota Pro: El "UI Mode" de Playwright (npx playwright test --ui) es, en mi opinión, la mejor herramienta de desarrollo de tests creada hasta la fecha, permitiendo inspeccionar el DOM en cada paso del histórico de la prueba.
Evidencia del error en imagen con Playwright

0:00
/0:10

Evidencia en vídeo de playwright

por último todo éxito:

TODO test exitoso

Conclusión

Playwright no es solo "otra herramienta de testing"; es una evolución necesaria para los equipos que buscan velocidad sin sacrificar estabilidad. En la próxima entrada, profundizaremos en el Page Object Model (POM) y cómo estructurar suites de pruebas escalables para proyectos de gran envergadura.

¿Qué problemas de estabilidad estás enfrentando hoy en tus pruebas? Hablemos en los comentarios.