Módulo 7: Flaky en CI y tests que solo fallan allá
1. Presentación del módulo: el flaky en CI
Descripción
Durante seis módulos construiste un pipeline que hace una promesa muy concreta: cada vez que alguien empuja código, la suite de Reservo corre sola y te dice la verdad. Verde: el código está sano. Rojo: hay algo que arreglar. Esa promesa —rojo significa bug— es la razón entera por la que un CI vale la pena. Es lo que te deja mergear con confianza, lo que protege la rama principal, lo que convierte "espero que funcione" en "el guardián lo verificó". Este módulo trata la enfermedad que ataca esa promesa por dentro: el test flaky.
Un test flaky es uno que a veces pasa y a veces falla sin que nadie toque el código. Lo corres ahora y da verde; lo corres en diez segundos y da rojo; vuelves a correrlo y verde otra vez. El código es el mismo, el test es el mismo, y el veredicto cambia. Su pariente cercano es el test que solo falla en CI —verde en tu máquina, siempre; rojo en el runner, a veces o siempre—. Los dos hacen exactamente el mismo daño: rompen el contrato "rojo = bug". Cuando un rojo puede ser un bug de verdad o "el flaky de siempre", ya no puedes leerlo. Y un semáforo que a veces miente en rojo no es un semáforo, es ruido con luces.
Al terminar esta lección vas a saber qué es un flaky, por qué en el contexto de CI es más tóxico que un fallo honesto, y lo vas a ver latir de verdad: Reservo estrena una función que mira el reloj de pared y decide distinto en cada corrida, así que la misma suite —sin cambiar una coma— te dará 8 passed una vez y 1 failed, 7 passed la siguiente. Ese parpadeo, ejecutado en tu terminal, es todo el problema del módulo en miniatura.
Conexión con el módulo: esta lección es el mapa. Aquí instalas la idea —qué es un flaky, por qué envenena el CI— y la ves parpadear una vez. La lección 2 disecciona por qué es especialmente tóxico en CI y no solo molesto. La 3 abre el debate del retry (--reruns), ejecutado de verdad: verás el RERUN rescatar al flaky, y el argumento a favor y en contra. La 4 enseña la cuarentena —aislar el flaky del gate, no re-correr a ciegas—. La 5 cataloga el fallo que solo ocurre en CI y sus causas. La 6 lo reproduce en tu máquina. Y la 7 cierra con la única cura real: arreglar el determinismo, no reintentar. La 8, el mini-proyecto, te pone a decidir —reintentar, cuarentena o arreglar— ante un flaky que bloquea el CI de Reservo.
Una nota de frontera, porque este módulo se apoya en sus hermanos y no los repite. Diagnosticar un flaky a fondo —reproducirlo de forma fiable, aislar el par de tests que interactúa, congelar el reloj para cazar la línea culpable— es el oficio de la guía hermana test-failure-diagnosis-guide. Aquí el foco es el flaky en el contexto de CI: cómo desbloquea o traba al equipo, el debate del retry, la cuarentena, y el CI-only. Cuando lleguemos a "reproduce el fallo en tu máquina" vamos a apoyarnos en el método del módulo 3 (reproducir un fallo de CI localmente) y en ese punto haremos el puente a la guía de diagnóstico. Las puertas de cobertura fueron el módulo 6; aquí no volvemos a ellas.
El detector de humo que suena cuando tuestas pan
Imagina un detector de humo en la cocina de un restaurante. Su trabajo es uno solo y es sagrado: sonar cuando hay fuego. Un día empieza a sonar también cuando alguien tuesta pan, o cuando el vapor de una olla sube. No siempre —a veces el pan tuesta en silencio, a veces suena—, pero lo bastante seguido como para volverse un fastidio. ¿Qué hace el equipo de cocina? Al principio, cada vez que suena, alguien corre a revisar. Después de la décima falsa alarma, dejan de correr. Después de la vigésima, alguien se para en una silla y le saca la batería "hasta que dejemos de cocinar tan cerca". Y una noche, de verdad, se prende un sartén. El detector no suena —no tiene batería— o suena y nadie voltea, porque "otra vez el pan".
El detector no falló por sonar de más. Falló por volverse impredecible, y con eso destruyó lo único que lo hacía útil: que la gente le creyera. Un detector que nunca suena es inútil. Uno que suena con humo real es un tesoro. Pero uno que suena a veces con humo y a veces con pan es peor que no tener ninguno, porque enseña al equipo a ignorarlo justo cuando más importa.
Un test flaky es ese detector. Su alarma es el rojo del CI. Cuando el rojo aparece a veces con un bug real y a veces con "el pan de siempre", el equipo aprende a ignorarlo —a re-correr sin mirar, a mergear "porque seguro es el flaky"—. Y el día que el rojo es un incendio de verdad, ya nadie voltea. Por eso este módulo no trata el flaky como una molestia técnica menor: lo trata como lo que es, un ataque directo a la confianza que hace que un CI sirva de algo.
Un test flaky pasa a veces y falla a veces sin que el código cambie. En CI su veneno no es el rojo en sí, sino que rompe el contrato "rojo = bug": entrena al equipo a desconfiar del semáforo, y un semáforo en el que no confías no protege nada.
Reservo, tal como lo dejamos — y una función que delata la hora exacta
Seguimos con Reservo, el sistema de reservas de salas de un coworking que venimos probando desde el primer módulo. Es lógica pura de Python: sin base de datos, sin red, sin relojes escondidos... hasta hoy. Sus piezas, por si necesitas refrescar:
Room(id,name,capacity,hourly_cents),Member(id,name,tier:"basic"o"pro"),Booking(con su campoprice_cents, elstart, elendcomo rango medio-abierto[start, end), y sustatus).- Las funciones núcleo:
price_cents(room, member, hours),refund_cents(booking, price_paid_cents, now),overlaps,is_available,book, y elCalendarque guarda las reservas en memoria. - Los números-ancla, el checksum de toda la guía: basic 3 h → 7500, pro 3 h → 6000 (20% de descuento), y el reembolso sobre 6000 pagados: 6000 si cancelas 72 h antes (≥ 48 h, 100%), 3000 a 36 h (24–48 h, 50%), 0 a 12 h (< 24 h).
Todo eso ya lo tienes probado, y todo eso es determinista: le das las mismas entradas, te da el mismo resultado, siempre. Un test de price_cents(focus, ana, 3) == 7500 pasa hoy, mañana y en cualquier runner del planeta, porque nada en esa función depende de cuándo la corres.
Para este módulo le agregamos a Reservo algo que sí depende de cuándo lo corres, porque necesitamos un flaky de verdad para estudiarlo. El equipo decidió no registrar en la bitácora de auditoría todas las reservas —serían millones—, sino muestrear más o menos la mitad. Y alguien implementó ese muestreo de la peor manera posible: mirando el reloj de pared.
# reservo/audit.py
from datetime import datetime
def should_audit(now=None) -> bool:
"""Decide si una reserva se registra en el muestreo de auditoria.
IMPLEMENTACION INGENUA — y esa es justo la leccion del modulo. Para no
registrar TODAS las reservas, muestrea ~la mitad mirando la paridad del
microsegundo del reloj de pared: par -> se audita, impar -> se salta.
Como el reloj avanza en cada lectura, la paridad cambia de una llamada a
la siguiente: el resultado NO es determinista. Un test que llame a
should_audit() sin inyectarle `now` sera flaky por construccion.
El parametro `now` es la costura (seam): si se lo pasas, la funcion se
vuelve una funcion pura de su entrada y su resultado es reproducible.
"""
if now is None:
now = datetime.now()
return now.microsecond % 2 == 0
Detente en la última línea: return now.microsecond % 2 == 0. now.microsecond es la parte de microsegundos del instante actual —un número entre 0 y 999999 que cambia cada vez que lees el reloj—. % 2 == 0 pregunta si es par. Como el microsegundo exacto en que corres la función es, para todo fin práctico, azar, la respuesta es "par o impar" con más o menos la misma probabilidad, y cambia en cada llamada. Ese es el corazón del flaky: una decisión que se toma leyendo el reloj de pared, sin que nadie se lo pida, no es reproducible.
Fíjate también en el parámetro now=None. Esa es la costura —el punto por donde, más adelante (lección 7), vamos a inyectar un reloj fijo para curar el flaky de raíz—. Por ahora ignórala: el problema empieza cuando un test llama a should_audit() sin pasarle now, dejando que la función lea el reloj real. Ese test es un flaky.
El primer contacto: la misma suite, dos veredictos
Aquí está el test que un desarrollador bienintencionado escribió para la nueva feature. Léelo: parece perfectamente razonable.
# tests/test_audit.py
from datetime import datetime
from reservo.audit import should_audit
def test_new_booking_is_audited():
# Intencion del autor: "una reserva nueva se audita".
# BUG: should_audit() sin argumento lee el reloj real, y devuelve True solo
# cuando el microsegundo es par (~la mitad de las veces). Este test es FLAKY
# por construccion: a veces pasa, a veces falla, sin tocar el codigo.
assert should_audit() is True
El autor quería afirmar "una reserva nueva se audita", que suena a una verdad de negocio. Pero al llamar should_audit() sin argumento, dejó que la función leyera el reloj, y ató el veredicto del test a la paridad del microsegundo en que corra. Cuando el microsegundo es par, should_audit() devuelve True y el test pasa. Cuando es impar, devuelve False y el test falla con assert False is True. No hay bug en price_cents, ni en refund_cents, ni en nada que le importe a un cliente de Reservo: el bug está en el test, que hizo una pregunta no determinista.
Ejemplo trabajado: corre la misma suite varias veces y mira el veredicto parpadear
Vamos a correr la suite completa de Reservo —los tres tests de precio, los tres de reembolso, y el nuevo de auditoría— en la máquina donde escribo esto, con Python 3.14.0 y pytest 9.1.1. La correremos varias veces seguidas, sin cambiar una sola línea entre corridas, para ver el flaky en acción. Usamos -q (--quiet), que resume cada corrida en una línea.
python -m pytest -q
Qué esperar. No una salida, sino dos distintas, según el microsegundo en que caiga cada corrida. En una corrida donde el reloj estaba en un microsegundo impar, esto es lo que salió, medido de verdad:
> 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 el resumen: 1 failed, 7 passed. Siete tests verdes —los seis anclas de Reservo más la mitad determinista de auditoría que veremos luego— y uno rojo, el flaky, que cayó en un microsegundo impar. Ahora, sin tocar nada, corre exactamente el mismo comando de nuevo, en un microsegundo par:
=========================== short test summary info ============================
...
8 passed in 0.01s
8 passed. Todo verde. El mismo comando, el mismo código, el mismo test —y el veredicto cambió de rojo a verde solo porque el reloj avanzó unos microsegundos entre una corrida y la otra. Esto es un flaky, en vivo, en tu terminal. Si lo corres seis veces seguidas verás algo como failed, passed, passed, passed, passed, passed —a veces rojo, casi siempre verde, sin patrón que dependa de nada que puedas controlar desde el código—.
Detente a sentir lo que esto le hace a un CI. Imagina que esa corrida es el gate de un pull request. Abres el PR, corre la suite, sale rojo: 1 failed. Miras el fallo, no entiendes —tu cambio no tocó auditoría—, vuelves a correr el CI, y ahora sale verde: 8 passed. ¿Qué aprendiste? Que el rojo "no era de verdad". Y la próxima vez que veas un rojo, tu primer instinto ya no será "voy a arreglar el bug" sino "voy a re-correr, seguro es el flaky". Ahí, exactamente ahí, empezó a morir la confianza en tu pipeline.
Qué es un flaky y qué no es
Vale la pena afilar la definición antes de avanzar, porque no todo rojo intermitente es un flaky y no todo flaky se ve igual.
Un flaky es un test cuyo veredicto no es una función determinista del código bajo prueba. Dale el mismo código y el mismo test, y a veces dice una cosa y a veces otra. La causa siempre es una fuente de no-determinismo que se coló en el test o en el código: el reloj de pared (nuestro caso), el orden de ejecución respecto a otros tests, el estado compartido entre tests, la aleatoriedad sin semilla fija, una llamada de red que a veces tarda de más, la concurrencia. En este módulo trabajamos las dos fuentes más comunes en CI: el reloj (este should_audit) y el orden/estado compartido (dos tests de Reservo que se pisan, en la lección 5).
Lo que un flaky no es: no es un test que falla siempre —eso es un fallo honesto, un bug reproducible, y se arregla como cualquier otro—. Tampoco es un test que falla en una versión de Python y pasa en otra de forma consistente —eso es una incompatibilidad de versión, que es el módulo 4—. La marca del flaky es la inconsistencia sin cambio de entrada: mismo código, mismo entorno, resultado que baila. Y su primo, el CI-only, tiene una marca propia: verde en tu máquina, rojo en el runner, porque el runner tiene una condición que tú no (otro orden, otra TZ, otro paralelismo) —a veces de forma intermitente, a veces siempre-rojo-en-CI-siempre-verde-en-local, que sigue contando como flaky para el equipo porque el veredicto depende de dónde corre, no del código—.
Y una honestidad de la guía, la de siempre: el workflow de CI corre en un runner de GitHub que aquí no tenemos, así que el YAML lo escribimos y leemos como contenido. Pero el flaky es real y ejecutado: should_audit mira el reloj de tu máquina igual que miraría el del runner, y el parpadeo 1 failed / 8 passed lo medí corriendo pytest de verdad, no es una maqueta. Cuando cite una salida con RERUN o un conteo, ese número salió de ejecutar el comando.
Errores comunes
Creer que "pasa la mayoría de las veces" es lo mismo que "pasa". Qué pasa: un test flaky da verde nueve de cada diez corridas, y el equipo lo trata como sano —"casi siempre pasa, ha de estar bien"—. Por qué pasa: el verde frecuente engaña; se confunde "probable" con "confiable". Cómo detectarlo: si no puedes correr el test cien veces seguidas y obtener cien verdes, no es confiable, es un flaky con buena racha. Cómo corregirlo: un test o es determinista o no lo es; "casi siempre" es la definición del problema, no una atenuante. Trátalo como flaky desde el primer parpadeo.
Buscar el bug en el código de producción cuando el flaky está en el test. Qué pasa: el CI falla en test_new_booking_is_audited, y el desarrollador se pone a revisar la lógica de auditoría de Reservo buscando qué está mal —cuando should_audit hace exactamente lo que su (mala) implementación dice—. Por qué pasa: se asume que un rojo apunta a un bug de producción, no a un test mal escrito. Cómo detectarlo: si el test pasa y falla sin que el código cambie, el no-determinismo está en el test o en su interacción con el reloj/orden, no en un bug clásico. Cómo corregirlo: pregúntate primero "¿este test hace una pregunta determinista?"; si depende del reloj o del orden, ahí está el problema.
Re-correr el CI hasta que salga verde y mergear. Qué pasa: el rojo aparece, el desarrollador aprieta "re-run" dos o tres veces, sale verde, y mergea sin más. Por qué pasa: es lo que desbloquea el PR ahora mismo, y bajo presión de entrega se siente racional. Cómo detectarlo: si tu forma habitual de "arreglar" un rojo es volver a correrlo, estás tratando el síntoma y dejando el flaky vivo para el siguiente. Cómo corregirlo: re-correr puede ser un triaje de emergencia (lección 3), pero nunca la respuesta; el flaky hay que registrarlo, ponerlo en cuarentena con ticket (lección 4) y arreglarlo de raíz (lección 7). Re-correr y olvidar es cómo un flaky sobrevive meses.
Ejercicios
Ejercicio 1 — Flaky o no flaky. Para cada test, di si es flaky, un fallo honesto, o una incompatibilidad de versión, y por qué en una frase. (a) assert price_cents(focus, ana, 3) == 7500 falla siempre, en toda máquina, con assert 6000 == 7500. (b) assert should_audit() is True pasa unas veces y falla otras en la misma máquina sin cambiar el código. (c) assert list(itertools.batched("ab", 1)) falla en Python 3.11 y pasa en 3.12+, siempre igual.
Ver solución
- (a) Fallo honesto. Falla siempre, con las mismas entradas, en toda máquina: eso es un bug reproducible (aquí,
price_centsestá devolviendo 6000 donde el ancla dice 7500). Se arregla como cualquier bug determinista: encuentras la causa y la corriges. No es flaky —no hay inconsistencia sin cambio de entrada—. - (b) Flaky. Mismo código, misma máquina, veredicto que baila entre pasa y falla: la marca exacta del flaky. La causa es la fuente de no-determinismo (el reloj, vía
should_audit()sinnow). - (c) Incompatibilidad de versión. Falla en 3.11 y pasa en 3.12+ de forma consistente —dale la misma versión y siempre da lo mismo—. Eso no es flaky, es una diferencia de entorno reproducible, y su terreno es el módulo 4 (la matriz), no este.
La regla: flaky = inconsistente sin cambiar la entrada. Si al fijar la entrada (incluida la versión) el resultado se vuelve constante, no es flaky; es un fallo o una incompatibilidad.
Ejercicio 2 — La analogía a la mesa. El detector de humo que suena con el pan tostado enseñó al equipo de cocina a ignorarlo. Mapea cada elemento a su equivalente en CI: (a) el detector de humo, (b) que suene con el pan, (c) sacarle la batería, (d) el incendio real que nadie atiende.
Ver solución
- (a) El detector de humo → el CI (la suite de tests como gate). Su trabajo es sonar (rojo) cuando hay fuego (un bug). Es valioso solo si el equipo le cree.
- (b) Que suene con el pan → el test flaky. Una alarma que se dispara sin fuego real: un rojo que no corresponde a un bug. No siempre, pero lo bastante seguido como para volverse ruido.
- (c) Sacarle la batería → re-correr sin mirar / mergear ignorando el rojo / desactivar el gate. La reacción humana a las falsas alarmas: apagar la señal para dejar de sufrirla —y con eso, quedarse sin protección—.
- (d) El incendio real que nadie atiende → un bug de verdad que pasa disfrazado de flaky. El día que el rojo sí era un bug, el equipo lo re-corre o lo ignora "porque seguro es el flaky", y el bug llega a producción. El flaky no solo desperdicia tiempo: hace que los rojos reales pasen desapercibidos.
La moraleja compartida: el problema no es que la alarma suene de más, es que se vuelve impredecible y con eso pierde la única propiedad que la hacía útil —que le crean—.
Ejercicio 3 — ¿Qué lección resuelve esto? Para cada situación, di qué lección de este módulo la atiende. (a) "El flaky bloquea el PR de todos y quiero desbloquear ya, aunque no lo arregle hoy." (b) "Quiero sacar el flaky del gate mientras investigo, sin borrarlo ni esconderlo." (c) "Este test pasa en mi máquina siempre y solo falla en CI." (d) "Quiero que el flaky deje de ser flaky, de raíz."
Ver solución
- (a) Desbloquear ya, aunque no lo arregle hoy → lección 3 (el debate del retry,
--reruns). Reintentar es la herramienta de desbloqueo inmediato; la lección te da su salida real y, sobre todo, su costo: puede tapar un bug. - (b) Sacarlo del gate mientras investigo, visible → lección 4 (la cuarentena).
@pytest.mark.flakyoxfail(strict=False)con ticket lo aíslan sin borrar la evidencia. - (c) Verde en local, rojo solo en CI → lecciones 5 y 6 (el fallo que solo ocurre en CI y cómo reproducirlo). Ahí está el catálogo de causas y la técnica para forzar en tu máquina la condición del runner.
- (d) Que deje de ser flaky, de raíz → lección 7 (arreglar el determinismo). Retry y cuarentena son triaje; la cura es quitar la fuente de no-determinismo —inyectar el reloj, aislar el estado—.
La regla mecánica: "desbloquear ahora" es retry (3), "aislar para investigar" es cuarentena (4), "solo falla allá" es CI-only (5–6), "curar" es determinismo (7).
Resumen y siguiente paso
En esta lección instalaste la idea que sostiene el módulo: un test flaky pasa a veces y falla a veces sin que el código cambie, y su veneno no es el rojo en sí, sino que rompe el contrato "rojo = bug" y entrena al equipo a desconfiar del semáforo —el detector de humo que suena con el pan hasta que le sacan la batería—. Un CI en el que no confías no protege nada.
Lo viste latir con una demo local real: Reservo estrenó should_audit, un muestreo de auditoría que mira la paridad del microsegundo del reloj de pared, y test_new_booking_is_audited lo llama sin inyectarle now. Corriste la misma suite varias veces y viste el veredicto parpadear —1 failed, 7 passed en un microsegundo impar, 8 passed en uno par—, sin tocar una sola línea entre corridas. También quedó clara la frontera: la diagnosis a fondo de un flaky es la guía hermana; aquí el foco es el flaky en el contexto de CI.
Antes de avanzar deberías poder: definir con tus palabras qué es un flaky y qué lo distingue de un fallo honesto o de una incompatibilidad de versión; explicar por qué un rojo intermitente daña la confianza en el CI; y señalar, en test_new_booking_is_audited, exactamente qué línea introduce el no-determinismo (la llamada should_audit() sin now).
Lo que sigue, en la lección 2, es abrir en canal por qué este daño es peor en CI que en tu máquina. Un flaky local es una molestia tuya; un flaky en CI es una molestia de todos, porque el gate es compartido —bloquea los PRs del equipo entero, invita a re-correr en vez de leer, y hace que los bugs reales pasen disfrazados—. Vamos a medir ese costo con precisión.
Recursos
- Flaky tests — documentación de pytest — la página oficial de pytest sobre qué es un flaky, por qué duele y qué categorías hay. Es la referencia conceptual de todo el módulo; léela para ver que este problema es reconocido y estudiado, no una rareza tuya.
datetime.now()— documentación de Python — la fuente de no-determinismo deshould_audit: lee el reloj de pared en el instante de la llamada. La lección 7 la reemplaza por unnowinyectado; aquí es la raíz del flaky.- pytest-rerunfailures — PyPI — el plugin del retry que ejecutaremos de verdad en la lección 3. Ojéalo ahora para tener el nombre; en la lección 3 lo instalamos y lo corremos sobre este mismo flaky.
- Acerca de la integración continua — GitHub Actions — el contexto de CI donde el flaky hace su peor daño: un gate compartido que corre en cada push. Vuelve a ella para recordar por qué el rojo del CI debía significar algo.