Volver al inicio

Estabilizando Pruebas de Playwright Sin Flakes

El artículo analiza las causas de flakes en pruebas E2E de Playwright y ofrece soluciones: sondeo dinámico expect.poll, idempotencia, mocks multinivel hasta Pact, estrategias de limpieza de datos de prueba. Ejemplos de código prácticos para estabilidad en CI.

Combatiendo Flakes en Playwright: poll, mocks, contratos
Advertisement 728x90

Estabilizar pruebas E2E de Playwright: Estrategias sin flakiness ni esperas fijas

En pipelines de CI/CD, las pruebas E2E de Playwright suelen fallar intermitentemente por alta carga, retrasos en red y contención de recursos. En local, CPUs potentes y baja latencia ocultan condiciones de carrera: el frontend renderiza la UI, pero el backend aún no ha confirmado la transacción en la base de datos. En CI, las condiciones son más duras: las pruebas detectan estados inconsistentes y fallan al azar.

La clave de la fiabilidad: pasar de esperas estáticas a sondeo dinámico, peticiones idempotentes y aislamiento robusto de dependencias. Vamos a profundizar en técnicas que eliminan la flakiness a gran escala.

Sondeo dinámico en lugar de timeouts rígidos

waitForTimeout(5000) es un antipatrón. En el 90% de los casos, la flakiness surge de desajustes temporales: el backend responde en 5001 ms en vez de los 5000 esperados. ¿La solución? expect.poll, que verifica el estado hasta éxito o timeout.

Google AdInline article slot
await expect.poll(async () => {
  const order = await api.getOrder(id);
  return order.status;
}, {
  message: 'Esperando estado PAGADO',
  timeout: 30000,
}).toBe('PAGADO');

Playwright ajusta automáticamente los intervalos de sondeo. Para escenarios lentos, aumenta test.setTimeout(60000), pero no modifiques los intervalos manualmente.

La trampa de networkidle y alternativas mejores

waitUntil: 'networkidle' espera 500 ms sin actividad de red. Problema: analíticas, widgets de chat o pings rompen el silencio, colgando la prueba hasta el timeout global.

Regla: Espera por elementos específicos o respuestas de API:

Google AdInline article slot
  • expect(page.getByText('Listo')).toBeVisible()
  • expect.poll en estado del backend

Para reintentar bloques de acciones, usa expect.toPass:

await expect(async () => {
  await page.getByRole('button', { name: 'Actualizar' }).click();
  await expect(page.getByText('Estado: Listo')).toBeVisible();
}).toPass({
  timeout: 15000
});

Idempotencia para proteger contra fallos de red

En sistemas distribuidos, reintentos por problemas de red crean duplicados: el primer POST tiene éxito, se pierde la respuesta, el reintento duplica la entidad. ¿Resultado? Errores 400 o estados inconsistentes.

Solución: Genera una Idempotency-Key a partir del método, URL y payload.

Google AdInline article slot
import { createHash } from 'crypto';

export function generateIdempotencyKey(method: string, url: string, data: any): string {
  const payload = `${method}:${url}:${JSON.stringify(data)}`;
  return createHash('sha256').update(payload).digest('hex').slice(0, 16);
}

// En BaseApiClient
protected async post(url: string, data?: any) {
  const key = generateIdempotencyKey('POST', url, data);
  return await this.request.post(url, {
    data,
    headers: { 'X-Idempotency-Key': key }
  });
}

El backend devuelve resultados en caché por clave. La flakiness por reintentos desaparece.

Mocks: Niveles de aislamiento desde básicos hasta basados en contratos

Mocks nativos con page.route

Para aislar la UI del backend:

await page.route('**/api/orders', route => {
  route.fulfill({
    status: 500,
    body: JSON.stringify({ error: 'Error interno del servidor' })
  });
});

await page.goto('/orders');
await expect(page.getByText('Algo salió mal')).toBeVisible();

Limitación: No captura peticiones del servidor vía fixture request.

Mocks de infraestructura (WireMock)

¿El backend depende de servicios externos (pagos, SMS)? Levanta WireMock:

# docker-compose.yml
services:
  wiremock:
    image: wiremock/wiremock:3.3.1
    ports:
      - "8080:8080"
    volumes:
      - ./wiremock/mappings:/home/wiremock/mappings

Ejemplo de mapping (payment.json):

{
  "request": {
    "method": "POST",
    "url": "/v1/payments"
  },
  "response": {
    "status": 200,
    "jsonBody": {
      "payment_id": "pay_test_123",
      "status": "succeeded"
    }
  }
}

Las pruebas quedan independientes de la disponibilidad externa.

Pruebas de contrato vs. mocks mentirosos

Los mocks mienten: el frontend espera order_id, el backend envía orderId—las pruebas pasan en verde, pero prod se cae.

Consumer-Driven Contracts (Pact) fijan expectativas en contratos JSON:

import { PactV3, MatchersV3 } from '@pact-foundation/pact';

const provider = new PactV3({
  consumer: 'frontend-tests',
  provider: 'order-service',
  dir: './pacts',
});

it('returns order by id', async () => {
  await provider
    .given('order ord_123 exists')
    .uponReceiving('GET /orders/ord_123')
    .withRequest({ method: 'GET', path: '/orders/ord_123' })
    .willRespondWith({
      status: 200,
      body: {
        order_id: MatchersV3.string('ord_123'),
        status: MatchersV3.string('PAGADO'),
      },
    })
    .executeTest(async (mockServer) => {
      const order = await fetchOrder(mockServer.url, 'ord_123');
      expect(order.status).toBe('PAGADO');
    });
});

El backend verifica el contrato en su CI. Los cambios de API se detectan antes del deploy.

Higiene de datos de prueba

Las pruebas ensucian la BD—el rendimiento cae, restricciones únicas causan flakiness.

1. TTL con limpieza en background

await api.createUser({
  email: `test_${Date.now()}@example.com`, 
  expires_at: new Date(Date.now() + 24*60*60*1000).toISOString()
});

PostgreSQL pg_cron:

SELECT cron.schedule('cleanup-test-data', '0 * * * *', $$
  DELETE FROM users WHERE expires_at < NOW() AND is_test = true;
$$);

2. Cola de limpieza

Recoge IDs en una cola al crear, purga en teardown global:

protected async post(url: string, data?: any) {
  const response = await this.request.post(url, { data });
  const body = await response.json();
  
  if (body.id) {
    cleanupQueue.push({ url, id: body.id });
  }
  return response;
}

3. Particionado de tablas

CREATE TABLE orders_test (
  id UUID, created_at TIMESTAMP
) PARTITION BY RANGE (created_at);

CREATE TABLE orders_test_2024_06 
  PARTITION OF orders_test
  FOR VALUES FROM ('2024-06-01') TO ('2024-07-01');

Eliminar particiones es O(1).

Lecciones clave:

  • Cambia waitForTimeout por expect.poll y expect.toPass para esperas inteligentes.
  • Idempotencia vía X-Idempotency-Key elimina duplicados por reintentos.
  • Evolución de mocks: page.route → WireMock → Pact para aislamiento real.
  • TTL + cron/colas/particiones para datos limpios.
  • La estabilidad es sistémica: arregla backend e infra junto con las pruebas.

— Editorial Team

Advertisement 728x90

Leer después