Módulo 7: Flaky en CI y tests que solo fallan allá
4. La cuarentena: marcar y aislar
Descripción
El retry global de la lección 3 tiene un problema de puntería: reintenta toda la suite. Si activas --reruns 2, no solo se reintenta el flaky de auditoría —también se reintentan los seis anclas de Reservo, que son deterministas y nunca deberían necesitar un segundo intento, y cualquier bug real que fallara honestamente—. Es como recetarle un antibiótico a toda la familia porque uno tiene infección. Esta lección enseña la alternativa quirúrgica: la cuarentena, que aísla solo el test flaky y deja al resto de la suite en paz, con su rojo honesto intacto.
Cuarentena es un término médico prestado con precisión: separas al enfermo del grupo sano —no para curarlo ahí mismo, sino para que no contagie mientras lo estudias—. En tests, poner un flaky en cuarentena significa sacarlo del gate —que su parpadeo deje de bloquear los PRs de todos— sin borrarlo ni esconderlo, dejándolo visible y rastreado mientras se investiga y se arregla. Vas a ver dos formas de hacerlo, ambas ejecutadas de verdad: @pytest.mark.flaky(reruns=N), que reintenta solo ese test; y @pytest.mark.xfail(reason=..., strict=False), que lo declara "se espera que falle" para que su resultado —falle o pase— no rompa el build, y quede como xfailed o xpassed con su razón a la vista.
Conexión con el módulo: la lección 3 te dio el retry como triaje de emergencia y su peligro (reintentar a ciegas). Esta lección refina el triaje: en vez de reintentar todo, aísla el culpable, con puntería y con visibilidad. La disciplina que aquí aprendes —cuarentena siempre con ticket, siempre temporal, siempre revisada— es la que evita que el triaje se pudra en negación permanente. Y prepara la lección 7, la cura: la cuarentena compra tiempo para investigar y arreglar el determinismo; no lo reemplaza. Un flaky en cuarentena eterna es un flaky que ganó.
El pasajero enfermo que no baja del avión
Imagina un vuelo largo donde un pasajero empieza a toser feo. Hay tres respuestas posibles, y solo una es sensata.
La primera: ignorarlo y seguir como si nada. El riesgo es que contagie a la cabina entera —el equivalente a dejar el flaky en el gate, bloqueando y confundiendo a todos—.
La segunda: fingir que no existe, taparlo, decirle que se calle. Esto es peor: el pasajero sigue enfermo y ahora nadie lo sabe, y cuando el problema estalle (aterrizaje de emergencia médica) nadie estaba preparado. En tests, esto es borrar el test o comentarlo sin dejar rastro: el flaky "desaparece", pero también desaparece la evidencia de que había un problema, y el bug que quizá escondía queda sin vigilancia.
La tercera, la sensata: mover al pasajero a una fila aparte, con atención. No lo bajas del avión (no borras el test), no lo dejas contagiando (no lo dejas en el gate). Lo separas, lo marcas —"este necesita seguimiento"—, y anotas su caso para atenderlo. Sigue a bordo, sigue visible, pero ya no pone en riesgo al resto mientras se le investiga.
Esa tercera respuesta es la cuarentena de un flaky. Ni ignorar (dejarlo bloqueando), ni esconder (borrarlo). Aislar con visibilidad: sacarlo del gate para que no contagie, marcarlo para que todos sepan que está bajo observación, y registrarlo con ticket para que se atienda. La diferencia entre esconder y aislar-para-investigar es toda la diferencia entre negar el problema y manejarlo.
Poner un flaky en cuarentena es aislarlo del gate —que deje de bloquear a todos— sin borrarlo ni esconderlo. Queda visible, marcado y con ticket, mientras se investiga y se arregla. No es una cura: es separar al enfermo para que no contagie mientras lo estudias.
Forma uno: @pytest.mark.flaky — reintentar solo ese test
pytest-rerunfailures (el mismo plugin de la lección 3) ofrece, además del --reruns global, un marcador por test: @pytest.mark.flaky(reruns=N). Puesto encima de un test, dice "reintenta este hasta N veces; el resto de la suite, ni lo toques". Es la puntería que le faltaba al retry global.
# demo_quarantine/test_marker_flaky.py
import pytest
from reservo.audit import should_audit
@pytest.mark.flaky(reruns=3)
def test_audited_marked_flaky():
# Solo ESTE test reintenta (marcador por-test), sin --reruns global.
assert should_audit() is True
La diferencia con la lección 3 es de alcance: aquí no hay --reruns en la línea de comandos. El reintento vive en el marcador, pegado al test culpable, y solo a él. Corres la suite normal —pytest, sin banderas— y los seis anclas de Reservo corren una vez cada uno, como deben; solo test_audited_marked_flaky reintenta si falla.
Ejemplo trabajado 1: el marcador reintenta solo su test
Corramos el test marcado, sin ninguna bandera de reruns, en modo verboso. Con Python 3.14.0, pytest 9.1.1 y rerunfailures 16.4, en una corrida real donde el primer intento cayó impar:
python -m pytest demo_quarantine/test_marker_flaky.py -v
Qué esperar.
collecting ... collected 1 item
demo_quarantine/test_marker_flaky.py::test_audited_marked_flaky RERUN [100%]
demo_quarantine/test_marker_flaky.py::test_audited_marked_flaky PASSED [100%]
========================== 1 passed, 1 rerun in 0.01s ==========================
RERUN y luego PASSED, resumen 1 passed, 1 rerun —igual que en la lección 3, pero sin haber escrito --reruns—. El reintento vino del marcador. Si en esta suite hubiera además un ancla de Reservo, ese ancla habría corrido una sola vez, sin reintentos, porque el marcador no lo alcanza. Esa es la ventaja: el retry queda documentado en el test, visible para quien lo lea, y acotado al único que lo necesita. Nadie que abra el archivo puede ignorar que este test es un flaky reconocido —el marcador lo grita—.
El marcador es una forma legítima de cuarentena cuando el flaky es tolerablemente raro y su verde-por-reintento no esconde un bug grave. Pero fíjate en su límite: reintentar, aunque sea por test, sigue siendo reintentar. Si el test agota sus reruns y falla, vuelve a bloquear el gate. Para un flaky que quieres sacar del gate por completo mientras lo investigas —sin que ni siquiera un mal día de reintentos lo bloquee—, hay una herramienta más contundente: xfail.
Forma dos: xfail — sacarlo del gate por completo
@pytest.mark.xfail es un marcador nativo de pytest (no necesita plugin) que declara: "se espera que este test falle". Con esa declaración, pytest cambia cómo cuenta el resultado:
- Si el test falla, no lo reporta como
FAILED(que rompería el build) sino comoXFAIL("expected failure": falló, como esperábamos, todo en orden). - Si el test pasa, lo reporta como
XPASS("unexpectedly passed": pasó, contra lo esperado).
La clave para la cuarentena es el parámetro strict. Con strict=False (lo que queremos para un flaky), ni XFAIL ni XPASS* rompen el build —el flaky queda completamente fuera del gate, pase o falle—. Con strict=True, un XPASS` sí rompería el build (útil para otros casos, pero no para un flaky, donde no quieres que "pasó por casualidad" te bloquee).
# demo_quarantine/test_xfail_quarantine.py
import pytest
from reservo.audit import should_audit
@pytest.mark.xfail(
reason="flaky en investigacion — ticket RES-412; NO bloquea el gate",
strict=False,
)
def test_audited_quarantined():
# En cuarentena: falle o pase, NO rompe el build. Queda VISIBLE como xfail/xpass.
assert should_audit() is True
Fíjate en el reason. No es decoración: es la etiqueta del pasajero en cuarentena. Dice por qué está aislado (es flaky, está en investigación) y dónde rastrearlo (el ticket RES-412). Un xfail sin reason es un test escondido sin explicación —negación—; un xfail con reason y ticket es cuarentena disciplinada —aislamiento con seguimiento—.
Ejemplo trabajado 2: xfail en sus dos desenlaces
Como el flaky parpadea, un test en xfail(strict=False) cae unas veces en XFAIL (cuando falla) y otras en XPASS (cuando pasa). Veamos ambas, medidas ejecutando, con -rxX para que el resumen liste las razones. Primero, una corrida donde el flaky falló (→ XFAIL, lo esperado):
python -m pytest demo_quarantine/test_xfail_quarantine.py -rxX
Qué esperar (el flaky falló → XFAIL).
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
collected 1 item
demo_quarantine/test_xfail_quarantine.py x [100%]
=========================== short test summary info ============================
XFAIL demo_quarantine/test_xfail_quarantine.py::test_audited_quarantined - flaky en investigacion — ticket RES-412; NO bloquea el gate
============================== 1 xfailed in 0.01s ==============================
Y ahora una corrida donde el mismo flaky pasó (→ XPASS, inesperado pero, con strict=False, inofensivo):
Qué esperar (el flaky pasó → XPASS).
demo_quarantine/test_xfail_quarantine.py X [100%]
=================================== XPASSES ====================================
=========================== short test summary info ============================
XPASS demo_quarantine/test_xfail_quarantine.py::test_audited_quarantined - flaky en investigacion — ticket RES-412; NO bloquea el gate
============================== 1 xpassed in 0.01s ==============================
Lo decisivo: en ambos casos, el resumen final es verde para el gate. 1 xfailed y 1 xpassed son estados que no rompen el build (con strict=False). El flaky ya no bloquea a nadie —caiga como caiga—, y sin embargo sigue visible: la minúscula x (xfail) o mayúscula X (xpass) en la línea de progreso, y el XFAIL/XPASS en el resumen con su razón y su ticket. El pasajero está en su fila aparte, marcado, sin contagiar, y todos pueden ver que está ahí.
Compara con skip. Podrías pensar en @pytest.mark.skip para sacar el flaky del gate, y funciona en cuanto a no bloquear. Pero skip es más opaco: el test no corre, así que pierdes toda información sobre si sigue flaky, si empezó a pasar siempre (señal de que quizá ya se arregló solo), o si degeneró en siempre-rojo (señal de un bug nuevo). xfail(strict=False) sí corre el test y te muestra el resultado (xfail vs xpass), solo que sin dejar que rompa el build. Para un flaky en investigación, xfail observa; skip ciega. Prefiere xfail mientras quieras vigilar el flaky, y reserva skip para tests que de plano no deben correr (por ejemplo, uno que depende de un recurso ausente).
La disciplina que separa cuarentena de basurero
La cuarentena es sana solo si respeta tres reglas. Sin ellas, degenera en un basurero de tests que nadie mira —lo peor de todos los mundos, porque acumula flaky escondidos que ya no protegen nada—.
Regla 1: siempre con ticket. Cada test en cuarentena lleva, en su reason, la referencia a un ticket que lo rastrea (como RES-412). El ticket es la promesa de que la cuarentena es temporal y de que hay trabajo pendiente. Sin ticket, "en cuarentena" se vuelve "olvidado para siempre".
Regla 2: siempre temporal, con fecha o revisión. Una cuarentena sin caducidad es una condena. El equipo debe revisar periódicamente los tests en cuarentena —¿se arreglaron?, ¿siguen flaky?, ¿alguno degeneró en bug real?— y sacarlos (arreglados) o escalarlos (si empeoraron). Un flaky en xfail durante un año no es cuarentena, es un test muerto que finge estar vivo.
Regla 3: contar la cuarentena. El número de tests en cuarentena es una métrica de salud de la suite. Cero es lo ideal. Un puñado, tolerable si están en investigación activa. Docenas, una alarma: significa que el equipo aísla flaky más rápido de lo que los arregla, y la suite se está pudriendo. Cuenta tus xfail/flaky y vigila que la lista no crezca sin bajar.
La cuarentena, bien hecha, es una herramienta de gestión honesta: reconoce que no puedes arreglar todo hoy, desbloquea al equipo sin mentir, y mantiene el problema a la vista con un plan. Mal hecha —sin ticket, sin caducidad, sin conteo— es solo una forma elegante de esconder la basura debajo de la alfombra, con la agravante de que la alfombra parece limpia (el gate está verde) mientras la basura crece debajo.
Errores comunes
Usar xfail(strict=True) para un flaky. Qué pasa: alguien marca el flaky con xfail pero deja strict=True (o no sabe que ese es el default en algunas configs), y entonces cuando el flaky pasa (XPASS), el build se rompe con "unexpectedly passed". Por qué pasa: strict es sutil y su default depende de la config. Cómo detectarlo: si tu test en cuarentena rompe el build al pasar, tienes strict=True. Cómo corregirlo: para un flaky —que por definición a veces pasa— usa strict=False, así ni el fallo ni el pase esperado rompen nada. strict=True es para otro caso (marcar un bug conocido que debe seguir fallando hasta que lo arregles), no para un flaky.
Borrar o comentar el test en vez de ponerlo en cuarentena. Qué pasa: harto del flaky, alguien borra test_new_booking_is_audited o lo comenta. El gate se pone verde y todos contentos. Por qué pasa: borrar es el desbloqueo más rápido de todos. Cómo detectarlo: si un test "desapareció" del historial sin un arreglo que lo justifique, se escondió, no se resolvió. Cómo corregirlo: borrar mata la evidencia —si el flaky escondía un bug real, ahora no hay nada que lo vigile—. Cuarentena con xfail y ticket mantiene el test vivo y visible; el pasajero enfermo se mueve de fila, no se tira del avión.
Dejar la cuarentena para siempre. Qué pasa: el xfail con "ticket RES-412" lleva ocho meses ahí, el ticket está cerrado-por-inactividad, y nadie recuerda por qué. Por qué pasa: la cuarentena desbloquea hoy, y sin una revisión periódica el "temporal" se vuelve permanente por inercia. Cómo detectarlo: revisa las fechas de tus xfail; cualquiera de más de unas semanas sin movimiento es sospechoso. Cómo corregirlo: la regla 2 —cuarentena con caducidad y revisión—; agenda una revisión recurrente de los tests aislados, y trata la lista de cuarentena como deuda que debe bajar, no como un archivero que solo crece. Un flaky curado (lección 7) sale de cuarentena; uno olvidado la convierte en basurero.
Ejercicios
Ejercicio 1 — Lee los desenlaces. Un test en @pytest.mark.xfail(strict=False) se corre cuatro veces en el gate. Los resultados de la línea de progreso son: x, X, x, x. (a) ¿Cuántas veces falló el test y cuántas pasó? (b) ¿Cuántas de esas cuatro corridas rompieron el build? (c) ¿Qué te dice el patrón sobre el estado del flaky?
Ver solución
- (a) La
xminúscula esXFAIL(el test falló, como se esperaba); laXmayúscula esXPASS(el test pasó, inesperado). El patrónx, X, x, xsignifica que falló 3 veces y pasó 1 vez. - (b) Ninguna. Con
strict=False, niXFAILniXPASSrompen el build. Las cuatro corridas dejaron el gate verde —ese es todo el punto de la cuarentena: el flaky ya no bloquea a nadie, caiga como caiga—. - (c) El flaky sigue vivo y sigue parpadeando (3 fallos, 1 pase): no se arregló solo ni degeneró en siempre-rojo. Si hubieras visto
X, X, X, X(siempre xpass), sería señal de que quizá ya pasa siempre —candidato a sacar de cuarentena y verificar—. Si hubieras vistox, x, x, x(siempre xfail), sería señal de que dejó de ser flaky y se volvió siempre-rojo —posible bug determinista nuevo, a investigar—. El valor dexfailsobreskip: te deja ver estas señales porque el test sigue corriendo.
Ejercicio 2 — Marcador vs. xfail. Para cada situación, di si conviene @pytest.mark.flaky(reruns=3) o @pytest.mark.xfail(strict=False), y por qué. (a) Un flaky que falla 1 de cada 50 veces y casi nunca bloquea, pero de vez en cuando molesta. (b) Un flaky que falla ~50% y bloquea constantemente, que necesitas fuera del gate mientras investigas su causa a fondo.
Ver solución
- (a)
@pytest.mark.flaky(reruns=3). Un flaky de 1/50 casi nunca falla, así que un par de reintentos por-test lo rescata prácticamente siempre (fallar dos veces seguidas es 1/2500) sin sacarlo del gate. El test sigue dentro del gate y sigue verificando de verdad su afirmación la mayoría de las veces; el marcador solo absorbe el tropiezo raro. Sacarlo conxfailsería excesivo —perderías la verificación real por un fallo que casi no ocurre—. - (b)
@pytest.mark.xfail(strict=False). Un flaky del 50% que bloquea constantemente no se domestica con reintentos —incluso--reruns 3lo deja bloqueado el 6% de las veces, y estarías reintentando media suite de corridas—. Necesitas sacarlo por completo del gate mientras investigas, y para esoxfail(strict=False)es contundente: falle o pase, no bloquea, y queda visible con su ticket. Es la cuarentena de verdad, no el reintento.
La regla: flaky(reruns=N) para el tropiezo raro que quieres absorber sin sacar del gate; xfail(strict=False) para el flaky grave que necesitas fuera del gate mientras lo curas. Ambos son triaje; la lección 7 es la cura que los saca a los dos.
Ejercicio 3 — Cuarentena sana o basurero. Revisas la suite de un equipo y encuentras esto en tres tests. Clasifica cada uno como cuarentena sana o basurero, y di qué le falta o le sobra. (a) @pytest.mark.xfail(reason="flaky, RES-412, revisar en sprint 14", strict=False), agregado hace una semana. (b) @pytest.mark.xfail(strict=False) sin reason, sin fecha, en el historial desde hace 10 meses. (c) El test simplemente comentado con # esto falla a veces, lo dejé apagado.
Ver solución
- (a) Cuarentena sana. Tiene las tres cosas: ticket (
RES-412), plan de revisión (sprint 14) ystrict=Falsepara no romper el build. Es el pasajero movido de fila, etiquetado y con seguimiento. No le falta nada; solo hay que cumplir la revisión prometida. - (b) Basurero disfrazado de cuarentena.
strict=Falselo saca del gate, sí, pero sinreason, sin ticket y con 10 meses de antigüedad: nadie sabe por qué está aislado ni cuándo se atenderá. Es un test muerto que finge estar en observación. Le falta ticket, razón y —sobre todo— una revisión que lo cure (lección 7) o lo escale. La antigüedad es la alarma. - (c) Basurero puro / test escondido. Comentar el test lo saca del gate matando toda visibilidad: no corre, no se cuenta, no se rastrea, y si escondía un bug real, nada lo vigila. Le sobra el comentario y le falta ser un
xfailcon ticket (para seguir visible) o un arreglo de verdad. Es tirar al pasajero del avión.
El criterio unificado: cuarentena sana = fuera del gate + visible + con ticket + temporal. Quita cualquiera de esos cuatro y tienes basurero. (b) y (c) fallan en visibilidad y/o seguimiento; (a) los cumple.
Resumen y siguiente paso
En esta lección aprendiste la cuarentena: aislar un flaky del gate sin borrarlo ni esconderlo, como el pasajero enfermo que se mueve de fila —ni ignorado ni tirado del avión, sino separado, marcado y con seguimiento—. Viste dos formas ejecutadas de verdad: @pytest.mark.flaky(reruns=3), que reintenta solo ese test (salida RERUN → PASSED, 1 passed, 1 rerun, sin --reruns global); y @pytest.mark.xfail(reason=..., strict=False), que lo saca del gate por completo —caiga en XFAIL (falló, esperado) o XPASS (pasó, inesperado), ninguno rompe el build—, dejándolo visible con su razón y su ticket. Y por qué xfail observa donde skip ciega.
Sobre todo, la disciplina que separa cuarentena de basurero: siempre con ticket, siempre temporal, siempre contada. Una cuarentena bien hecha desbloquea al equipo sin mentir y mantiene el problema a la vista con un plan; mal hecha, esconde basura bajo una alfombra que parece limpia.
Antes de avanzar deberías poder: marcar un flaky con flaky(reruns=N) y con xfail(strict=False); leer xfailed/xpassed y saber por qué ninguno rompe el build; elegir entre marcador y xfail según la gravedad del flaky; y aplicar las tres reglas de la cuarentena sana.
Lo que sigue, en la lección 5, es la otra mitad del módulo: el flaky que solo ocurre en CI. Hasta aquí trabajamos un flaky que parpadea igual en tu máquina y en el runner (el reloj). Pero muchos flaky son verdes en tu laptop, siempre, y solo se ponen rojos en CI —por el orden distinto de ejecución, el paralelismo del runner, su zona horaria, un archivo ausente—. Vas a ver el catálogo de esas causas y, con una demo real de dos tests de Reservo que comparten un Calendar, el CI-only clásico: verde en un orden, rojo en otro.
Recursos
xfailyskip— documentación de pytest — la referencia oficial de@pytest.mark.xfail, el parámetrostrict, y la diferencia conskip. Lee la sección dexfail: explica exactamente por quéstrict=Falsees el correcto para un flaky.- pytest-rerunfailures — repositorio en GitHub — la documentación del marcador
@pytest.mark.flaky(reruns=N)por test, la cuarentena-por-reintento de esta lección. Los autores también advierten sobre no dejarlo permanente, la regla 2 de la cuarentena. -rpara el resumen de tests — documentación de pytest — cómo-rxXhace que el resumen liste losXFAILyXPASScon su razón, la visibilidad que vuelve auditable una cuarentena. Sin él, los tests aislados se pierden de vista.- Flaky tests — documentación de pytest — el marco conceptual: pytest recomienda aislar y rastrear los flaky, no ignorarlos ni borrarlos. La cuarentena de esta lección es esa recomendación puesta en práctica.