Tienes un test flaky — camina por tu proceso real de triage para encontrar y arreglar la causa raíz
Los tests flaky son reportes de bugs disfrazados — el triage real aísla orden, paralelismo y timing hasta exponer el estado compartido o la condición de carrera detrás.
APRENDE HACIENDOPon a prueba tu intuición3 pasos
Tienes un test flaky — camina por tu proceso real de triage para encontrar y arreglar la causa raíz
Usa los ejemplos y la explicación de la lección para comprobar tu idea.
Reflexión personal. No se envía a ningún servicio de IA.#Los tests flaky son un síntoma, no la enfermedad
Un test flaky — uno que pasa y falla sobre el mismo código, sin ningún cambio de por medio — no es "solo molesto". Es un reporte de bug sobre el sistema bajo prueba, o sobre el propio test, que todo el mundo está entrenado a ignorar haciendo clic en "rerun". Ese instinto va exactamente al revés: la mayoría de la inestabilidad viene de una condición de carrera, estado mutable compartido, o una dependencia de orden que tarde o temprano va a morder en producción, no solo en CI.
#Paso 1: confirmar que de verdad es flaky
Antes de investigar nada, descarta que haya fallado una sola vez porque una dependencia tuvo un hipo. Corre el test específico entre 20 y 50 veces en local, y confirma por separado que falla de forma intermitente en CI, no solo una vez. La mayoría de los runners de CI soportan una bandera de repetición o un loop simple; si falla más o menos 1 de cada 15 corridas, esa proporción ya es una pista sobre sensibilidad a timing.
#Paso 2: reproducirlo de forma confiable
Los tests flaky suelen ser sensibles a una de un puñado de dimensiones — encuentra cuál cambiándola de forma aislada:
- Correrlo solo versus como parte de toda la suite. Si solo falla junto a otros tests, sospecha de estado compartido.
- Mezclar el orden de los tests (la mayoría de los runners soportan un modo de semilla aleatoria). Si las fallas se correlacionan con el orden, es una fuga de estado entre tests, no aleatoriedad genuina.
- Correrlo en serie versus en workers paralelos. Si solo falla en paralelo, sospecha de un recurso compartido — un puerto, un archivo, un singleton en memoria, una fila de base de datos — que dos workers están tocando al mismo tiempo.
- Agregar carga o retraso artificial a la máquina. Si las fallas aumentan bajo carga, es sensible a timing — normalmente una condición de carrera o un await faltante.
#Paso 3: mirar la falla real — un ejemplo concreto
Este es un patrón real: una caché a nivel de módulo usada como capa rápida de memoización, probada con dos tests que parecen completamente independientes.
// cache.tsexport const requestCache = new Map<string, number>();export function incrementAndGet(key: string): number {const next = (requestCache.get(key) ?? 0) + 1;requestCache.set(key, next);return next;}
// cache.test.tsimport { incrementAndGet } from "./cache";test("la primera llamada para una clave nueva devuelve 1", () => {expect(incrementAndGet("requests")).toBe(1);});test("la segunda llamada para la misma clave devuelve 2", () => {expect(incrementAndGet("requests")).toBe(2);});
Corre el archivo de arriba hacia abajo y ambos tests pasan — la aserción de 2 en el segundo test solo funciona porque el primero ya incrementó el requestCache compartido a 1. Corre el segundo test solo (test.only, o un runner que particiona o reordena), y falla: el módulo recién cargado da 1, no 2. Corre toda la suite en modo de orden aleatorio y falla con la misma frecuencia. El bug no está en incrementAndGet — está en que la suite tiene una dependencia oculta: el orden de los tests es parte de un contrato no declarado, y el Map a nivel de módulo es estado mutable compartido que se filtra entre tests que parecen independientes.
#Paso 4: arreglar la causa raíz, no el síntoma
El arreglo equivocado es forzar un orden de ejecución fijo o agregar una anotación de reintento — eso esconde el acoplamiento en lugar de eliminarlo. El arreglo correcto quita el estado compartido para que cada test arranque desde una base conocida:
// cache.test.ts (arreglado)import { requestCache, incrementAndGet } from "./cache";beforeEach(() => {requestCache.clear();});test("la primera llamada para una clave nueva devuelve 1", () => {expect(incrementAndGet("requests")).toBe(1);});test("la segunda llamada para la misma clave devuelve 2", () => {incrementAndGet("requests");expect(incrementAndGet("requests")).toBe(2);});
Todavía mejor a largo plazo: dejar de exportar un singleton a nivel de módulo y hacer que la caché sea una instancia que el test construye desde cero (new RequestCache()), lo que hace que los bugs de estado compartido sean estructuralmente imposibles en lugar de simplemente reseteados entre corridas.
#Otras causas raíz comunes, con la misma lógica de triage
- Trabajo asíncrono sin esperar: una aserción corre antes de que una promesa se resuelva; pasa en una máquina rápida y falla en un runner de CI cargado. Arreglo: esperar la señal real de finalización, nunca usar
setTimeoutcomo primitiva de sincronización. - Reloj real del sistema: un test hace una aserción sobre matemática con
Date.now()y falla una vez al mes en un límite — medianoche, fin de mes, un cambio de horario de verano. Arreglo: inyectar un reloj falso. - Aleatoriedad sin semilla: un input de caso borde que aparece de forma intermitente. Arreglo: sembrar el generador de números aleatorios en los tests.
- Recursos que se filtran: un puerto o archivo temporal de una corrida anterior que nunca se liberó. Arreglo: un
afterEachde limpieza estricto.
🧠 Check your understanding
0/2 · 0/2 answered1. Un test pasa cuando se corre después de otro test en el mismo archivo, pero falla cuando se corre solo. ¿Qué suele indicar este patrón?
2. ¿Por qué agregar una anotación de reintento se considera un mal arreglo para un test flaky causado por una condición de carrera?