Módulo 7: Flaky en CI y tests que solo fallan allá
6. Reproducir un CI-only en tu máquina
Descripción
La lección 5 te dio el diagnóstico: un fallo que solo ocurre en CI es un bug real que necesita una condición del runner —otro orden, paralelismo, otra TZ, un archivo ausente— para manifestarse. Pero un diagnóstico sin reproducción es una hipótesis. Esta lección te enseña a convertir la hipótesis en certeza: reproducir el CI-only en tu máquina, soplando tú mismo el viento que el runner sopla, para ver el rojo aparecer en tu terminal a voluntad. Un bug que reproduces es un bug que puedes arreglar (lección 7) y verificar; uno que solo ves en el runner es un fantasma que persigues a ciegas.
La técnica no es nueva —es el método del módulo 3 (reproducir un fallo de CI localmente: leer el log, igualar las capas, correr el mismo comando)—, pero afilada para el flaky de CI, donde la "capa" que hay que igualar no es una versión de dependencia sino una condición de ejecución: el orden, el paralelismo, la zona horaria. Vas a ver, ejecutado de verdad, cómo forzar el orden de CI reproduce el rojo del Calendar compartido, y cómo el retry —que rescataba el flaky de reloj— no rescata este de orden. Marcaremos con precisión la frontera: aquí llegamos hasta reproducir el CI-only en el contexto de CI; la diagnosis a fondo del flaky (aislar el par mínimo, congelar el reloj, cazar la línea) es la guía hermana.
Conexión con el módulo: la lección 5 clasificó las causas del CI-only; esta las reproduce. Es el puente entre "sé qué viento sopla" y "puedo arreglarlo" (lección 7): sin reproducir, no puedes verificar que tu arreglo funcionó —arreglarías a ciegas y esperarías que CI te dé la razón, un ciclo lentísimo—. Reproducir localmente cierra ese ciclo en segundos. Y conecta hacia atrás con el módulo 3, cuyo método reusamos, y hacia el lado con test-failure-diagnosis-guide, donde la diagnosis profunda continúa.
El mecánico que pide "hazlo sonar otra vez"
Cuando llevas el coche al mecánico por un ruido raro, lo primero que hace un buen mecánico no es abrir el motor: es pedirte que reproduzcas el ruido. "¿Suena al frenar? ¿Al girar a la izquierda? ¿En frío o en caliente?". Te sube al coche y recrea las condiciones —frena, gira, acelera— hasta que el ruido aparece ahí, con él escuchando. Solo entonces empieza a diagnosticar, porque ahora tiene el problema en la mano, repetible, y puede probar si su arreglo lo silencia.
Un mecánico que no logra reproducir el ruido está perdido: puede cambiar piezas al azar, entregarte el coche, y que el ruido siga —porque nunca confirmó que tocaba la pieza correcta ni que su cambio funcionó—. La reproducción no es un paso opcional antes del arreglo; es lo que hace posible un arreglo verificable. Sin ella, "arreglar" es adivinar.
Reproducir un CI-only es pedirle a tu máquina que "haga sonar el ruido otra vez". El ruido es el rojo del runner. Las condiciones —frenar, girar— son los vientos del catálogo: el orden, la TZ, el paralelismo. Recreas esas condiciones en tu terminal hasta que el rojo aparece contigo mirando, repetible. Y entonces —solo entonces— arreglas, y verificas que tu arreglo lo silencia corriendo la misma reproducción y viéndola pasar a verde. Sin reproducir, cambiarías código al azar y esperarías a que el runner te diera la razón un push después: el ciclo más lento y frustrante de la ingeniería.
Reproducir un CI-only es recrear en tu máquina la condición del runner —el orden, la TZ, el paralelismo— hasta que el rojo aparece a voluntad. Es lo que convierte un fantasma que solo ves en el runner en un bug que puedes arreglar y verificar en segundos. Sin reproducción, arreglar es adivinar.
El método, afilado para el flaky de CI
El método del módulo 3 se traslada casi tal cual, con el paso 0 (leer el log) idéntico y los pasos de "igualar capas" reinterpretados como "igualar condiciones de ejecución".
Paso 0 — lee el log de CI y extrae la condición. El log te dice qué viento sopló. Busca: el orden en que corrieron los tests (si el runner imprime la lista, o si usa pytest-randomly, la semilla: Using --randomly-seed=1234), si corrió en paralelo (-n auto/-n 4 en el comando), la zona horaria del runner (casi siempre UTC; la cabecera o el env lo dicen), y el archivo o variable que el error menciona (FileNotFoundError: datos/x.csv). Ese es tu "¿suena al frenar o al girar?".
Paso 1 — iguala la condición en tu máquina. Según lo que el log reveló, soplas ese viento en local. Las herramientas, una por causa:
- Orden: nombra los tests en el orden de CI (
pytest a::t2 a::t1), o instalapytest-randomlyy reusa la semilla del log (pytest -p randomly --randomly-seed=1234), o desactiva la randomización para fijar un orden con-p no:randomly. - Paralelismo: corre con
pytest-xdist(pytest -n 2) para recrear la ejecución simultánea del runner. - Zona horaria/locale: antepón
TZ=UTCal comando (TZ=UTC pytest ...), como en el módulo 3. - Archivo ausente: mueve o renombra temporalmente el archivo local que sospechas (
mv datos/fixture.csv /tmp/), para que tu máquina esté tan "desnuda" como el runner.
Paso 2 — corre y observa. Corres con la condición igualada y miras si el rojo aparece. Si aparece, reprodujiste: tienes el bug en la mano. Si no, la condición que igualaste no era el viento correcto —vuelve al paso 0 y prueba otra, de a una (la regla de oro del módulo 3: cambia una condición por vez, para saber cuál era)—.
Paso 3 — el handoff. Con el CI-only reproducido, el módulo termina su parte: sabes qué condición lo dispara y lo tienes repetible. De aquí, dos caminos: si la causa es clara (el shared a nivel de módulo), pasas directo al arreglo del determinismo (lección 7); si necesitas cazar por qué exactamente el código produce el valor equivocado —aislar el par mínimo de tests, poner un breakpoint(), reducir el caso—, eso es la diagnosis a fondo de test-failure-diagnosis-guide.
Ejemplo trabajado 1: reproducir el flaky de orden forzando el orden de CI
Apliquemos el método al CI-only del Calendar compartido de la lección 5. Supón que el log de CI mostró que test_b_book_focus corrió antes que test_a_focus_free_at_nine (o una semilla de randomización que produce ese orden), y que test_a falló con assert False is True.
Paso 0 — condición: el orden inverso al de definición (test_b antes que test_a).
Paso 1 — igualar el orden: nombramos los dos tests en el orden de CI, explícitamente.
Paso 2 — correr y observar. Con Python 3.14.0 y pytest 9.1.1, medido ejecutando:
python -m pytest \
"demo_ci_only/test_shared_calendar.py::test_b_book_focus" \
"demo_ci_only/test_shared_calendar.py::test_a_focus_free_at_nine" -v
Qué esperar.
collecting ... collected 2 items
demo_ci_only/test_shared_calendar.py::test_b_book_focus PASSED [ 50%]
demo_ci_only/test_shared_calendar.py::test_a_focus_free_at_nine FAILED [100%]
=================================== FAILURES ===================================
__________________________ test_a_focus_free_at_nine ___________________________
def test_a_focus_free_at_nine():
# Asume un calendario VACIO. Cierto solo si corre ANTES de test_b.
> assert is_available(shared, "r1", start, start + timedelta(hours=1)) is True
E AssertionError: assert False is True
demo_ci_only/test_shared_calendar.py:16: AssertionError
=========================== short test summary info ============================
FAILED demo_ci_only/test_shared_calendar.py::test_a_focus_free_at_nine - Asse...
========================= 1 failed, 1 passed in 0.01s ==========================
Reproducido. El rojo del runner apareció en tu terminal, a voluntad, solo con igualar el orden. Ahora sabes —no crees— que la causa es el orden con estado compartido: cambiaste esa única condición y el color se movió, mientras que en tu orden de siempre (lección 5) daba verde. Tienes el "ruido" sonando contigo escuchando. El mecánico puede empezar a trabajar.
Un atajo útil para el paso 1 cuando no sabes el orden exacto de CI: instala pytest-randomly (pip install pytest-randomly) y corre la suite varias veces —randomiza el orden en cada corrida, así que tarde o temprano produce el orden malo y reproduce el fallo—. Cuando lo reproduzca, el plugin imprime la semilla (Using --randomly-seed=NNNN); guárdala y reúsala (--randomly-seed=NNNN) para reproducir ese orden exacto a voluntad. Es soplar todos los vientos de orden hasta dar con el que hace crujir la casa, y luego fijarlo.
Ejemplo trabajado 2: el retry NO reproduce ni rescata el flaky de orden
En la lección 5 anotamos que el retry no salva un flaky de orden. Verifiquémoslo ejecutando, porque es una pieza clave del diagnóstico. Corremos la misma reproducción —orden de CI— pero ahora con --reruns 3, esperando (equivocadamente) que reintentar rescate test_a:
python -m pytest --reruns 3 \
"demo_ci_only/test_shared_calendar.py::test_b_book_focus" \
"demo_ci_only/test_shared_calendar.py::test_a_focus_free_at_nine" -v
Qué esperar.
collecting ... collected 2 items
demo_ci_only/test_shared_calendar.py::test_b_book_focus PASSED [ 50%]
demo_ci_only/test_shared_calendar.py::test_a_focus_free_at_nine RERUN [100%]
demo_ci_only/test_shared_calendar.py::test_a_focus_free_at_nine RERUN [100%]
demo_ci_only/test_shared_calendar.py::test_a_focus_free_at_nine RERUN [100%]
demo_ci_only/test_shared_calendar.py::test_a_focus_free_at_nine FAILED [100%]
=================================== FAILURES ===================================
__________________________ test_a_focus_free_at_nine ___________________________
...
E AssertionError: assert False is True
demo_ci_only/test_shared_calendar.py:16: AssertionError
=========================== short test summary info ============================
FAILED demo_ci_only/test_shared_calendar.py::test_a_focus_free_at_nine - Asse...
===================== 1 failed, 1 passed, 3 rerun in 0.02s =====================
Mira los tres RERUN seguidos de FAILED: el retry reintentó test_a tres veces y las tres volvió a fallar. Resumen: 1 failed, 1 passed, 3 rerun. ¿Por qué? Porque test_b ya reservó Focus en el shared, y ese estado no se revierte entre reintentos —cada rerun de test_a corre con el calendario igual de ocupado, así que encuentra is_available en False una y otra vez—. A diferencia del flaky de reloj (donde cada reintento re-tiraba el dado del azar y a veces salía verde), aquí el resultado es determinista dado el estado, y el estado es el mismo en los cuatro intentos.
Esta salida es doblemente valiosa. Primero, confirma la regla: el retry rescata flaky de azar, no de estado/orden. Segundo, es una herramienta de diagnóstico: si pones --reruns sobre un flaky y no ayuda —falla idéntico en todos los reintentos—, acabas de aprender que su causa no es el azar sino el estado o el orden. Un flaky que el retry no calma te está diciendo "mi problema es estructural, arréglame el determinismo (lección 7), no me reintentes".
La frontera: hasta dónde llega este módulo
Conviene ser preciso sobre dónde termina esta lección, porque reproducir no es diagnosticar-a-fondo ni arreglar.
Este módulo te lleva hasta tener el CI-only reproducido en tu máquina —el "ruido sonando contigo escuchando"—, con la condición del runner identificada (aquí, el orden con estado compartido) y repetible a voluntad. Ese es el punto de handoff, y se bifurca:
- Si la causa es evidente desde la reproducción —como aquí, donde el
shareda nivel de módulo salta a la vista— pasas directo a arreglar el determinismo (lección 7): una fixture que dé unCalendarfresco por test, y el orden deja de importar. - Si necesitas entender por qué exactamente —aislar el par mínimo de tests que interactúa, poner un depurador, reducir el caso a lo esencial, congelar variables una por una— ese oficio es la guía hermana
test-failure-diagnosis-guide. Aquí no entramos; nuestro trabajo era darte el CI-only reproducible que esa guía necesita como punto de partida, en el contexto de CI.
La frontera es la misma del módulo 3, aplicada al flaky: reproducir cierra la brecha entre tu máquina y el runner; qué hacer con el fallo reproducido se bifurca entre "la causa es clara, arréglala" (lección 7) y "hay que diagnosticar a fondo" (guía hermana). Sin la reproducción, ninguna de las dos ramas es posible —por eso reproducir es el paso que desbloquea todo lo demás—.
Errores comunes
Intentar arreglar sin reproducir primero. Qué pasa: sale un CI-only, el desarrollador cree saber la causa, cambia código, hace push, y espera que CI le dé la razón —un ciclo de diez minutos por intento, a ciegas—. Por qué pasa: reproducir localmente parece un rodeo cuando "ya sé qué es". Cómo detectarlo: si tu forma de verificar un arreglo de CI-only es hacer push y esperar al runner, no estás reproduciendo, estás adivinando con un ciclo lentísimo. Cómo corregirlo: reproduce en local primero (segundos por intento), arregla, verifica en local que ahora pasa, y solo entonces haz push. El mecánico no te entrega el coche esperando que el ruido se haya ido; lo silencia con él escuchando.
Igualar varias condiciones de golpe. Qué pasa: para reproducir rápido, alguien fuerza a la vez el orden, la TZ y el paralelismo, reproduce el rojo, y no sabe cuál de las tres era la causa. Por qué pasa: la prisa por ver el rojo. Cómo detectarlo: si reprodujiste cambiando tres cosas, sabes que alguna era, no cuál. Cómo corregirlo: la regla de oro del módulo 3 —cambia de a una condición—. Iguala el orden, corre; si no reproduce, revierte y prueba la TZ; y así. Cada corrida te da información limpia sobre una condición, en vez de un dato ambiguo que tendrás que desenredar para arreglar la causa correcta.
Confundir "no reproduje" con "es irreproducible". Qué pasa: alguien fuerza el orden, sigue verde, y concluye "es cosa del CI, no se puede reproducir". Por qué pasa: se asume que el orden era la única causa posible. Cómo detectarlo: si te rendiste tras probar una sola condición del catálogo, no lo agotaste. Cómo corregirlo: recorre los cinco vientos —orden, paralelismo (-n 2), TZ, archivo ausente, y si aplica, recursos— de a uno. Casi todo CI-only es reproducible; "no se puede" casi siempre significa "todavía no soplé el viento correcto". El paso 0 —leer bien el log— es lo que te dice cuál viento probar primero.
Ejercicios
Ejercicio 1 — Elige la herramienta de reproducción. Para cada CI-only diagnosticado, escribe el comando (o la acción) con que lo reproducirías en tu máquina. (a) El log muestra que CI corrió con pytest-randomly y Using --randomly-seed=4242, y un test falló. (b) El log muestra -n auto y dos tests que escriben el mismo archivo fallan intermitentemente. (c) El error es assert 15 == 21, una diferencia de 6 horas, y el runner corre en UTC.
Ver solución
- (a) Reusa la semilla exacta del log para fijar ese orden:
pytest -p randomly --randomly-seed=4242. La semilla reproduce el orden exacto que produjo el rojo; es la forma más fiel de igualar la condición de orden cuando CI randomiza. - (b) Recrea el paralelismo:
pytest -n 2(o-n auto). Correr en dos procesos hace que los dos tests se ejecuten a la vez y se pisen el archivo, igual que en el runner. Quizá haya que correrlo varias veces, porque la condición de carrera es probabilística. - (c) Fuerza la zona del runner:
TZ=UTC pytest ...(el patrón del módulo 3). La diferencia de exactamente 6 horas apunta a tuUTC-6contra elUTCdel runner; anteponerTZ=UTCiguala esa capa.
La disciplina: cada viento tiene su herramienta de reproducción —--randomly-seed para orden, -n para paralelismo, TZ= para zona—. El paso 0 (leer el log) es lo que te dice cuál usar; aplicarla es igualar la condición.
Ejercicio 2 — Interpreta el retry que no ayuda. Corres un flaky con --reruns 3 y ves RERUN, RERUN, RERUN, FAILED —falla en los cuatro intentos, idéntico—. Otro flaky, con el mismo --reruns 3, ves RERUN, PASSED —pasa al segundo intento—. ¿Qué te dice cada patrón sobre la causa de cada flaky, y qué herramienta del módulo le toca a cada uno?
Ver solución
RERUN, RERUN, RERUN, FAILED(falla idéntico en los cuatro): la causa es estructural —estado compartido u orden—, no azar. El reintento no cambia el resultado porque el estado que causa el fallo no se revierte entre intentos (como elCalendarya reservado). Le toca: reproducir la condición (esta lección, forzando el orden/paralelismo) y arreglar el determinismo (lección 7, aislar el estado con una fixture). El retry es inútil aquí.RERUN, PASSED(pasa al reintentar): la causa es azar —el reloj, la red, algo que re-rola en cada intento—. El reintento a veces cae bien. Le toca: como triaje, el retry o la cuarentena por-reintento (@pytest.mark.flaky, lección 4) pueden desbloquear; como cura, arreglar la fuente de azar (lección 7, inyectar el reloj). El retry al menos funciona como parche, aunque no cure.
La lección de diagnóstico: el comportamiento del test bajo --reruns te revela su causa. Si el retry no ayuda, es estructural (estado/orden); si ayuda, es azar. Esa distinción decide qué herramienta del módulo aplicar.
Ejercicio 3 — El ciclo lento vs. el rápido. Un compañero arregla un CI-only así: cambia código, hace push, espera 8 minutos a que CI corra, ve que sigue rojo, cambia otra cosa, push, espera 8 minutos... Lleva cuatro intentos y una hora. Describe el ciclo que la lección propone en su lugar y por qué es más rápido, aunque "reproducir localmente" parezca un paso extra.
Ver solución
El ciclo que propone la lección: (1) reproducir el CI-only en local forzando la condición del runner —una vez, quizá un par de minutos de leer el log y probar el orden/TZ/-n—; (2) con el rojo repetible en tu terminal, arreglar y correr la reproducción de nuevo —segundos por intento—; (3) cuando la reproducción local pasa a verde, hacer push una vez con confianza.
Por qué es más rápido aunque parezca un paso extra: el compañero paga 8 minutos por intento y hace varios intentos a ciegas —su ciclo de feedback es el runner, el más lento posible—. El ciclo de la lección paga la reproducción una sola vez y luego itera contra la terminal local, cuyo feedback es de segundos. Cuatro intentos a ciegas = una hora; cuatro intentos locales = un par de minutos más el setup de reproducción. "Reproducir localmente" no es un paso extra: es lo que reemplaza el ciclo de 8 minutos por uno de segundos. El mecánico que reproduce el ruido con él escuchando arregla en una visita; el que cambia piezas y te pide que "vuelvas si sigue sonando" te hace volver cuatro veces.
Resumen y siguiente paso
En esta lección convertiste el diagnóstico del CI-only en reproducción: soplar tú mismo el viento del runner —el orden, la TZ, el paralelismo— hasta que el rojo aparece en tu terminal a voluntad, como el mecánico que recrea el ruido antes de arreglar. Reusaste el método del módulo 3, afilado para condiciones de ejecución: leer el log (paso 0), igualar la condición con la herramienta correcta (--randomly-seed para orden, -n para paralelismo, TZ= para zona, mover el archivo ausente), correr y observar, de a una condición por vez.
Lo viste ejecutado: forzar el orden de CI reprodujo el rojo del Calendar compartido (1 failed, 1 passed), y --reruns 3 no lo rescató —tres RERUN y FAILED, porque el estado no se revierte entre reintentos—, confirmando que el retry salva flaky de azar, no de orden, y sirviendo de herramienta de diagnóstico. Y marcaste la frontera: aquí llegas hasta reproducir; la diagnosis a fondo es la guía hermana, y la cura es la lección 7.
Antes de avanzar deberías poder: reproducir un CI-only forzando la condición del runner con la herramienta adecuada; interpretar el comportamiento bajo --reruns como pista de la causa (azar vs. estructura); explicar por qué el ciclo de reproducción local es más rápido que iterar contra el runner; y decir dónde termina este módulo y dónde empieza la guía de diagnóstico.
Lo que sigue, en la lección 7, es la cura de verdad. Retry y cuarentena te desbloquearon; reproducir te dio el bug en la mano. Nada de eso arregló nada. La lección 7 arregla el determinismo de raíz: inyectar el reloj (la costura now= de should_audit) para matar el flaky de reloj, y una fixture que dé un Calendar fresco por test para matar el flaky de orden —los dos ejecutados, los dos estables corrida tras corrida—. Es donde el flaky deja de ser flaky.
Recursos
- pytest-randomly — PyPI — el plugin que randomiza el orden de los tests y, crucialmente, imprime y acepta una semilla (
--randomly-seed) para reproducir un orden exacto. La herramienta central para reproducir un flaky de orden a voluntad. - pytest-xdist — documentación — cómo correr en paralelo con
-npara reproducir localmente el paralelismo del runner y exponer condiciones de carrera sobre recursos compartidos. - Seleccionar tests por nodo — documentación de pytest — cómo nombrar tests con
archivo::testy en qué orden, la técnica del ejemplo trabajado 1 para forzar el orden de CI sin plugins. - Reproducir un fallo de CI localmente — módulo 3 de esta guía — el método base que esta lección reutiliza (leer el log, igualar las capas de a una, correr el mismo comando). La diagnosis a fondo del flaky continúa en la guía hermana
test-failure-diagnosis-guide.