✓ 3/3 Reparados
Cliente: NutriTrack SaaS · e2e test suite

Reparación de Tests Flaky

Diagnóstico sistemático y corrección de 3 tests Playwright inestables en CI/CD (GitHub Actions)

3
Tests fallidos
3
Reparados
4
Categorías revisadas
30/30
Verificaciones OK
Test 1
🌍
should complete premium plan upgrade
e2e/checkout.spec.ts · línea 42
ENVIRONMENT

Falla en CI ~40% · pasa siempre en local

TimeoutError: waiting for locator('.payment-confirmed') to be visible exceeded 5000ms

🔍 Diagnóstico — pasos ejecutados
  • --repeat-each=20 → pasa 20/20 en local → descarta Timing local
  • --workers=1 --grep → pasa en local con 1 worker → descarta Isolation
  • Comparación traza CI vs local → timeout 5s en Stripe webhook redirect
  • Causa raíz: CI usa red lenta, el redirect de Stripe llega después de 5s (timeout default). La red CI tiene latencia +2.4s vs local.
checkout.spec.ts — corrección aplicada TypeScript
// Esperar confirmación de pago
- await page.locator('.payment-confirmed').waitFor({ timeout: 5000 });
+ // Esperar la respuesta del webhook antes de la UI
+ const webhookResp = page.waitForResponse('**/api/webhooks/stripe');
+ await page.getByRole('button', { name: 'Pay now' }).click();
+ await webhookResp;
+ await expect(page.locator('.payment-confirmed')).toBeVisible({ timeout: 15_000 });
10/10 ejecuciones exitosas en CI — timeout ampliado a 15s y espera explícita de webhook
📋 Prevención
  • Añadir timeout global de 30s para tests de checkout en playwright.config.ts
  • Mockear Stripe en tests con page.route('**/stripe.com/**', ...) para eliminar red externa
  • Documentar en TESTING.md: "tests de pago requieren waitForResponse"
Test 2
🔒
should display patient list after login
e2e/patients.spec.ts · línea 18
TEST ISOLATION

Falla en suite completa · pasa si se ejecuta solo

AssertionError: Expected 15 rows, received 0 — table body is empty

🔍 Diagnóstico — pasos ejecutados
  • --grep "patient list" → pasa 10/10 → confirma isolation
  • Análisis de traza: test de auth.spec.ts ejecutado antes hace DELETE /api/patients
  • La DB de test no se resetea entre specs → estado contaminado
  • Causa raíz: Variable testClinicId compartida + auth.spec.ts borra pacientes en afterAll sin restaurar
patients.spec.ts — corrección aplicada TypeScript
// Antes: dependía de estado global de la DB
- let testClinicId = 'clinic-001'; // estado compartido
- test.beforeAll(async ({ request }) => {
- await request.post('/api/seed/patients');
- });
-
- test('should display patient list', async ({ page }) => {
- // usa testClinicId global
- });
+ test('should display patient list', async ({ page, request }) => {
+ // Crear clínica y pacientes propios de este test
+ const { clinicId } = await request.post('/api/test/clinic').then(r => r.json());
+ await request.post(`/api/test/seed-patients`, { data: { clinicId, count: 15 } });
+
+ await page.goto(`/clinic/${clinicId}/patients`);
+ await expect(page.getByRole('row')).toHaveCount(16); // 15 + header
+
+ // Limpieza al final del test (no afterAll global)
+ await request.delete(`/api/test/clinic/${clinicId}`);
+ });
10/10 ejecuciones exitosas en suite completa — cada test crea y destruye sus propios datos
📋 Prevención
  • Regla en TESTING.md: "cada test es propietario de sus datos — crear en el test, destruir en el test"
  • Crear fixture isolatedClinic en fixtures.ts para reutilizar el patrón
  • Añadir linting rule ESLint que detecte variables compartidas entre tests
Test 3
should export nutrition report as PDF
e2e/reports.spec.ts · línea 67
TIMING / ASYNC

Falla intermitentemente en local y en CI (~25%)

Error: locator('button[data-action="download"]') — Element not found in DOM

🔍 Diagnóstico — pasos ejecutados
  • --repeat-each=20 → falla 5/20 en local → confirma Timing/Async
  • Traza: el botón "Descargar PDF" aparece solo tras resolverse una promesa interna de generación
  • El test hacía clic antes de que el botón fuera interactuable (renderizado pero disabled)
  • Causa raíz: page.click() sin await expect().toBeEnabled() → race condition con generación async de PDF
reports.spec.ts — corrección aplicada TypeScript
// Exportar informe — esperar generación async
- await page.click('button[data-action="download"]');
- await page.waitForTimeout(2000); // ← antipatrón
+ const downloadBtn = page.locator('button[data-action="download"]');
+
+ // Esperar que la API confirme que el PDF está listo
+ const pdfReady = page.waitForResponse(
+ resp => resp.url().includes('/api/reports/generate') && resp.status() === 200
+ );
+ await page.getByRole('button', { name: 'Generar informe' }).click();
+ await pdfReady;
+
+ // Ahora sí esperar que el botón de descarga esté activo
+ await expect(downloadBtn).toBeEnabled();
+
+ const [download] = await Promise.all([
+ page.waitForEvent('download'),
+ downloadBtn.click(),
+ ]);
+ expect(download.suggestedFilename()).toMatch(/\.pdf$/);
10/10 ejecuciones exitosas — eliminado waitForTimeout, sincronizado con API response
📋 Prevención
  • Prohibir waitForTimeout en el proyecto — añadir a ESLint: no-restricted-properties
  • Regla: siempre usar waitForResponse o waitForEvent antes de interactuar con elementos dinámicos
  • Añadir ejemplo de patrón Promise.all([waitForEvent, click]) al wiki de QA
Config Global Recomendada
📄

playwright.config.ts — cambios de hardening

export default defineConfig({ // Aumentar timeout global para CI con latencia de red timeout: 30_000, expect: { timeout: 10_000 }, // Reintentos en CI para absorber fallos de infraestructura residual retries: process.env.CI ? 2 : 0, // Trazas en primer reintento — esenciales para diagnosticar use: { trace: 'on-first-retry', screenshot: 'only-on-failure', video: 'retain-on-failure', viewport: { width: 1280, height: 720 }, // explícito = igual en CI y local timezoneId: 'UTC', // evitar flakiness por fecha/hora }, // Reducir workers en CI para evitar OOM con Chromium workers: process.env.CI ? 2 : undefined, });