Módulo 7: Flaky en CI y tests que solo fallan allá

2. Por qué el flaky es tóxico en CI

Descripción

En la lección 1 viste un flaky parpadear: la misma suite de Reservo dando 1 failed, 7 passed y luego 8 passed, sin tocar el código. Quizás pensaste: "molesto, sí, pero corro de nuevo y sigo". Ese pensamiento —perfectamente razonable en tu máquina— es exactamente el que vuelve al flaky un veneno en CI. Esta lección es sobre por qué el mismo test flaky que en tu laptop es una molestia menor, en un pipeline compartido es un problema de equipo que erosiona la confianza, desperdicia dinero y deja pasar bugs reales.

La diferencia está en una palabra: compartido. Tu máquina es tuya; si un test te parpadea, lo sufres tú y sigues. El gate de CI es de todos: corre en el PR de cada quien, bloquea el merge de cada quien, y su rojo lo ven todos. Un flaky ahí no es tu molestia, es la de veinte personas a la vez. Y como el rojo bloquea, la reacción natural del equipo —"vuelve a correrlo"— no solo no arregla el flaky sino que lo institucionaliza: enseña a la gente a re-correr el rojo en vez de leerlo, y el día que el rojo es un bug de verdad, ya nadie lo cree.

Al terminar esta lección vas a poder explicar, con precisión, los tres daños que un flaky hace específicamente en CI —bloquea a todos, entrena la desconfianza, esconde bugs reales—, ponerles un costo, y entender por qué "re-correr hasta que pase" es la trampa que los agrava. Vas a ver, ejecutada de verdad, la corrida donde un solo flaky tiñe de rojo toda una suite por lo demás verde: un 1 failed, 7 passed que en un gate significa "nadie mergea hasta que esto se ponga verde".

Conexión con el módulo: la lección 1 te dio el qué (un flaky parpadea y rompe el contrato rojo=bug). Esta te da el por qué duele tanto en CI, que es la justificación de todo lo que sigue. Porque el flaky es tóxico y bloquea, la lección 3 discute el retry como desbloqueo de emergencia; porque re-correr a ciegas es malo, la lección 4 ofrece la cuarentena como alternativa disciplinada; porque una parte de estos flaky solo aparecen en CI, las lecciones 5 y 6 los cazan; y porque nada de eso cura, la lección 7 arregla el determinismo. Entender el daño es lo que te hará elegir bien entre esas herramientas en el mini-proyecto.

El semáforo descompuesto del que nadie se fía

Piensa en un cruce con semáforo. Su valor entero depende de una cosa: que sea predecible. Verde, avanzas; rojo, frenas. Todos los conductores construyen su comportamiento sobre esa promesa, y por eso el cruce funciona sin un policía dirigiendo.

Ahora imagina que ese semáforo se descompone de una forma particular: a veces se pone rojo sin que venga nadie por el otro lado. No siempre —la mayoría de las veces funciona—, pero lo bastante seguido como para que los conductores lo noten. ¿Qué pasa? Al principio todos frenan en cada rojo, obedientes. Pero después de frenar diez veces para nada, empiezan a hacer algo peligrosísimo: miran el rojo y deciden por su cuenta si les toca frenar o no. "Se puso rojo pero no viene nadie, avanzo." Y funciona... hasta el día que sí viene alguien por el otro lado, el conductor asume "otro rojo falso" y avanza. El choque no lo causó el semáforo por ponerse rojo. Lo causó por volverse no confiable, y con eso obligar a cada conductor a sustituir la regla clara ("rojo = frena") por un juicio propio y falible ("rojo = a ver si de verdad").

Un flaky en CI es ese semáforo. El rojo del pipeline debía ser una regla clara para todo el equipo: rojo, no mergees. Cuando el rojo empieza a mentir a veces, cada desarrollador deja de obedecer la regla y empieza a juzgar cada rojo —"¿será el flaky o será de verdad?"—. Y como juzgar es falible y toma esfuerzo, la mayoría opta por el atajo: re-correr hasta que se ponga verde, o mergear "porque seguro era el flaky". Ahí el semáforo dejó de dirigir el tráfico. Y el choque —un bug real que pasa el gate— es cuestión de tiempo.

En tu máquina, un flaky es un problema tuyo que resuelves corriendo de nuevo. En CI, el gate es compartido: el flaky bloquea a todos, y la reacción natural —re-correr hasta el verde— entrena al equipo a desobedecer el rojo. El daño no es el rojo; es que el rojo deje de significar algo.

Daño uno: bloquea el PR de todos, no el tuyo

El primer daño es el más inmediato y el más fácil de medir. En tu máquina, si un test te parpadea, el costo es tuyo y es pequeño: corres de nuevo y sigues trabajando. En CI, el flaky vive en el gate —la puerta que todo cambio debe cruzar para mergear—, y ese gate es compartido.

Imagina el flaky de should_audit en la suite de Reservo, con una regla de protección de rama que dice "no se mergea a main con el CI en rojo" (una regla sanísima, que el módulo 6 defendió). Ahora:

  • Ana abre un PR que cambia el texto de un correo. No toca auditoría para nada. El CI corre, should_audit cae en un microsegundo impar, y su PR sale rojo. Ana no puede mergear.
  • Bruno abre un PR que agrega una sala nueva. Tampoco toca auditoría. Mismo flaky, mismo rojo. Bruno no puede mergear.
  • El PR de Carla, que arregla un bug de verdad y urge, sale rojo por lo mismo. Carla no puede mergear.

Ninguno de los tres tocó el código flaky. Ninguno tiene un bug. Y los tres están bloqueados por el mismo test que parpadea, cada uno preguntándose qué hizo mal —cuando no hicieron nada—. Un flaky en tu máquina te cuesta a ti treinta segundos; un flaky en el gate le cuesta al equipo entero, multiplicado por cada PR que corra mientras el flaky viva. Ese es el primer costo, y es de dinero y de tiempo: reruns que consumen minutos facturados, PRs frenados, gente esperando.

Ejemplo trabajado: un flaky tiñe de rojo una suite verde

Veamos el momento exacto en que esto ocurre. La suite de Reservo tiene ocho tests: seis anclas sólidas (precio y reembolso), la mitad determinista de auditoría, y el flaky. Corremos la suite completa —como lo haría el gate— en una corrida donde el flaky cae en un microsegundo impar. Con Python 3.14.0 y pytest 9.1.1, medido ejecutando:

python -m pytest -q

Qué esperar.

>       assert should_audit() is True
E       assert False is True
E        +  where False = should_audit()

tests/test_audit.py:11: AssertionError
=========================== short test summary info ============================
FAILED tests/test_audit.py::test_new_booking_is_audited - assert False is True
1 failed, 7 passed in 0.02s

Lee ese 1 failed, 7 passed con ojos de gate. Siete tests verdes —los seis números-ancla de Reservo, sanísimos, más la parte determinista de auditoría— y el gate entero está rojo. No importa que el 87% de la suite pase: un solo test rojo pone el check en rojo, y la regla de protección de rama no distingue "rojo por un bug" de "rojo por un flaky". Para GitHub, para la regla, para el botón de merge, es un rojo y punto. Todo PR que corra contra este estado queda bloqueado por un test que no tiene nada que ver con el trabajo de nadie.

Y aquí está el detalle cruel: el rojo es honesto en su forma —el test de verdad falló, assert False is True de verdad ocurrió— pero mentiroso en su significado —no hay ningún bug de auditoría que arreglar—. Esa combinación, un rojo real que no corresponde a un problema real, es lo que hace del flaky algo tan difícil de ignorar y tan fácil de malinterpretar.

Daño dos: entrena al equipo a desconfiar del rojo

El segundo daño es más lento y mucho más caro, porque no es técnico sino cultural. Cuando un flaky bloquea PRs, el equipo busca la salida más rápida, y casi siempre es la misma: re-correr el CI hasta que salga verde. La primera vez lo haces con culpa. La quinta, con naturalidad. La vigésima, es un reflejo: ves rojo, aprietas "re-run", ni siquiera lees el fallo.

Ese reflejo es el veneno. Porque no distingue. El músculo que aprendiste —"rojo → re-correr"— se dispara igual ante el flaky de siempre que ante un rojo nuevo que es un bug. Has entrenado a tu equipo, sin querer, a tratar todo rojo como ruido. Y con eso destruiste lo único que el CI aportaba: un veredicto en el que confías sin pensarlo. Ahora cada rojo requiere un juicio —"¿será el flaky o será real?"— y ese juicio es lento, falible, y bajo presión de entrega casi siempre resuelve "ha de ser el flaky, mergeo".

Fíjate en la asimetría que lo hace tan tóxico. Un test que siempre falla es honesto: te obliga a mirarlo, no puedes ignorarlo, y lo arreglas. Un flaky es deshonesto: te da suficientes verdes como para que re-correr "funcione", y así te enseña a no mirarlo. El flaky es peor que un test siempre-rojo, porque el siempre-rojo te fuerza a la acción correcta (arreglar) mientras el flaky te seduce hacia la incorrecta (re-correr y olvidar). Un solo flaky tolerado unos meses reconfigura cómo un equipo entero lee su CI, y esa reconfiguración no se revierte apagando el flaky: la desconfianza, una vez aprendida, se queda.

Daño tres: esconde bugs reales

El tercer daño es el más grave, porque es el que el flaky existía para prevenir. Cuando el equipo aprende que "rojo puede ser el flaky", empieza a atribuir cualquier rojo intermitente al flaky conocido. Y algunos de esos rojos son bugs de verdad.

Piensa en la mecánica. Tienes un flaky de auditoría que todos saben que parpadea. Un día, Diego mergea un cambio que, sin que él lo note, rompe refund_cents en un caso raro: el reembolso de 72 h a veces devuelve 5900 en vez de 6000 por un error de redondeo que depende del orden en que se procesan las reservas. El CI de otro PR corre, test_full_refund_72h_before sale rojo. El desarrollador de ese PR mira el rojo, piensa "otro flaky", re-corre, y por casualidad esta vez pasa. Mergea. El bug de reembolso, que el CI sí atrapó, quedó enterrado bajo la suposición de que "todo rojo raro es el flaky".

Este es el desenlace que la analogía del semáforo predijo: el choque. El flaky no solo desperdicia tiempo y erosiona confianza; camufla los bugs reales, porque le da al equipo una explicación cómoda ("es el flaky") para descartar rojos que merecían atención. Un bug que el CI atrapó pero que el equipo ignoró por costumbre es peor que un bug que el CI no atrapó nunca: en el segundo caso te faltaba un test; en el primero lo tenías y lo desperdiciaste. El flaky convierte tus tests buenos en ruido de fondo que enmascara las alarmas verdaderas.

Por qué "córrelo de nuevo" es la trampa, no la solución

Vale la pena ser explícito sobre por qué el atajo natural agrava los tres daños en vez de aliviarlos, porque bajo presión "re-correr" siempre se siente como la opción sensata.

Re-correr no arregla nada: el flaky sigue exactamente igual de flaky después de que pasó por casualidad. Lo único que cambió es que este PR concreto se desbloqueó —a costa de dejar el flaky vivo para el siguiente PR, y el siguiente, y el siguiente—. Es achicar agua de un bote con un agujero: te mantiene a flote un rato, pero el agujero sigue ahí y cada quien vuelve a achicar.

Peor: re-correr entrena el reflejo equivocado (daño dos) y camufla los bugs reales (daño tres). Cada vez que re-corres y pasa, refuerzas la lección "el rojo no era de verdad", y esa lección se aplica indiscriminadamente al siguiente rojo, sea flaky o bug. Re-correr no es neutral: activamente empeora la cultura de lectura del CI.

Esto no significa que reintentar esté siempre prohibido. Significa que reintentar es, como mucho, un triaje de emergencia —una forma de desbloquear al equipo hoy mientras se hace el trabajo real de registrar, aislar y arreglar el flaky—, nunca un final. La lección 3 estudia el retry con esa lente exacta: útil como analgésico, peligroso como cura, y siempre acompañado de un plan para eliminar el flaky de raíz. La diferencia entre un equipo que sobrevive a sus flaky y uno que se ahoga en ellos no es si reintentan —a veces hay que hacerlo—, es si reintentar viene con un ticket y una fecha o si es el punto final.

Errores comunes

Medir el costo del flaky solo en tu tiempo. Qué pasa: alguien defiende dejar un flaky vivo con "es un test, lo corro de nuevo y ya, treinta segundos". Por qué pasa: se piensa desde la máquina propia, donde el costo es individual y pequeño. Cómo detectarlo: pregunta "¿este test corre en el gate compartido?"; si sí, multiplica esos treinta segundos por cada PR del equipo que se bloquee, más los reruns facturados, más la erosión de confianza. Cómo corregirlo: contabiliza el costo en CI como un costo de equipo, no personal. Un flaky en el gate no cuesta treinta segundos; cuesta treinta segundos × personas × frecuencia, más un daño cultural que no tiene precio pequeño.

Tratar "pasa el 90%" como aceptable para un gate. Qué pasa: se argumenta que un flaky que pasa nueve de cada diez veces "casi no molesta". Por qué pasa: el 90% suena alto. Cómo detectarlo: si el gate corre, digamos, cincuenta veces al día entre todos los PRs, un flaky del 10% de fallo bloquea cinco veces al día a alguien que no hizo nada malo —y cada bloqueo invita a un rerun que refuerza el reflejo tóxico—. Cómo corregirlo: para un gate compartido, el estándar no es "casi siempre pasa" sino "pasa siempre"; cualquier tasa de fallo espuria, por baja que parezca, se amplifica por el volumen del gate y por el daño cultural.

Confundir "el rojo es real" con "el problema es real". Qué pasa: el equipo ve assert False is True —un fallo genuino, con traceback y todo— y concluye que hay un bug de auditoría que arreglar, y pierde horas buscándolo. Por qué pasa: un rojo con traceball se ve idéntico a un bug; la forma es honesta. Cómo detectarlo: si el mismo test pasa al re-correr sin cambiar nada, el rojo es real como evento pero no apunta a un bug de producción —apunta a un test no determinista—. Cómo corregirlo: antes de cazar un bug de producción, confirma que el test hace una pregunta determinista; si depende del reloj/orden, el "bug" está en el test, y el arreglo es la lección 7, no una expedición por la lógica de Reservo.

Ejercicios

Ejercicio 1 — Pon el costo en números. El flaky de should_audit falla el 10% de las corridas del gate. El equipo abre 30 PRs al día, y cada PR corre el CI un promedio de 2 veces (push inicial + un ajuste). Cada rerun del CI cuesta 3 minutos de runner. Estima (a) cuántas corridas del gate al día fallan por el flaky, y (b) los minutos de runner al día que se gastan solo en los reruns que esos fallos provocan (asume un rerun por fallo). Y en una frase, nombra el costo que no aparece en esos números.

Ver solución
  • (a) Corridas que fallan por el flaky: 30 PRs × 2 corridas = 60 corridas del gate al día; al 10% de fallo espurio, ≈ 6 corridas al día salen rojas por el flaky (sin bug real).
  • (b) Minutos de rerun: cada uno de esos 6 fallos provoca al menos un rerun de 3 minutos → 18 minutos al día de runner quemados solo en re-correr por el flaky, ≈ 90 minutos a la semana, ≈ 6.5 horas de runner al mes tiradas en un solo test mal escrito.
  • El costo que no aparece: el daño cultural —cada uno de esos 6 reruns diarios entrena al equipo a re-correr el rojo en vez de leerlo, y eso, además de erosionar la confianza, hace que algún bug real pase disfrazado de flaky—. Ese costo no cabe en una hoja de cálculo, y suele ser el más caro de todos.

La lección: aun ignorando el daño cultural, el costo medible de un flaky del 10% en un gate activo ya es de horas de runner al mes. "Lo corro de nuevo y ya" ignora que ese "ya" lo pagan muchas personas, muchas veces.

Ejercicio 2 — Peor que siempre-rojo. Un compañero dice: "Prefiero un flaky que pasa el 90% a un test que siempre falla; al menos el flaky me deja avanzar a veces." Argumenta por qué, para la salud del equipo, un flaky del 90% puede ser peor que un test que siempre falla.

Ver solución

Un test que siempre falla es honesto: no te deja avanzar hasta que lo mires, así que te fuerza a la acción correcta —arreglarlo (o borrarlo si ya no aplica)—. No puedes ignorarlo, y por eso no envenena nada: es un dolor agudo que se resuelve rápido.

Un flaky del 90% es deshonesto justo por dejarte avanzar "a veces". Esos verdes ocasionales hacen que "re-correr" funcione lo suficiente como para volverse el hábito, y así el flaky sobrevive —nadie lo arregla porque nadie está obligado a mirarlo—. Peor, entrena el reflejo "rojo → re-correr" que luego se aplica a los rojos reales (daño dos y tres). El siempre-rojo se cura en horas porque bloquea; el flaky del 90% vive meses porque casi no bloquea, y en esos meses reconfigura cómo el equipo lee su CI.

La paradoja: la propiedad que tu compañero valora del flaky —"me deja avanzar a veces"— es exactamente la que lo hace tóxico y persistente. Un dolor que te deja funcionar es un dolor que no atiendes.

Ejercicio 3 — El bug disfrazado. Reservo tiene un flaky conocido de auditoría. Un día, test_full_refund_72h_before (un ancla sólida que siempre pasaba) empieza a fallar de forma intermitente tras un merge de otra persona. El equipo, por costumbre, lo atribuye al "flaky conocido" y re-corre hasta pasar. ¿Qué señal debió detener esa atribución, y qué debió hacer el equipo?

Ver solución

La señal que debió detenerlos: el test que ahora parpadea no es el flaky conocido. El flaky conocido es test_new_booking_is_audited; el que empezó a fallar es test_full_refund_72h_before, un ancla de reembolso que siempre había pasado de forma determinista. Atribuir su rojo al "flaky de auditoría" es un error de identidad: son tests distintos, con causas distintas. Un ancla determinista que de pronto se vuelve intermitente después de un merge es la firma de un bug nuevo introducido por ese merge —probablemente estado compartido u orden, dado que es intermitente—, no del flaky preexistente.

Lo que debió hacer el equipo: (1) no meter todos los rojos intermitentes en el mismo saco "es el flaky"; identificar cuál test parpadea. (2) Notar que un ancla estable volviéndose flaky coincide con un merge → sospechar de ese merge. (3) Reproducir el nuevo flaky (lecciones 5–6) y arreglar su determinismo o revertir el merge. Este es el daño tres en vivo: el flaky conocido dio la cobertura perfecta para que un bug real de reembolso pasara como "más de lo mismo". La disciplina que lo evita: cada flaky se rastrea por identidad y con ticket (lección 4), para que "es el flaky" sea una afirmación verificable, no una excusa de bolsillo.

Resumen y siguiente paso

En esta lección mediste por qué el mismo flaky que en tu máquina es una molestia menor, en CI es un veneno de equipo. La diferencia es una palabra —compartido— y de ella salen tres daños: el flaky bloquea el PR de todos (viste 1 failed, 7 passed poner en rojo un gate 87% verde, frenando a quienes no tocaron el código flaky); entrena al equipo a desconfiar del rojo (el reflejo "rojo → re-correr" que se aplica indiscriminadamente); y esconde bugs reales (rojos genuinos descartados como "el flaky de siempre"). El semáforo que a veces se pone rojo sin motivo no falla por el rojo, falla por volverse no confiable y obligar a cada quien a juzgar en vez de obedecer.

También quedó claro por qué "córrelo de nuevo" es la trampa: no arregla nada, refuerza el reflejo tóxico y camufla los bugs. Reintentar puede ser un triaje de emergencia, nunca un final —y siempre con ticket y plan—.

Antes de avanzar deberías poder: nombrar los tres daños del flaky en CI y explicar por qué son de equipo y no personales; argumentar por qué un flaky del 90% puede ser peor que un siempre-rojo; y detectar cuándo "es el flaky" es una excusa que esconde un bug real.

Lo que sigue, en la lección 3, es mirar de frente la herramienta que el equipo alcanza primero cuando el flaky bloquea: el retry. Vamos a instalar pytest-rerunfailures y correr --reruns 3 de verdad sobre este mismo flaky —verás el RERUN rescatarlo a veces y no alcanzar otras— para tener, con la salida real en la mano, el debate honesto: cuándo reintentar desbloquea y cuándo, sencillamente, te miente en verde.

Recursos