Módulo 7: Flaky en CI y tests que solo fallan allá
8. Mini-proyecto: un flaky bloquea el CI de Reservo
Descripción
Llegó el momento de juntar todo el módulo en una decisión real. Las siete lecciones anteriores te dieron el diagnóstico (qué es un flaky, por qué es tóxico en CI), las herramientas de triaje (retry, cuarentena), la caza del CI-only (clasificar, reproducir) y la cura (arreglar el determinismo). Ahora las ejerces todas ante una situación concreta: el flaky de should_audit bloquea el CI de Reservo y frena tres pull requests, y tú tienes que decidir qué hacer —reintentar, poner en cuarentena o arreglar— y justificar la elección con una matriz de decisión honesta y evidencia real.
Este no es un ejercicio de escritura de tests: es el trabajo de juicio que separa a un equipo que se ahoga en flaky de uno que los maneja. La misma herramienta (retry, cuarentena, arreglo) es correcta o desastrosa según el contexto —la urgencia, la causa del flaky, quién está bloqueado—, y elegir bien requiere entender el trade-off de cada una. Vas a producir cuatro entregables: el diagnóstico del flaky (qué es, qué causa, qué tan grave), la matriz de decisión (las tres opciones con su costo y beneficio para este caso), la ejecución de la decisión con evidencia medida de verdad, y una nota de política para que el equipo no repita el problema. Todo con la honestidad de la guía: el YAML del CI es contenido, pero cada salida de pytest —RERUN, xfailed, la suite estable— es real, ejecutada en Python 3.14.0.
Conexión con el módulo: esta lección cierra el arco. La 1 te dio el flaky; la 2, su toxicidad; la 3, el retry; la 4, la cuarentena; la 5, el CI-only; la 6, la reproducción; la 7, la cura. El mini-proyecto las ejerce todas a la vez sobre una decisión que un tech lead toma de verdad. Y mira hacia adelante: al terminar, sabrás manejar el flaky en tu pipeline, la última pieza antes del capstone de la guía (módulo 8), donde armas el pipeline completo de Reservo —suite, matriz, cobertura, caché, y sí, una política de flaky—.
El escenario
Son las 4 de la tarde de un jueves. El equipo de Reservo tiene tres PRs abiertos que necesitan mergear antes del cierre de la semana:
- PR de Ana: cambia el texto de un correo de confirmación. Urgente (marketing lo espera), no toca auditoría.
- PR de Bruno: agrega una sala nueva al catálogo. Importante, no toca auditoría.
- PR de Carla: arregla un bug real de reembolso. Crítico, no toca auditoría.
Los tres tienen el CI en rojo. El fallo, en los tres, es el mismo:
FAILED tests/test_audit.py::test_new_booking_is_audited - assert False is True
1 failed, 7 passed in 0.02s
Ninguno de los tres tocó should_audit. El flaky de auditoría —el que mira el reloj de pared— cayó en un microsegundo impar en la corrida de cada PR, y como el gate no distingue "rojo por bug" de "rojo por flaky", los tres están bloqueados. Es el daño uno de la lección 2, en vivo: un flaky en el gate compartido frena a tres personas que no hicieron nada malo. Tienes que desatascar esto —hoy— y, además, resolver el flaky para que no vuelva a pasar. Tu trabajo:
- Diagnosticar el flaky: qué es, cuál es su fuente de no-determinismo, qué tan grave.
- Construir la matriz de decisión: las tres opciones (reintentar, cuarentena, arreglar) con su costo y beneficio para este caso.
- Ejecutar la decisión, con evidencia real (la salida de pytest que la respalda).
- Escribir la nota de política para el equipo, para que el próximo flaky se maneje sin drama.
Intenta cada paso por tu cuenta antes de mirar la solución. El aprendizaje está en decidir tú, no en leer la decisión.
Paso 1 — Diagnostica el flaky
Antes de elegir herramienta, entiende el enemigo. Corre el flaky varias veces y contesta: ¿es de verdad flaky (parpadea sin cambiar el código)? ¿Cuál es su fuente de no-determinismo? ¿El retry lo rescata (pista de si es azar o estructura)? ¿Qué tan seguido falla?
Piénsalo antes de seguir: ¿la causa de este flaky es azar (reloj/red) o estructura (orden/estado)? Esa pregunta decide qué herramientas siquiera pueden funcionar.
Paso 2 — Construye la matriz de decisión
Para este flaky, en esta situación (tres PRs urgentes bloqueados un jueves a las 4), evalúa las tres opciones. Para cada una: ¿desbloquea hoy? ¿cura el flaky? ¿qué riesgo trae? Recuerda de la lección 3 que el retry es analgésico, de la 4 que la cuarentena aísla con ticket, y de la 7 que solo el arreglo cura.
Piénsalo: ¿alguna opción desbloquea y cura a la vez? ¿O tienes que combinar una de triaje (para hoy) con la cura (para que no vuelva)?
Paso 3 — Ejecuta la decisión con evidencia
Elegida la opción (o la combinación), ejecútala y captura la salida real que la respalda. Si eliges arreglar, muestra la suite estable; si combinas triaje + cura, muestra ambas evidencias.
Paso 4 — Escribe la nota de política
Un párrafo que el equipo pueda pegar en su CONTRIBUTING.md: cómo tratar un flaky la próxima vez, para no improvisar bajo presión.
Solución completa
Entregable 1 — Diagnóstico del flaky
Corro el flaky varias veces para confirmar que parpadea, y con --reruns para leer su causa. Con Python 3.14.0, pytest 9.1.1 y rerunfailures 16.4:
python -m pytest tests/test_audit.py::test_new_booking_is_audited --reruns 3 -v
En una corrida real donde el primer intento cayó impar y el reintento par:
tests/test_audit.py::test_new_booking_is_audited RERUN [100%]
tests/test_audit.py::test_new_booking_is_audited PASSED [100%]
========================== 1 passed, 1 rerun in 0.01s ==========================
El diagnóstico, punto por punto:
- ¿Es flaky? Sí. Corrido muchas veces sin tocar el código, da
1 failed, 7 passeduna de cada dos veces y8 passedla otra. Inconsistencia sin cambio de entrada: la marca del flaky (lección 1). - ¿Fuente de no-determinismo? El reloj de pared.
should_audit()sin argumento cae endatetime.now()y decide por la paridad del microsegundo (now.microsecond % 2 == 0). El testtest_new_booking_is_auditedllamashould_audit()sin inyectarnow, atando su veredicto al instante de la corrida. - ¿Azar o estructura? Azar. El
RERUNseguido dePASSEDlo confirma: el retry sí lo rescata, porque cada reintento re-lee el reloj y re-tira el dado (lección 6). Si fuera estructural —orden/estado—, el retry fallaría idéntico en todos los intentos. Que ayude me dice que es azar del reloj. - ¿Gravedad? Falla ~50% de las corridas. Es de los peores: tan frecuente que bloquea constantemente el gate. Un flaky del 1% se tolera con un reintento; uno del 50% es una barricada.
Conclusión del diagnóstico: flaky de reloj, azar, ~50% de fallo, causa en el test (no en la lógica de negocio de Reservo). La lógica de precio y reembolso está sana —los siete verdes lo confirman—; el problema es un test que hace una pregunta no determinista.
Entregable 2 — La matriz de decisión
Para este flaky (reloj, azar, 50%) en esta situación (tres PRs urgentes bloqueados, jueves 4 pm):
| Opción | ¿Desbloquea hoy? | ¿Cura el flaky? | Riesgo / costo | Veredicto para este caso |
|---|---|---|---|---|
Reintentar (--reruns) | Sí, casi siempre (el 94% con --reruns 3; el retry rescata porque es azar) | No | Reintenta toda la suite; puede tapar bugs reales; perpetúa el flaky si se queda | Útil como triaje inmediato, pero global es tosco |
Cuarentena (xfail/flaky marker) | Sí, del todo (saca el flaky del gate; los tres PRs pasan) | No | Debe llevar ticket y ser temporal; el flaky sigue vivo | Buen triaje quirúrgico: aísla solo el culpable |
| Arreglar (inyectar el reloj) | No de inmediato para los PRs de hoy (hay que escribir el arreglo, revisarlo, mergearlo) | Sí, de raíz | Toma un poco de trabajo ahora | La única cura; imprescindible a mediano plazo |
La lectura de la matriz para este caso: ninguna opción sola es suficiente. El arreglo cura pero no desbloquea hoy mismo (los tres PRs de Ana, Bruno y Carla no pueden esperar a que el arreglo pase por review). El retry y la cuarentena desbloquean hoy pero no curan. Por eso la decisión correcta es una combinación: triaje quirúrgico ahora para desatascar los tres PRs, y el arreglo en paralelo como la resolución real, con la cuarentena atada a un ticket para que no se olvide.
Concretamente: poner el flaky en cuarentena con xfail + ticket (desbloquea los tres PRs de inmediato, quirúrgicamente, sin reintentar toda la suite ni tapar otros posibles rojos) y abrir de una vez el PR del arreglo (inyectar el reloj, la cura de la lección 7). Cuando el arreglo mergee, se quita la cuarentena. La cuarentena es el letrero "cuidado con la baldosa" que ponemos mientras alguien —hoy mismo— nivela la baldosa.
¿Por qué cuarentena y no retry global para el triaje? Porque el retry global (addopts = --reruns 2) reintentaría toda la suite, incluidos los seis anclas deterministas de Reservo y cualquier bug real que fallara honestamente —justo cuando el PR de Carla arregla un bug de reembolso y no quiero que un retry global enmascare una regresión ahí—. La cuarentena con xfail es quirúrgica: aísla solo test_new_booking_is_audited y deja al resto de la suite fallar honesto. En una situación con un PR crítico de reembolso en juego, esa puntería importa.
Entregable 3 — Ejecución de la decisión, con evidencia
Parte A — la cuarentena (triaje de hoy). Marco solo el flaky con xfail, con su ticket, strict=False para que ni falle ni pase rompan el gate:
# tests/test_audit.py
import pytest
from reservo.audit import should_audit
@pytest.mark.xfail(
reason="flaky de reloj — ticket RES-412; arreglo en PR #128 (inyectar now)",
strict=False,
)
def test_new_booking_is_audited():
assert should_audit() is True
Con la cuarentena puesta, el gate se desbloquea caiga como caiga el flaky. Verifico las dos caras, medido ejecutando con -rxX:
# --- corrida donde el flaky fallo -> XFAIL (esperado, no rompe el gate) ---
demo_quarantine/test_xfail_quarantine.py x [100%]
XFAIL ... - flaky de reloj — ticket RES-412; arreglo en PR #128 (inyectar now)
1 xfailed in 0.01s
# --- corrida donde el flaky paso -> XPASS (inesperado, tampoco rompe el gate) ---
demo_quarantine/test_xfail_quarantine.py X [100%]
XPASS ... - flaky de reloj — ticket RES-412; arreglo en PR #128 (inyectar now)
1 xpassed in 0.01s
1 xfailed o 1 xpassed, y en ambos el gate queda verde. Los PRs de Ana, Bruno y Carla ya pueden mergear —el flaky dejó de bloquearlos—, y el flaky sigue visible (con su ticket RES-412 y la referencia al PR del arreglo) en cada corrida. Nadie lo escondió; se aisló con seguimiento.
Parte B — el arreglo (la cura real, PR #128). En paralelo, escribo la cura de la lección 7: inyectar el reloj. El test deja de preguntar "¿qué hora es ahora?" y pregunta "¿qué hace should_audit con este instante?":
# tests/test_audit.py (version curada, reemplaza al flaky)
from datetime import datetime
from reservo.audit import should_audit
FROZEN_EVEN = datetime(2026, 8, 1, 9, 0, 0, 123_456) # microsecond par
FROZEN_ODD = datetime(2026, 8, 1, 9, 0, 0, 123_457) # microsecond impar
def test_booking_is_audited_when_microsecond_is_even():
assert should_audit(FROZEN_EVEN) is True
def test_booking_is_skipped_when_microsecond_is_odd():
assert should_audit(FROZEN_ODD) is False
La prueba de que el arreglo cura: corro los tests deterministas ocho veces seguidas y ninguna parpadea. Medido ejecutando:
2 passed in 0.00s
2 passed in 0.00s
2 passed in 0.00s
2 passed in 0.00s
2 passed in 0.00s
2 passed in 0.00s
2 passed in 0.00s
2 passed in 0.00s
Ocho 2 passed, sin un solo rojo —compara con el 1 failed de una de cada dos corridas del flaky original—. La baldosa está nivelada. Cuando el PR #128 mergee, quito la cuarentena xfail: el flaky ya no existe, así que no hay nada que aislar. Y bonus (lección 7): el arreglo habilitó test_booking_is_skipped_when_microsecond_is_odd, la rama impar que con el reloj real era intestable —el determinismo me regaló cobertura además de estabilidad—.
La secuencia completa, entonces: cuarentena xfail con ticket (hoy, 4 pm → los tres PRs mergean) → PR #128 con la cura (esta tarde/mañana) → merge del arreglo → quitar la cuarentena. Triaje para desbloquear, cura para resolver, y el ticket que amarra uno con el otro para que el triaje no se vuelva permanente.
Entregable 4 — La nota de política
Para pegar en el CONTRIBUTING.md de Reservo:
Política de tests flaky. Un test flaky (pasa a veces, falla a veces sin cambiar el código) nunca se "arregla" re-corriendo el CI hasta que pase: eso lo deja vivo y entrena al equipo a desconfiar del rojo. Ante un flaky que bloquea el gate: (1) Diagnostica su fuente —¿azar (reloj, red) o estructura (orden, estado compartido)?—; una pista rápida: si
--rerunslo rescata, es azar; si falla idéntico en los reintentos, es estructura. (2) Desbloquea hoy con cuarentena quirúrgica:@pytest.mark.xfail(reason="... ticket X", strict=False)sobre solo ese test —nunca retry global, que enmascara toda la suite—. La cuarentena siempre lleva ticket y es temporal. (3) Cura de raíz en un PR: quita la fuente de no-determinismo —inyecta el reloj (parámetronow=/freezegun), aísla el estado con una fixture, siembra la aleatoriedad—. (4) Quita la cuarentena cuando la cura mergee. Revisamos la lista dexfailcada sprint: cero es la meta; unxfailde más de un mes es una alarma. El retry (--reruns) es analgésico de emergencia, con ticket, nunca destino.
Errores comunes
Elegir la herramienta sin diagnosticar la causa. Qué pasa: alguien pone --reruns 5 global "porque hay un flaky", sin ver que —de haber sido un flaky de orden— el retry no habría ayudado, o —como aquí— que había un PR crítico de reembolso que un retry global podía enmascarar. Por qué pasa: bajo presión se agarra la primera herramienta. Cómo detectarlo: si elegiste antes de saber si el flaky es de azar o de estructura, elegiste a ciegas. Cómo corregirlo: el paso 1 (diagnóstico) va antes del paso 2 (decisión); la causa determina qué herramientas pueden funcionar y cuáles son peligrosas en el contexto.
Quedarse en el triaje y no abrir nunca el PR del arreglo. Qué pasa: se pone la cuarentena, los PRs mergean, la urgencia se va, y el arreglo nunca se escribe —el xfail "temporal" cumple seis meses—. Por qué pasa: una vez desbloqueado, desaparece la presión de curar. Cómo detectarlo: si tu cuarentena no tiene un PR de arreglo ya abierto o agendado, se va a volver permanente. Cómo corregirlo: la cuarentena y el PR del arreglo se abren juntos, atados por el ticket; la nota de política incluye la revisión por sprint justo para cazar los xfail que envejecen. Triaje sin cura agendada es negación con fecha abierta.
Usar retry global cuando un PR crítico está en juego. Qué pasa: se activa addopts = --reruns 2 para calmar el flaky, sin notar que eso reintenta toda la suite —incluido el PR de Carla que arregla un bug de reembolso—, así que si ese arreglo tuviera una regresión intermitente, el retry global podría enmascararla. Por qué pasa: el retry global es una línea y "arregla" el flaky visible. Cómo detectarlo: si tu triaje afecta a tests que no son el flaky, es demasiado ancho. Cómo corregirlo: triaje quirúrgico —xfail/flaky sobre el test culpable, no --reruns global—; la puntería importa especialmente cuando hay cambios críticos que necesitan un gate honesto.
Ejercicios
Ejercicio 1 — Cambia el contexto, cambia la decisión. El mismo flaky de reloj, pero ahora el contexto es distinto: es lunes por la mañana, no hay PRs urgentes bloqueados, y tienes toda la semana. ¿Cambia tu decisión respecto a la del mini-proyecto? Justifica.
Ver solución
Sí, cambia —y esto es justo la lección de que la herramienta correcta depende del contexto—. En el mini-proyecto, la urgencia (tres PRs bloqueados un jueves 4 pm) obligaba a un triaje inmediato (cuarentena) más la cura en paralelo. Un lunes sin urgencia, no hay nada que desbloquear con prisa, así que puedes ir directo a la cura sin cuarentena: abres el PR #128 (inyectar el reloj), lo revisan con calma, mergea, y el flaky desaparece —sin pasar por xfail—.
¿Por qué es mejor saltarse el triaje cuando no hay urgencia? Porque la cuarentena, aun bien hecha, es deuda: un test aislado que hay que recordar quitar. Si puedes curar directo, evitas crear esa deuda. La cuarentena existe para desbloquear bajo presión; sin presión, es un rodeo innecesario. La decisión no la dicta solo el flaky (que es el mismo), sino el flaky más el contexto: urgencia alta → triaje + cura; urgencia baja → cura directa.
La moraleja: no hay una respuesta universal a "¿retry, cuarentena o arreglo?". Hay una respuesta para este flaky en este momento. Diagnostica la causa (decide qué puede funcionar) y lee el contexto (decide qué conviene).
Ejercicio 2 — El flaky que el retry no salva. Supón que el flaky que bloquea los tres PRs no fuera el de reloj, sino el de orden de la lección 5 (el Calendar compartido). Rehaz la matriz de decisión: ¿sigue sirviendo el retry como triaje? ¿Y la cuarentena? ¿Cuál es la cura?
Ver solución
La matriz cambia en una casilla clave: el retry ya no sirve como triaje, porque este flaky es de estructura, no de azar. Como vimos en la lección 6, --reruns 3 sobre el flaky de orden da RERUN, RERUN, RERUN, FAILED —falla idéntico en todos los reintentos, porque el Calendar sigue reservado entre intentos—. El retry no desbloquea nada aquí; sería inútil.
- Reintentar: ❌ no desbloquea (falla idéntico en los reintentos; el estado no se revierte) y ❌ no cura. Descartado incluso como triaje.
- Cuarentena (
xfailsobre el test dependiente de orden): ✅ desbloquea (lo saca del gate) pero ❌ no cura. Sigue siendo triaje válido para hoy. - Arreglar (fixture que da un
Calendarfresco por test): ✅ cura de raíz —el orden deja de importar—. Es la lección 7, la cura del flaky de orden.
La decisión: como el retry queda fuera, el triaje de hoy es solo cuarentena (con ticket), y la cura es la fixture. La secuencia: xfail con ticket → PR con la fixture → merge → quitar la cuarentena. Y una lección de diagnóstico que este cambio subraya: la causa del flaky filtra las herramientas de triaje disponibles. Para el flaky de azar (reloj), retry y cuarentena sirven como triaje; para el de estructura (orden), solo la cuarentena —el retry es inútil—. Por eso el paso 1 (diagnosticar azar vs. estructura) es lo primero: descarta opciones antes de que las consideres.
Ejercicio 3 — Defiende la cuarentena ante un jefe impaciente. Tu tech lead dice: "No perdamos tiempo con xfail y tickets. Pon --reruns 3 global en el pytest.ini, que reintente todo, y sigamos —ya arreglaremos el flaky algún día—." Da tres razones, ancladas en el módulo, para preferir la cuarentena quirúrgica + arreglo agendado sobre el retry global indefinido.
Ver solución
Tres razones:
-
El retry global enmascara toda la suite, no solo el flaky (lección 3).
--reruns 3enaddoptsreintenta los seis anclas de Reservo y cualquier bug real que falle honestamente —justo cuando hay un PR crítico de reembolso (Carla) que necesita un gate honesto—. Si ese arreglo tuviera una regresión intermitente, el retry global podría rescatarla y dejarla pasar. La cuarentena conxfailes quirúrgica: aísla solo el flaky y deja al resto de la suite fallar de verdad. -
"Ya lo arreglaremos algún día" es cómo un flaky vive para siempre (lecciones 2 y 4). Un retry global sin ticket no tiene fecha ni dueño; la urgencia desaparece al desbloquear y nadie vuelve. La cuarentena con ticket (
RES-412) y el PR del arreglo abierto hoy atan el triaje a una cura concreta y agendada. El ticket es lo que convierte "algún día" en "PR #128, este sprint". -
El retry global entrena el reflejo tóxico y esconde la deuda (lección 2). Con toda la suite reintentando en silencio, el equipo pierde de vista cuántos flaky tiene y si empeoran —el
X rerunse vuelve ruido de fondo que nadie cuenta—. La cuarentena mantiene el flaky visible (aparece comoxfailed/xpassedcon su ticket en cada corrida), así que la deuda está a la vista y se puede gestionar. Un problema visible con ticket se arregla; uno enmascarado globalmente se pudre.
El cierre honesto: no es que el retry esté prohibido —es un analgésico legítimo de emergencia—; es que el retry global e indefinido combina lo peor (enmascara todo, sin ticket, sin visibilidad). La cuarentena quirúrgica desbloquea igual de rápido, sin esos costos, y con una cura agendada. Desbloqueamos hoy y resolvemos —no una cosa a costa de la otra—.
Resumen y siguiente paso
En este mini-proyecto ejerciste todo el módulo sobre una decisión real: el flaky de should_audit bloqueaba tres PRs urgentes de Reservo, y tuviste que elegir —y justificar— entre reintentar, poner en cuarentena o arreglar. Diagnosticaste el flaky (reloj, azar, ~50%, causa en el test; el retry lo rescata → azar, no estructura). Construiste la matriz de decisión y viste que ninguna opción sola basta: el arreglo cura pero no desbloquea hoy, el triaje desbloquea pero no cura. Ejecutaste la combinación con evidencia real —cuarentena xfail con ticket para desatascar los tres PRs de inmediato (1 xfailed/1 xpassed, gate verde), y el arreglo por inyección del reloj como cura, estable en ocho corridas (2 passed ×8)—. Y escribiste la política para que el equipo maneje el próximo flaky sin improvisar.
La lección de fondo, la que cierra el módulo: la herramienta correcta —retry, cuarentena, arreglo— depende de la causa del flaky (azar vs. estructura filtra qué puede funcionar) y del contexto (la urgencia filtra qué conviene). El triaje desbloquea hoy; la cura resuelve para siempre; y un ticket que ata uno con el otro es lo que evita que el analgésico se vuelva la dieta. Nivelar la baldosa, siempre —y poner el letrero solo mientras alguien la nivela—.
Antes de cerrar deberías poder: diagnosticar un flaky (causa, azar vs. estructura, gravedad); construir una matriz de decisión honesta para un flaky en su contexto; ejecutar la decisión combinando triaje quirúrgico con cura agendada; y escribir una política de flaky para un equipo.
Lo que sigue es el módulo 8, el capstone de toda la guía: armar el pipeline de CI completo para Reservo —la suite corriendo en cada push, la matriz de versiones (módulo 4), la caché y el paralelismo (módulo 5), la puerta de cobertura (módulo 6), y la política de flaky que acabas de diseñar (módulo 7)—. Todo lo que aprendiste, junto, en un solo workflow que protege la rama principal de Reservo sin volverse un cuello de botella. El flaky, que empezó rompiendo la confianza en el semáforo, termina como una pieza más de un pipeline que el equipo sí cree.
Recursos
- Flaky tests — documentación de pytest — el marco completo que este mini-proyecto ejerce: diagnosticar, aislar, y arreglar de raíz, en ese orden. Vuelve a ella para ver que la secuencia triaje→cura es la recomendación oficial, no una opinión de la guía.
- pytest-rerunfailures — repositorio en GitHub — la referencia del retry y del marcador
@pytest.mark.flaky, las opciones de triaje de la matriz de decisión. Los autores insisten, como esta lección, en que el retry es temporal. xfailystrict— documentación de pytest — la herramienta de cuarentena quirúrgica de la decisión:xfail(strict=False)conreason/ticket. Lee por quéstrict=Falsees el correcto para un flaky que a veces pasa.- Fixtures — documentación de pytest y freezegun — PyPI — las dos curas de raíz del ejercicio 2 y del mini-proyecto: la fixture que aísla el estado (flaky de orden) y la congelación del reloj (flaky de reloj). El módulo 8 (capstone) integra la política de flaky que aquí diseñaste en el pipeline completo de Reservo.