
El test era simple. Hacer clic en un botón. Esperar a que un diálogo se cierre. Verificar el resultado.
Pasaba el 95% de las veces. En el otro 5%, el diálogo tardaba más de 30 segundos en cerrarse. Solo en CI. En local funcionaba perfecto. El equipo ya lo había etiquetado: inestable.
La mayoría de los equipos habrían agregado un timeout más largo, configurado retries: 2, y seguido adelante. “La CI es lenta, los tests son inestables, desplegamos.” He visto esa respuesta tantas veces que ya tiene memoria muscular propia. Pero “inestable” no es un diagnóstico. Es la ausencia de uno. Así que investigué a fondo.
Lo que encontré no era un problema del test. Era un bug real de UX que afecta a cada usuario en producción.
El test
Así se veía el test — Playwright directo interactuando con un diálogo de mutación:
test('user can update record status', async ({ page }) => {
await page.goto('/records');
// Open the status update dialog
await page.getByRole('row', { name: /Record-1042/ })
.getByRole('button', { name: 'Update Status' })
.click();
// Select new status and confirm
await page.getByRole('combobox', { name: 'Status' }).selectOption('approved');
await page.getByRole('button', { name: 'Confirm' }).click();
// Wait for dialog to close, then verify the result
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
await expect(
page.getByRole('row', { name: /Record-1042/ }).getByText('Approved')
).toBeVisible();
});La falla siempre ocurría en la misma línea: await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 }). El diálogo se quedaba abierto por más de 30 segundos. El timeout de 10 segundos expiraba. El test fallaba.
La investigación
El paso 1 fue verificar que la falla era real y no un artefacto de la infraestructura de CI. Ejecuté el test 100 veces en local con --repeat-each=100. Pasó todas las veces. Lo ejecuté con CPU limitado (ralentización 6x a través de la emulación de Playwright) para simular las restricciones de recursos de la CI. Falló 3 veces de 100. La falla dependía del entorno — la contención de recursos la hacía manifestarse.
El paso 2 fue entender qué hacía realmente el handler de cierre del diálogo. Abrí el código fuente del frontend y tracé el flujo de la mutación.
La causa raíz: el ciclo de vida de la mutación
El diálogo usaba TanStack Query (React Query) para su mutación. Cuando el usuario hace clic en “Confirmar”, la mutación se ejecuta. Al tener éxito, se ejecuta el handler onSuccess del diálogo. Aquí es donde vive el problema:
// The mutation hook — simplified from the actual codebase
const updateStatus = useMutation({
mutationFn: (data: StatusUpdate) =>
api.patch(`/records/${data.id}/status`, { status: data.status }),
onSuccess: async () => {
// This is the problem: awaiting all cache invalidations
// before closing the dialog
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['records'] }),
queryClient.invalidateQueries({ queryKey: ['record-detail'] }),
queryClient.invalidateQueries({ queryKey: ['record-history'] }),
queryClient.invalidateQueries({ queryKey: ['dashboard-stats'] }),
queryClient.invalidateQueries({ queryKey: ['team-metrics'] }),
queryClient.invalidateQueries({ queryKey: ['audit-log'] }),
queryClient.invalidateQueries({ queryKey: ['notifications'] }),
queryClient.invalidateQueries({ queryKey: ['pending-reviews'] }),
]);
// Dialog only closes AFTER all 8 invalidations resolve
onClose();
},
});Ahí está. El handler onSuccess llama a Promise.all() sobre 8 invalidaciones de caché de consultas. En TanStack Query, invalidateQueries no solo marca el caché como obsoleto — dispara un refetch. Cada invalidación lanza una petición de red para recargar esos datos.
La llamada a onClose() — la que cierra el diálogo — está después del Promise.all(). El diálogo no se cierra hasta que los 8 refetches se completan.
En una máquina local rápida con baja latencia hacia el servidor API, esas 8 peticiones se completan en menos de un segundo. En CI, donde el runner de tests comparte recursos con otros workers paralelos y el servidor API corre en un contenedor con CPU limitado, esas 8 peticiones tardan de 10 a 30+ segundos en resolverse.
Por qué es un problema de usuario, no un problema de test
El test decía la verdad. Estaba reportando exactamente lo que le pasa a un usuario real.
Cada usuario que hace clic en “Confirmar” en este diálogo espera a que 8 refetches de caché se completen antes de que el diálogo se cierre. En una red corporativa rápida, quizá esperan 1-2 segundos y ni lo notan. En una conexión móvil, o durante carga máxima, o cuando la API tiene mucho tráfico — se quedan viendo un diálogo abierto preguntándose si su acción funcionó.
El test E2E en CI estaba experimentando lo que un usuario en una conexión lenta experimenta. El test no era inestable. La UX era lenta.
La corrección
La corrección separa el cierre del diálogo de la invalidación del caché. El diálogo debe cerrarse en el momento en que la mutación tiene éxito (HTTP 200). La invalidación del caché es una operación de segundo plano — actualiza los datos en la página tras bambalinas, pero la acción del usuario ya está completa.
// AFTER: Dialog closes on success. Cache invalidation is fire-and-forget.
const updateStatus = useMutation({
mutationFn: (data: StatusUpdate) =>
api.patch(`/records/${data.id}/status`, { status: data.status }),
onSuccess: () => {
// Close the dialog immediately — the user's action succeeded
onClose();
// Invalidate caches in the background — don't await
// TanStack Query will handle the refetches asynchronously
queryClient.invalidateQueries({ queryKey: ['records'] });
queryClient.invalidateQueries({ queryKey: ['record-detail'] });
queryClient.invalidateQueries({ queryKey: ['record-history'] });
queryClient.invalidateQueries({ queryKey: ['dashboard-stats'] });
queryClient.invalidateQueries({ queryKey: ['team-metrics'] });
queryClient.invalidateQueries({ queryKey: ['audit-log'] });
queryClient.invalidateQueries({ queryKey: ['notifications'] });
queryClient.invalidateQueries({ queryKey: ['pending-reviews'] });
},
});El cambio clave: onClose() se mueve al inicio de onSuccess, y las llamadas a invalidateQueries ya no se esperan con await. El wrapper Promise.all() desapareció. TanStack Query maneja los refetches de manera asíncrona — los datos en la página se actualizan en segundo plano conforme cada consulta se resuelve, que es lo que el usuario espera.
Después de la corrección, el diálogo se cierra en menos de 200 ms. Los datos de la página se refrescan en los siguientes 1-2 segundos conforme las invalidaciones de caché se completan. El usuario ve su acción confirmada de inmediato, y luego ve los datos actualizados aparecer gradualmente.
El test pasó 680 ejecuciones consecutivas después de la corrección. Cero fallas.
La trampa del ciclo de vida de TanStack Query
Este patrón de bug existe en cualquier base de código que use TanStack Query (o bibliotecas similares de gestión de caché) con la siguiente combinación:
- Una mutación con un callback
onSuccess - Llamadas a
invalidateQueriesdentro deonSuccessque sonawait-eadas - Una acción de UI (cierre de diálogo, navegación, toast) que depende de que
onSuccessse complete
La trampa es que invalidateQueries devuelve una Promise. Si tu función onSuccess es async y haces await de la invalidación, la mutación se queda en un estado casi-pending — onSuccess no ha terminado aún, así que cualquier cosa que dependa de su finalización queda bloqueada.
La documentación de TanStack Query es explícita al respecto: devolver una Promise desde onSuccess mantiene isPending en true hasta que la Promise se resuelve. Esto es útil cuando quieres mostrar un estado de carga hasta que los datos se refresquen. Es dañino cuando accidentalmente encadenas interacciones de UI a la latencia de red.
El patrón de diagnóstico
Cuando encuentres un diálogo, modal o navegación que responde lento después de una mutación, revisa el handler onSuccess:
// 🔍 Red flag: async onSuccess with awaited invalidations
onSuccess: async () => {
await queryClient.invalidateQueries(/* ... */); // <-- blocks
closeDialog(); // <-- delayed
}
// ✅ Fix: separate the user-facing action from the background refresh
onSuccess: () => {
closeDialog(); // <-- immediate
queryClient.invalidateQueries(/* ... */); // <-- background
}Este patrón aplica más allá de TanStack Query. Cualquier biblioteca de gestión de estado que vincule acciones de UI a operaciones de refresco de caché puede producir el mismo bug: el usuario espera operaciones de datos que deberían ser invisibles para él.
El protocolo de investigación de tests inestables
Cuando un test E2E expira en una interacción de UI — un diálogo que no se cierra, un botón que no se habilita, una navegación que no se completa — el instinto es aumentar el timeout. Resiste ese instinto. El timeout es un síntoma. La pregunta es: ¿qué está esperando la UI?
Paso 1: Reproducir bajo restricciones
Ejecuta el test con CPU limitado para simular las condiciones de CI. Si falla bajo restricciones pero pasa normalmente, la falla depende de los recursos, lo que significa que hay un problema de rendimiento oculto bajo condiciones normales.
// playwright.config.ts — add CPU throttling for investigation
use: {
launchOptions: {
args: ['--disable-gpu'],
},
// Simulate CI-like resource constraints
contextOptions: {
reducedMotion: 'reduce',
},
},Paso 2: Rastrear la interacción
Usa el trace viewer de Playwright para capturar qué sucede entre la acción del usuario y el resultado esperado. Busca peticiones de red que se disparen entre el clic y el cierre del diálogo. Cuéntalas. Cronométralas.
# Run with trace on to capture the full interaction timeline
npx playwright test flaky-test.spec.ts --trace=on
# Open the trace viewer
npx playwright show-trace test-results/trace.zipPaso 3: Revisar el ciclo de vida de la mutación
Abre el código fuente frontend del componente involucrado. Encuentra el hook de mutación. Lee los handlers onSuccess, onError y onSettled. Busca:
awaitdentro deonSuccess(bloquea la UI)Promise.all()envolviendo múltiples operaciones asíncronas (multiplica la latencia)- Peticiones de red que deben completarse antes de las actualizaciones de UI (dependencia secuencial)
Paso 4: Contar las invalidaciones de caché
Si encuentras llamadas a invalidateQueries, cuéntalas. Cada una es una petición de red. Bajo restricciones de recursos, cada petición agrega latencia. 8 invalidaciones x 3 segundos cada una = 24 segundos de tiempo bloqueado. Ese es tu test “inestable”.
Paso 5: Proponer la separación
La corrección es casi siempre la misma: separar la acción visible para el usuario del refresco de datos en segundo plano. Cerrar el diálogo, luego invalidar cachés. Navegar la página, luego refetchear datos. Mostrar el toast, luego actualizar el dashboard.
La lección más profunda
Tu suite E2E es lo más cercano que tienes a un usuario real. Hace clic en botones a la velocidad que lo haría un usuario. Espera respuestas como lo haría un usuario. Experimenta la latencia como lo haría un usuario en una red con restricciones.
Cuando tu suite E2E dice que algo es lento, créele. Cuando un test expira en una interacción de UI, no es el test siendo impaciente — es el test experimentando tu aplicación de la manera en que un usuario real la experimenta bajo condiciones no ideales.
Ese test “inestable” le ahorró a cada usuario un bloqueo de 30 segundos en un diálogo que debería cerrarse en milisegundos. La investigación tomó unas pocas horas. La corrección fue directa. La mejora de UX afecta a cada usuario, cada vez que usa esa funcionalidad.
Deja de aumentar timeouts. Empieza a investigar causas raíz. El test está intentando decirte algo.

