Módulo 8: Proyecto — un pipeline de CI para Reservo

7. La política de flaky

Descripción

Tu pipeline ya es serio: corre en cada push, en tres versiones, con caché, paralelismo y una puerta de cobertura. Pero le falta una respuesta a un problema que la propia lección 5 sembró y que erosiona la confianza como ningún otro: el test flaky —el que a veces pasa y a veces falla sin que el código cambie—. Un rojo honesto dice "hay un bug, arréglalo". Un rojo flaky dice "quizá hay un bug, o quizá no, vuelve a correr y veremos". Y el día que el equipo aprende que el rojo a veces miente, deja de creerle a todos los rojos —incluidos los verdaderos—. Un flaky no es un test que falla; es un test que envenena la señal de todo el pipeline.

Es la capa del módulo 7, y la más de criterio de todas, porque la herramienta fácil —reintentar hasta que pase— es también la trampa fácil. Vas a ver el debate del retry ejecutado de verdad: la corrida sin reintento (1 failed) y con --reruns 2 (RERUNPASSED), el retry por test con @pytest.mark.flaky, y la cuarentena con marcador (-m "not flaky"). Y vas a entender por qué el retry, usado sin disciplina, no arregla el flaky: lo esconde, y esconder un flaky puede esconder un bug real. La política honesta no es "reintenta hasta que pase"; es "parche temporal, ticket, y arreglo de raíz".

Al terminar vas a saber configurar reintentos y cuarentena, reconocer los disparadores de un flaky (orden, paralelismo, recursos, tiempo), y —lo que separa a quien silencia un flaky de quien lo resuelve— aplicar una política que mantiene el pipeline rápido sin dejar que la confianza se erosione.

Conexión con el módulo: esta es la sexta y última capa antes del proyecto, y cierra un círculo abierto en la lección 5. El paralelismo (-n auto) que agregaste para la velocidad cambia el orden de ejecución, y ese cambio es uno de los disparadores clásicos de flaky: un test que asumía correr después de otro se destapa cuando xdist los reparte. Aceleraste allá; manejas el efecto secundario aquí. Y marca una frontera importante: esta lección te enseña a contener un flaky en CI (retry, cuarentena, política), pero diagnosticar por qué un test es no determinista —cazar la causa raíz— es el tema de la guía hermana test-failure-diagnosis. Aquí decides qué hacer con el flaky; allá averiguas por qué lo es.

La alarma de auto que aúlla sin razón

Piensa en un auto cuya alarma se dispara sola. La primera semana, cada vez que aúlla, sales corriendo a ver si te lo están robando. Nunca hay nadie: se dispara con el viento, con un camión que pasa, con nada. Para la tercera semana, cuando la alarma suena, ya ni volteas —"es la alarma otra vez, se dispara sola"—. Y entonces, una noche, alguien de verdad intenta robarlo, la alarma aúlla, y nadie voltea, porque nadie le cree. La alarma que se disparaba sin razón no solo era molesta: destruyó su propia capacidad de avisar de un peligro real.

El problema no es que la alarma suene; es que suena cuando no hay peligro, y esas falsas alarmas entrenan a todos a ignorarla. Una alarma útil tiene que ser confiable: cuando suena, hay algo. El dueño serio no le baja el volumen para no oírla (eso la vuelve inútil de otra forma); arregla el sensor que se dispara con el viento, para que cada aullido vuelva a significar peligro.

Un test flaky es esa alarma. Cuando falla sin que haya un bug —por el orden, por un recurso, por el reloj—, es una falsa alarma. Y las falsas alarmas entrenan al equipo a ignorar los rojos: "el CI está rojo otra vez, dale re-run". El día que un rojo señala un bug real, nadie lo mira, porque el pipeline ya perdió su credibilidad. La tentación es bajarle el volumen —reintentar hasta que pase, para no oír el rojo—, pero eso vuelve la alarma inútil de otra forma: ahora no avisa ni de los peligros reales. La respuesta seria es arreglar el sensor: encontrar por qué el test es no determinista y hacerlo determinista, para que cada rojo vuelva a significar algo.

Un flaky es una alarma que aúlla sin peligro: entrena al equipo a ignorar los rojos, y así destruye la señal de todo el pipeline. Reintentar hasta que pase es bajarle el volumen; la respuesta seria es arreglar el sensor —hacer el test determinista—.

El retry: la herramienta fácil (y su trampa)

pytest-rerunfailures (ya en tu requirements-dev.txt) reintenta los tests que fallan. La bandera --reruns N reintenta hasta N veces antes de declarar el fallo definitivo. Veámoslo con un flaky real: un test que falla la primera vez y pasa al reintentar (modela un test que depende de un recurso que "aún no está listo" en el primer intento).

Sin reintentos, el flaky rompe el build:

python -m pytest flaky_demo/

Qué esperar (real):

=========================== short test summary info ============================
FAILED flaky_demo/test_flaky_confirmation.py::test_booking_confirmation_is_sent - AssertionError: el servicio de confirmaciones no respondio a la primera
========================= 1 failed, 1 passed in 0.03s =========================

Rojo: 1 failed. Ahora con --reruns 2:

python -m pytest --reruns 2 -v flaky_demo/

Qué esperar (real):

collected 2 items

flaky_demo/test_flaky_confirmation.py::test_booking_confirmation_is_sent RERUN [ 50%]
flaky_demo/test_flaky_confirmation.py::test_booking_confirmation_is_sent PASSED [ 50%]
flaky_demo/test_flaky_confirmation.py::test_pricing_is_deterministic PASSED [100%]

========================== 2 passed, 1 rerun in 0.02s ==========================

Verde: 2 passed, 1 rerun. Fíjate en el RERUN: el test falló, pytest-rerunfailures lo volvió a correr, y la segunda vez pasó. El build queda verde y el resumen delata el reintento (1 rerun). Parece la solución perfecta —el flaky ya no rompe el build—, y aquí está la trampa.

El retry no arregla el flaky; lo esconde. El test sigue siendo no determinista —sigue fallando la primera vez—, solo que ahora el pipeline lo disimula reintentando. Y disimular tiene un costo peligroso: si ese test alguna vez falla por un bug real —no por su flakiness habitual, sino porque el código de verdad se rompió—, el retry también lo esconderá, dándote un verde sobre un bug. Bajaste el volumen de la alarma: ya no te molesta, pero ya no avisa de los ladrones tampoco. El 1 rerun en el resumen es la pista de que algo se disimuló; un equipo que ignora esas pistas acumula flaky reintentados hasta que el pipeline es un teatro de verdes que no significan nada.

El retry con disciplina: por test, no global

Un --reruns 2 global reintenta todos los tests, incluidos los sanos y deterministas —así que un bug real en un test bueno también se reintentaría y podría colarse—. La forma disciplinada es reintentar solo el test que sabes flaky, dejando a los demás con su rojo honesto al primer fallo. pytest-rerunfailures lo permite con un marcador por test:

import pytest

@pytest.mark.flaky(reruns=2)
def test_external_calendar_sync():
    ...  # este test golpea un recurso externo que a veces tarda

Ahora solo test_external_calendar_sync se reintenta; el resto de la suite falla a la primera si algo se rompe. Ejecutado de verdad:

python -m pytest -v flaky_demo/test_flaky_marked.py

Qué esperar (real):

flaky_demo/test_flaky_marked.py::test_external_calendar_sync RERUN         [ 50%]
flaky_demo/test_flaky_marked.py::test_external_calendar_sync PASSED        [ 50%]
flaky_demo/test_flaky_marked.py::test_core_pricing_stays_green PASSED      [100%]

========================== 2 passed, 1 rerun in 0.02s ==========================

test_external_calendar_sync se reintentó (RERUNPASSED); test_core_pricing_stays_green pasó a la primera, sin reintentos —conserva su rojo honesto si algún día se rompe—. Esta es la diferencia entre bajarle el volumen a toda la casa y ponerle un parche a la alarma que falla: el retry por test contiene el flaky sin anestesiar el resto de la suite. Sigue siendo un parche —el test sigue siendo flaky—, pero un parche localizado y explícito, que además marca el test como sospechoso para quien lea el código.

La cuarentena: aislar el flaky de la puerta principal

Cuando un flaky es lo bastante molesto o lento de arreglar, la siguiente herramienta es la cuarentena: sacarlo de la corrida principal —la que decide si el merge pasa— sin borrarlo, para que no bloquee al equipo mientras se investiga. Se hace con un marcador y un filtro. El test se marca:

@pytest.mark.flaky(reruns=2)   # el marcador ya lo señala; el filtro lo aísla
def test_external_calendar_sync():
    ...

Y la corrida principal del pipeline lo excluye con -m "not flaky":

python -m pytest -m "not flaky" -v flaky_demo/test_flaky_marked.py

Qué esperar (real):

collected 2 items / 1 deselected / 1 selected

flaky_demo/test_flaky_marked.py::test_core_pricing_stays_green PASSED      [100%]

======================= 1 passed, 1 deselected in 0.01s ========================

1 deselected: el flaky quedó fuera de la corrida principal —no puede romper el build ni con su flakiness ni reintentándose—, y test_core_pricing_stays_green corre normal. La cuarentena convierte "este flaky bloquea a todo el equipo cada vez que aúlla" en "este flaky está aislado, no bloquea el merge, y hay un ticket para arreglarlo". Normalmente se combina con una segunda corrida —no bloqueante— que sí incluye los flaky (-m "flaky"), para seguir vigilándolos sin que frenen la rama principal: si un flaky en cuarentena empieza a fallar siempre (no intermitentemente), eso es señal de un bug real que la cuarentena ayudó a aislar, no a esconder.

La cuarentena es un parche, como el retry —el test sigue siendo flaky—, pero un parche honesto y visible: el deselected en el log grita "hay tests fuera de la puerta principal", y el marcador en el código los nombra. Lo peligroso no es poner un flaky en cuarentena; es dejarlo ahí para siempre, olvidado, hasta que la "cuarentena temporal" es un cementerio de tests que nadie arregla.

Los disparadores: por qué un test es flaky (y por qué solo en CI)

Contener un flaky es esta lección; diagnosticarlo a fondo es la guía hermana. Pero conviene reconocer los disparadores clásicos, porque muchos explican el patrón más desconcertante —el flaky que solo falla en CI, nunca en tu máquina—:

  • El orden de ejecución. Un test que deja estado —un archivo, una variable global, una fila en una BD de prueba— y otro que asume ese estado (o su ausencia). En serie, siempre corren en el mismo orden y el acoplamiento no se ve; con -n auto (lección 5), xdist los reparte en workers distintos y el orden cambia, destapando el fallo. Este es el flaky que el paralelismo del pipeline crea.
  • Los recursos compartidos. Dos tests que usan el mismo puerto, el mismo archivo temporal, la misma tabla. En tu máquina, uno solo; en CI paralelo, chocan.
  • El tiempo y las carreras. Un test que asume que algo tarda "poco" (un sleep corto, un timeout ajustado). Tu laptop es rápida; el runner de CI, cargado y compartido, es más lento, así que el timeout que sobraba en tu máquina se queda corto allá. El flaky que solo el CI lento destapa.
  • La aleatoriedad y las fechas. Un test que usa random sin semilla, o datetime.now() real, o el orden de un dict/set que asume estable. Pasa casi siempre, falla en el 1% de las corridas.

Reservo es lógica pura y determinista —por eso su suite no es flaky—, pero en cuanto un proyecto toca el mundo (red, disco, tiempo, concurrencia), los flaky aparecen, y el CI —paralelo, lento, limpio en cada corrida— es donde más se destapan. La lección práctica: cuando un test falle solo en CI, sospecha primero del orden (¿lo introdujo el paralelismo?), los recursos y el tiempo, antes que del código de negocio.

La política honesta: parche, ticket, arreglo de raíz

Junta las piezas en una política, porque el capstone se evalúa por el método. Ante un flaky:

  1. Contén el sangrado (parche temporal). Retry por test (@pytest.mark.flaky) o cuarentena (-m "not flaky"), para que el flaky deje de bloquear al equipo hoy. Nunca un --reruns global que anestesie toda la suite.
  2. Abre un ticket. El parche es temporal solo si hay un compromiso de arreglarlo. Sin ticket, el parche es permanente y la cuarentena es un cementerio. El marcador flaky en el código debe apuntar a un issue.
  3. Arregla el sensor (raíz). Haz el test determinista: aísla su estado (fixtures que limpian), fija sus recursos (puertos únicos, archivos temporales por test), controla el tiempo (inyecta el reloj en vez de now() real), siembra la aleatoriedad (random.seed). El cómo de esa diagnosis es la guía hermana; el que hay que hacerlo es esta política.
  4. Quita el parche. Cuando el test sea determinista, sácalo de cuarentena y quítale el reruns. Un flaky arreglado vuelve a ser un test normal con su rojo honesto.

Lo que esta política no es: "ponle --reruns 3 y olvídate". Eso es bajarle el volumen a la alarma para siempre. La diferencia entre un equipo que usa retry como parche-con-ticket y uno que lo usa como silenciador permanente es la diferencia entre un pipeline en el que el rojo significa algo y uno en el que nadie mira los rojos. El retry es una aspirina: alivia el síntoma para que puedas funcionar mientras curas la enfermedad, no una cura.

Errores comunes

Usar --reruns global como política permanente. Qué pasa: un equipo, harto de flaky, pone --reruns 3 global en el pipeline y lo deja para siempre. Ahora todos los tests se reintentan, así que no solo se esconden los flaky —también se esconden los bugs reales: un test que empieza a fallar por un bug genuino se reintenta y, si pasa una de tres veces, se cuela en verde. Por qué pasa: es una línea, arregla el rojo molesto de inmediato, y se siente como una solución. Cómo detectarlo: si tu pipeline reintenta todo y llevas meses sin arreglar un solo flaky de raíz, el retry se volvió un silenciador. Cómo corregirlo: retry por test (nunca global), con ticket, como parche temporal; y una disciplina de arreglar la raíz que vacíe la cuarentena en vez de llenarla.

Dejar la cuarentena volverse un cementerio. Qué pasa: los flaky se marcan y se excluyen con -m "not flaky", pero nadie los arregla nunca, así que la cuarentena crece —diez, veinte tests fuera de la puerta principal— hasta que una porción real de la suite ya no protege nada. Por qué pasa: la cuarentena quita el dolor inmediato (el flaky ya no bloquea), y sin dolor no hay urgencia de arreglar. Cómo detectarlo: cuenta los tests en cuarentena y mira sus tickets; si hay muchos y los tickets llevan meses sin tocar, es un cementerio. Cómo corregirlo: cada test en cuarentena necesita un ticket con dueño y fecha; revisa la lista periódicamente; y trata un flaky en cuarentena que empieza a fallar siempre como el bug real que la cuarentena ayudó a aislar. La cuarentena es una sala de espera, no una tumba.

Culpar al código de negocio cuando el flaky es de infraestructura. Qué pasa: un test empieza a fallar de forma intermitente solo en CI, y el equipo revisa una y otra vez la lógica de negocio —el cálculo de precio, la regla de reembolso— sin encontrar nada, porque el código está bien. El flaky es por el orden que el paralelismo cambió, o un recurso compartido, o el runner lento. Por qué pasa: "el test falla" hace pensar primero en lo que el test prueba, no en las condiciones en que corre. Cómo detectarlo: si el fallo aparece solo en CI (no en tu máquina), o solo con -n auto (no en serie), o solo a veces, el disparador es casi seguro de infraestructura —orden, recursos, tiempo—, no de negocio. Cómo corregirlo: reproduce las condiciones del CI (corre en serie vs. paralelo, con -p no:randomly o forzando un orden, en un entorno limpio) para aislar el disparador; la diagnosis a fondo es la guía hermana. Empieza por cómo corre el test, no por qué prueba.

Ejercicios

Ejercicio 1 — Global vs. por test. Un compañero arregla un flaky poniendo --reruns 2 global en el pytest del pipeline. Explica qué riesgo introduce esto para los tests sanos de la suite, y reescribe la solución para que solo el test flaky se reintente.

Ver solución

El riesgo: --reruns 2 global reintenta todos los tests, no solo el flaky. Eso significa que un test sano —determinista, que hoy da un rojo honesto cuando algo se rompe— también se reintentaría si empezara a fallar por un bug real. Si ese bug es intermitente (una carrera nueva, digamos) o si el reintento pasa por casualidad, el retry global lo escondería, dándote un verde sobre un bug genuino. Bajaste el volumen de toda la casa para acallar una alarma: ahora ninguna alarma avisa. El retry global convierte cada test de la suite en potencialmente flaky-tolerante, erosionando la señal de todos.

La solución disciplinada: reintentar solo el test que sabes flaky, con el marcador por test, dejando al resto con su rojo a la primera:

import pytest

@pytest.mark.flaky(reruns=2)
def test_external_calendar_sync():
    ...  # el unico que se reintenta

def test_core_pricing_stays_green():
    ...  # sin marcador: falla a la primera si se rompe, como debe

Y en el pipeline, python -m pytest sin --reruns global —el marcador hace el reintento solo donde se puso—. Así, el flaky se contiene sin anestesiar la suite: test_external_calendar_sync se reintenta, test_core_pricing_stays_green conserva su rojo honesto. La regla: el retry se aplica al test que lo necesita, nunca a la suite entera.

Ejercicio 2 — El flaky que el paralelismo creó. En la lección 5 agregaste -n auto. Una semana después, un test que llevaba meses en verde empieza a fallar en CI ~1 de cada 5 corridas, siempre con un FileNotFoundError sobre un archivo temporal. En serie (pytest sin -n) nunca falla. Explica qué disparador es más probable y qué harías, distinguiendo lo que es de esta guía de lo que es de la guía hermana.

Ver solución

El disparador más probable es un recurso compartido destapado por el cambio de orden que el paralelismo introdujo. La pista fuerte es "en serie nunca falla, con -n auto sí, e intermitentemente": eso apunta a que dos tests usan el mismo archivo temporal (mismo nombre/ruta), y en serie corrían en un orden en que uno lo creaba antes de que el otro lo leyera, así que el acoplamiento nunca se veía. Con -n auto, xdist los reparte en workers distintos que corren a la vez, así que a veces el test que lee el archivo se ejecuta antes (o en paralelo con) el que lo crea, y salta el FileNotFoundError. El paralelismo no causó el bug —el acoplamiento (dos tests compartiendo un archivo con nombre fijo) ya existía—; lo reveló al romper el orden que lo escondía. Es exactamente el círculo que la lección 5 sembró: aceleraste, y la aceleración destapó un flaky.

Qué haría, separando las dos guías: de esta guía (contención + política) — contener el flaky para que no bloquee al equipo hoy: cuarentena (-m "not flaky") o retry por test, con un ticket abierto de inmediato; nunca --reruns global. De la guía hermana (test-failure-diagnosis, la raíz) — cazar y arreglar la causa: dar a cada test su propio archivo temporal (un tmp_path de pytest, único por test, en vez de un nombre fijo compartido), de modo que ningún test dependa del archivo de otro. Una vez que el test sea determinista bajo -n auto, se saca de cuarentena y vuelve a ser normal. La frontera: aquí decido qué hacer con el flaky (contener, ticket, arreglar de raíz como compromiso); el cómo de la diagnosis —reproducir, aislar el recurso, corregir la fixture— es la guía hermana.

Ejercicio 3 — Escribe la política de flaky de Reservo. Reservo hoy es determinista y no tiene flaky, pero el equipo quiere una política escrita para cuando aparezcan (van a integrar una API de pagos externa que a veces tarda). Escribe la política en cuatro o cinco líneas, como iría en el README del proyecto.

Ver solución

Una política razonable para el README:

Política de tests flaky. Un test que falla de forma intermitente sin cambios de código se trata así: (1) Contener — se marca con @pytest.mark.flaky(reruns=2) para reintentarlo por test (nunca --reruns global), o se pone en cuarentena con @pytest.mark.flaky + la corrida principal con -m "not flaky" si es muy molesto; la corrida principal decide el merge, una segunda corrida no bloqueante (-m "flaky") los sigue vigilando. (2) Ticket — todo flaky marcado abre un issue con dueño y fecha; sin ticket, el parche no se acepta. (3) Raíz — se arregla haciendo el test determinista (fixtures que aíslan estado, recursos únicos por test, reloj inyectado, semilla fija); la diagnosis sigue la guía de diagnóstico de fallos. (4) Limpiar — arreglado el test, se le quita el reruns y sale de cuarentena. Un flaky en cuarentena que empieza a fallar siempre se trata como bug real. Nunca se usa retry global como solución permanente: eso esconde bugs, no arregla flaky.

Lo que hace buena a esta política: reconoce el retry como parche temporal con ticket, no como cura; prohíbe explícitamente el --reruns global (el error más común); distingue contener (esta guía) de diagnosticar la raíz (la guía hermana); y cierra el ciclo con "limpiar", para que la cuarentena no se vuelva un cementerio. Es la diferencia entre un equipo cuyo rojo significa algo y uno que reintenta hasta que el pipeline es teatro. Para el caso concreto que menciona el equipo —la API de pagos que a veces tarda—, el retry por test es el parche correcto de arranque, mientras se arregla la raíz (probablemente aislar la llamada externa con un doble de test, para que la suite no dependa de la latencia real de un tercero).

Resumen y siguiente paso

En esta lección apilaste la sexta y última capa: la política de flaky, la respuesta al test que a veces pasa y a veces falla y que erosiona la confianza en todo el pipeline como una alarma que aúlla sin peligro. Viste el debate del retry ejecutado de verdad —sin reintentos (1 failed), con --reruns 2 (RERUNPASSED, 2 passed, 1 rerun)— y entendiste su trampa: el retry no arregla el flaky, lo esconde, y esconderlo puede esconder un bug real. Aprendiste el retry con disciplina (@pytest.mark.flaky por test, nunca global) y la cuarentena (-m "not flaky", 1 deselected) para aislar un flaky de la puerta principal sin borrarlo.

Reconociste los disparadores —orden (que el paralelismo de la lección 5 cambia), recursos compartidos, tiempo, aleatoriedad— y por qué explican el flaky que solo falla en CI. Y armaste la política honesta: parche temporal (retry por test o cuarentena) + ticket + arreglo de raíz + limpiar, con la frontera clara de que contener es esta guía y diagnosticar la raíz es la guía hermana test-failure-diagnosis. La lección de fondo: el retry es una aspirina, no una cura, y un pipeline cuyos rojos a veces mienten es un pipeline en el que nadie mira los rojos.

Antes de avanzar deberías poder: configurar retry por test y cuarentena; explicar por qué el retry global es peligroso; reconocer los disparadores de un flaky (sobre todo el orden que el paralelismo destapa); y escribir una política que contenga el flaky sin dejar que la confianza se erosione.

Lo que sigue, en la lección 8, es el proyecto: juntas las seis capas —base, reproducibilidad, matriz, velocidad, puerta, flaky— en un solo tests.yml, montas la paridad local, y te evalúas por el método de tus decisiones. Y esa lección cierra la guía, con el repaso de los ocho módulos y el camino hacia las guías hermanas del ecosistema de testing. El ensayo de cada sección terminó; llegó el concierto.

Recursos

  • pytest-rerunfailures — el plugin de reintentos: --reruns, --reruns-delay, el marcador @pytest.mark.flaky(reruns=N) por test. La referencia de lo que ejecutaste, con la advertencia (en su propio README) de usarlo con criterio.
  • Working with custom markers — documentación de pytest — cómo declarar y filtrar marcadores (-m "not flaky"), el mecanismo de la cuarentena. Explica el registro de marcadores en pyproject.toml que evita el warning de marcador desconocido.
  • How to manage flaky tests — Google Testing Blog — cómo Google trata los flaky a escala: cuarentena, medición de tasa de flakiness, y por qué el retry sin arreglo de raíz no escala. El respaldo de la política "parche + ticket + raíz".
  • Guía hermana test-failure-diagnosis — donde se diagnostica por qué un test es no determinista (reproducir, aislar el disparador, corregir la fixture). Esta lección te enseñó a contener el flaky; allá aprendes a cazar su causa. La frontera entre las dos guías, hecha explícita.