Módulo 7: Flaky en CI y tests que solo fallan allá

5. El fallo que solo ocurre en CI

Descripción

Hasta aquí trabajamos un flaky que parpadea igual en todas partes: should_audit mira el reloj, y ese reloj avanza lo mismo en tu laptop que en el runner. Pero existe una familia entera de fallos con una firma distinta y más desconcertante: verde en tu máquina, siempre; rojo en CI, a veces o siempre. Corres la suite en tu terminal cien veces y pasa las cien; la subes, el runner la corre, y sale roja. No entiendes: "en mi máquina funciona" —la frase que este ecosistema entero existe para desterrar—. Esta lección explica por qué CI ve rojo donde tú ves verde, con un catálogo de las causas reales, y demuestra la más común de todas con dos tests de Reservo que comparten un Calendar: pasan en un orden y fallan en el otro.

La clave para entender el CI-only es dejar de pensar "el CI está roto" y empezar a preguntar "¿qué tiene el runner que mi máquina no?". Porque siempre hay una diferencia concreta —el runner corre los tests en otro orden, o los paraleliza, o tiene otra zona horaria, o le falta un archivo que en tu carpeta existe— y esa diferencia es la que expone un no-determinismo que tu entorno, por pura suerte, mantenía escondido. El CI no inventa el bug: lo revela, porque prueba tu código en condiciones que tú no reproduces sin querer.

Conexión con el módulo: las lecciones 1–4 trataron el flaky visible en todas partes (el reloj) y cómo manejarlo (retry, cuarentena). Esta lección abre la segunda mitad: el flaky que se esconde de ti y solo se muestra en CI. La 6 te enseña a reproducirlo en tu máquina —forzando la condición del runner— apoyándose en el método del módulo 3. Y la 7 lo cura arreglando el determinismo. Aquí el trabajo es de diagnóstico ligero: nombrar la causa. La frontera con la guía hermana test-failure-diagnosis-guide es nítida —la diagnosis a fondo de un flaky vive allá—; aquí clasificamos las causas en el contexto de CI para saber qué condición reproducir.

La casa que solo cruje cuando sopla el viento del norte

Un dueño de casa oye un crujido inquietante en el techo. Llama a un inspector. El inspector viene un día tranquilo, revisa todo, no oye nada: "su casa está perfecta, señor". Se va. Esa noche, el crujido vuelve. El dueño enloquece: ¿está loco?, ¿el inspector es incompetente?

Ninguno de los dos. El crujido solo ocurre cuando sopla el viento del norte, que ejerce una fuerza sobre una viga mal fijada. El día de la inspección no había viento, así que la condición que dispara el crujido no estaba presente, y la casa —de verdad— no crujió. El problema es real y está en la viga; solo que necesita una condición ambiental específica para manifestarse, y esa condición no estaba el día que el inspector midió.

Tu máquina es el inspector en el día sin viento. Corres la suite en tus condiciones —tus tests en tu orden, uno tras otro, en tu zona horaria, con todos tus archivos presentes— y no cruje: verde. El runner de CI es la noche con viento del norte: corre en otro orden, o en paralelo, en UTC, sin los archivos que tú tienes sueltos en tu carpeta. Esa condición distinta ejerce fuerza sobre una "viga mal fijada" de tu código —un test que asume un orden, un estado compartido, una zona horaria— y entonces sí cruje: rojo. El bug estaba ahí todo el tiempo; tu entorno, por suerte, nunca soplaba el viento que lo despierta.

Entender esto cambia tu reacción. En vez de "el CI está roto" (culpar al inspector) o "es un misterio" (rendirse), preguntas: ¿qué viento sopla en CI que no sopla en mi máquina? Y como los vientos posibles son pocos y conocidos, la pregunta tiene respuesta.

Un fallo que solo ocurre en CI no es un CI roto ni un misterio: es un bug real que necesita una condición específica del runner para manifestarse —otro orden, paralelismo, otra zona horaria, un archivo ausente—, una condición que tu máquina, por suerte, no reproduce. El CI no inventa el bug; sopla el viento que lo revela.

El catálogo de vientos: por qué CI difiere de tu máquina

Los "vientos del norte" de CI son un puñado, y conocerlos convierte la caza de un CI-only en un checklist. Estos son los cinco más comunes.

1. El orden de ejecución. Tu máquina corre los tests en un orden (normalmente el de definición, archivo por archivo). CI puede correrlos en otro orden —porque usa pytest-randomly para variarlos a propósito, porque distribuye la suite entre varios procesos, o simplemente porque recolecta los archivos en otro orden—. Si dos tests comparten estado y uno depende de correr antes que el otro, tu orden los deja verdes y el orden de CI los pone rojos. Es la causa que demostraremos.

2. El paralelismo del runner. Si el CI usa pytest-xdist (-n auto, del módulo 5) para correr la suite en varios procesos a la vez, tests que en tu máquina corrían en serie —uno termina antes de que empiece el otro— ahora corren simultáneamente. Cualquier recurso compartido —un archivo temporal, un puerto, una tabla, una variable global— que en serie no daba problema, en paralelo produce una condición de carrera. En tu máquina, sin -n, nunca lo ves.

3. La zona horaria y el locale. Los runners de CI casi siempre corren en UTC y en locale C/en_US.UTF-8. Tu máquina está en tu zona (digamos America/Mexico_City, UTC-6) y tu locale. Un test que compara horas, formatea fechas, o depende del separador decimal o del orden de los nombres, pasa en tu zona/locale y falla en la de CI. (Este es justo el terreno del pytz del módulo 3, ahora visto como fuente de flaky por entorno.)

4. Un archivo o recurso que existe en tu máquina y no en CI. Tienes un config.local.json, un dato de prueba, una variable de entorno exportada en tu .zshrc, una carpeta con permisos abiertos —cosas que viven en tu máquina y no están en el repo—. Tu test los encuentra; el runner, que solo tiene lo que está commiteado, no. El test pasa para ti y falla en CI con un FileNotFoundError o un KeyError que en tu terminal es imposible de reproducir... hasta que borras ese archivo local.

5. Recursos limitados del runner. El runner tiene menos CPU, menos RAM, discos más lentos que tu laptop. Un test con un timeout ajustado —"esto debe responder en 100 ms"— pasa en tu máquina veloz y falla en el runner lento, que necesitó 130 ms. El código no cambió; cambió cuánto tarda, y el test ató su veredicto a un tiempo que solo se cumple en hardware rápido.

Fíjate en el hilo común: en los cinco, el bug ya existía en tu código (un test frágil, un estado compartido, una dependencia oculta) y CI solo aporta la condición que lo manifiesta. Por eso la reacción correcta nunca es "arreglar el CI" sino "identificar qué condición del runner reveló mi bug, y reproducirla" (lección 6) para luego arreglar el determinismo (lección 7).

La causa reina: orden y estado compartido

De las cinco, la más común y la más instructiva es el orden con estado compartido. Vamos a construirla en Reservo, ejecutarla, y verla parpadear según el orden —el CI-only clásico—.

El pecado original es un Calendar compartido a nivel de módulo entre dos tests:

# demo_ci_only/test_shared_calendar.py
from datetime import datetime, timedelta

from reservo.models import Room, Member
from reservo.calendar import Calendar, book, is_available

# TRAMPA: un Calendar compartido a nivel de modulo. Si un test lo muta, el
# siguiente lo ve mutado. El resultado depende del ORDEN de ejecucion.
shared = Calendar()
focus = Room(id="r1", name="Focus", capacity=1, hourly_cents=2500)
ana = Member(id="m1", name="Ana", tier="basic")
start = datetime(2026, 8, 1, 9, 0)


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


def test_b_book_focus():
    b = book(shared, focus, ana, start, start + timedelta(hours=3))
    assert b.price_cents == 7500

Léelo con cuidado. test_a_focus_free_at_nine afirma que la sala Focus está libre a las 9 —cierto si el calendario está vacío—. test_b_book_focus reserva Focus de 9 a 12 —lo que muta el shared para siempre—. Como los dos comparten el mismo shared, el resultado de test_a depende de si corre antes o después de test_b. Antes: el calendario está vacío, la sala está libre, verde. Después: test_b ya reservó, la sala está ocupada, is_available devuelve False, y test_a falla. El veredicto de test_a no depende de su código —depende de quién corrió primero—.

Ejemplo trabajado: verde en tu orden, rojo en el orden de CI

En tu máquina, pytest corre los tests en orden de definición: test_a primero (calendario vacío, pasa), test_b después (reserva, pasa). Todo verde. Con Python 3.14.0 y pytest 9.1.1, medido ejecutando:

python -m pytest demo_ci_only/test_shared_calendar.py -v

Qué esperar (tu orden: a, luego b → verde).

collecting ... collected 2 items

demo_ci_only/test_shared_calendar.py::test_a_focus_free_at_nine PASSED   [ 50%]
demo_ci_only/test_shared_calendar.py::test_b_book_focus PASSED           [100%]

============================== 2 passed in 0.01s ==============================

2 passed. En tu máquina esto es sólido como una roca —lo corres cien veces y pasa cien—, porque el orden de definición siempre pone test_a antes que test_b. Aquí no hay viento: el inspector no oye nada.

Ahora simulemos el orden de CI. En el runner, por cualquiera de las razones del catálogo (randomización, distribución entre procesos, otra recolección), los tests pueden correr al revés: test_b primero. Podemos forzar ese orden en local nombrando los tests explícitamente en el orden inverso —esto es exactamente lo que la lección 6 formaliza como técnica de reproducción—:

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 (orden de CI: b, luego a → rojo).

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
E        +  where False = is_available(<reservo.calendar.Calendar object at 0x102010ad0>, 'r1', datetime.datetime(2026, 8, 1, 9, 0), (datetime.datetime(2026, 8, 1, 9, 0) + datetime.timedelta(seconds=3600)))

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 ==========================

Ahí está el CI-only, reproducido. El mismo código, los mismos dos tests, y solo cambió el orden: test_b corrió primero, reservó Focus, y cuando le tocó a test_a la sala ya estaba ocupada —is_available devolvió False, y assert False is True falló—. En tu orden, verde; en el orden de CI, rojo. Ni el inspector ni tú estaban locos: la casa cruje solo cuando sopla el viento del norte, y "el viento" era el orden inverso.

Detente en lo que esto significa para el diagnóstico. Cuando veas un CI-only, la pregunta no es "¿por qué el CI miente?" sino "¿qué orden/condición usó el runner?". Aquí, si el log de CI mostrara test_b corriendo antes que test_a (o una semilla de randomización), tendrías el viento identificado. La causa es el shared a nivel de módulo, y el arreglo —una fixture que dé un Calendar fresco a cada test— es la lección 7. Por ahora, lo que importa es la clasificación: esto es un flaky de orden por estado compartido, la causa reina del CI-only.

Por qué el retry no salva un flaky de orden

Aquí conviene un adelanto que amarra este tema con la lección 3. El retry (--reruns) reintenta el test fallido, en la misma sesión, con el estado tal como quedó. Para el flaky de reloj, eso funcionaba: cada reintento re-lee el reloj y re-tira el dado. Pero para el flaky de orden, el reintento re-corre test_a con el shared ya mutado por test_b —el calendario sigue ocupado— así que el reintento vuelve a fallar, y otra vez, y otra. El retry no puede rescatar un flaky de orden, porque no revierte el estado que lo causó; solo repite el test en el mismo entorno envenenado.

Esto lo veremos ejecutado en la lección 6, pero anótalo ya: el retry rescata flaky de azar (reloj, red), no flaky de estado (orden, recursos compartidos). Es una razón más para no tratar el retry como cura universal, y una pista de diagnóstico: si --reruns no ayuda, sospecha de estado compartido u orden, no de azar.

Errores comunes

Culpar al CI en vez de buscar la condición. Qué pasa: sale un CI-only y el desarrollador concluye "el runner está mal configurado" o "es cosa de GitHub", y abre un ticket de infraestructura en vez de mirar su código. Por qué pasa: "en mi máquina funciona" empuja a culpar al entorno ajeno. Cómo detectarlo: si tu explicación de un CI-only no nombra una condición concreta del runner (orden, TZ, paralelismo, archivo), no diagnosticaste, solo culpaste. Cómo corregirlo: asume que el bug es tuyo y que CI lo reveló; recorre el catálogo de cinco vientos y encuentra cuál sopla en el runner y no en tu máquina. Casi siempre es uno de esos cinco.

Asumir que "pasa cien veces en mi máquina" prueba que el test es sólido. Qué pasa: alguien corre la suite muchas veces en su laptop, siempre verde, y declara el test confiable —ignorando que su laptop siempre usa el mismo orden, la misma TZ, sin paralelismo—. Por qué pasa: la repetición en un entorno da falsa confianza. Cómo detectarlo: si tus cien corridas fueron todas en las mismas condiciones (mismo orden, misma TZ, sin -n), no probaste robustez, probaste una sola combinación cien veces. Cómo corregirlo: varía las condiciones a propósito —corre con -p randomly, con -n 2, con TZ=UTC— para soplar tú mismo los vientos de CI (lección 6) antes de que el runner los sople por ti.

Confundir un CI-only de orden con el flaky de reloj y tratarlos igual. Qué pasa: el equipo mete todo flaky en el mismo saco y le aplica --reruns a todos. Para el de orden, --reruns no ayuda (el estado sigue envenenado), y el equipo concluye "este flaky es incurable". Por qué pasa: no se distingue la causa del flaky. Cómo detectarlo: si --reruns rescata a unos flaky y a otros no, tienes causas distintas —azar (rescatable) vs. estado/orden (no rescatable por retry)—. Cómo corregirlo: clasifica por causa antes de elegir herramienta. El de orden se arregla aislando el estado (lección 7), no reintentando; que el retry no lo salve es la pista de que su causa es estructural, no de azar.

Ejercicios

Ejercicio 1 — Identifica el viento. Para cada CI-only, di cuál de las cinco causas del catálogo es la más probable y qué condición del runner reproducirías. (a) Un test que formatea una fecha como texto pasa en tu máquina y falla en CI mostrando la hora seis horas distinta. (b) Un test que lee datos/fixture.csv pasa en tu carpeta y en CI falla con FileNotFoundError. (c) Dos tests que escriben en el mismo archivo temporal pasan en serie en tu máquina y fallan intermitentemente en CI, que corre con -n auto.

Ver solución
  • (a) Zona horaria (causa 3). Una diferencia de exactamente seis horas es la firma de tu America/Mexico_City (UTC-6) contra el UTC del runner. Reproducirías forzando TZ=UTC al correr el test en tu máquina (lección 6). El arreglo de fondo: no depender de la zona del sistema —hacer la zona explícita en el código—.
  • (b) Archivo ausente (causa 4). El fixture.csv existe en tu carpeta pero no está commiteado (o está en un .gitignore), así que el runner no lo tiene. Reproducirías borrando/moviendo ese archivo en tu máquina y corriendo el test. El arreglo: commitear el fixture o generarlo en el test.
  • (c) Paralelismo con recurso compartido (causa 2). El -n auto hace que los dos tests corran a la vez y se pisen el archivo temporal —una condición de carrera que en serie no aparece—. Reproducirías corriendo con pytest -n 2 en tu máquina. El arreglo: dar a cada test su propio archivo temporal (una fixture tmp_path), no compartir.

La disciplina común: cada CI-only tiene una condición concreta y reproducible del runner. Nombrarla es el diagnóstico; forzarla en tu máquina (lección 6) es la reproducción; quitar la dependencia de ella (lección 7) es la cura.

Ejercicio 2 — Predice según el orden. Los dos tests de test_shared_calendar.py comparten un Calendar. Para cada orden de ejecución, di qué tests pasan y cuáles fallan, y por qué. (a) test_a, luego test_b. (b) test_b, luego test_a. (c) Solo test_a, sin test_b en la corrida.

Ver solución
  • (a) test_atest_b: ambos pasan (2 passed). test_a corre con el calendario vacío (Focus libre → True, pasa). Luego test_b reserva Focus (pasa). Es el orden de definición, el de tu máquina.
  • (b) test_btest_a: test_b pasa, test_a falla (1 failed, 1 passed). test_b reserva Focus primero (pasa). Luego test_a encuentra el calendario con Focus ya ocupado (is_availableFalse), y assert False is True falla. Es el orden de CI del ejemplo trabajado.
  • (c) Solo test_a: pasa (1 passed). Sin test_b en la corrida, nadie reservó Focus, el calendario está vacío, la sala está libre. Esto revela que test_a aislado es correcto —el problema no es test_a en sí, sino su dependencia del estado que test_b deja—.

La lección: el veredicto de test_a no es una función de test_a; es una función de qué corrió antes. Un test cuyo resultado depende de sus vecinos es un test frágil, y el CI —con su orden distinto— es quien lo delata.

Ejercicio 3 — El viento que el retry no calma. Un compañero pone @pytest.mark.flaky(reruns=5) sobre test_a_focus_free_at_nine para calmar el CI-only, y se queja de que "ni con cinco reintentos pasa en CI". Explica por qué el retry no lo salva, y qué debió hacer en su lugar.

Ver solución

El retry no lo salva porque este flaky es de estado/orden, no de azar. Cuando en CI test_b corre antes y reserva Focus, el shared queda mutado —Focus ocupado— para el resto de la sesión. Cada uno de los cinco reintentos de test_a vuelve a correr con ese mismo shared envenenado, encuentra Focus igual de ocupado, y falla otra vez. El reintento no revierte el estado; solo repite el test en el entorno ya contaminado. A diferencia del flaky de reloj (donde cada reintento re-tira el dado del azar), aquí no hay dado que re-tirar: el resultado es determinista dado el estado, y el estado no cambia entre reintentos.

Lo que debió hacer: (1) clasificar el flaky —el hecho de que --reruns no ayude es la pista de que la causa es estructural (estado/orden), no azar—; (2) reproducirlo forzando el orden de CI en su máquina (lección 6); (3) arreglar el determinismo dando a cada test un Calendar fresco con una fixture (lección 7), lo que elimina el estado compartido y hace que el orden deje de importar. El retry fue la herramienta equivocada porque el problema no era mala suerte, era un diseño que ataba dos tests por un estado común.

Resumen y siguiente paso

En esta lección abriste la segunda mitad del módulo: el fallo que solo ocurre en CI —verde en tu máquina siempre, rojo en el runner—. No es un CI roto ni un misterio, sino un bug real que necesita una condición específica del runner para manifestarse, como la casa que solo cruje con el viento del norte. Aprendiste el catálogo de cinco vientos —orden de ejecución, paralelismo, zona horaria/locale, archivo ausente, recursos limitados— y que en los cinco el bug ya vivía en tu código; CI solo aporta la condición que lo revela.

Y viste la causa reina —orden con estado compartido— ejecutada de verdad: dos tests de Reservo que comparten un Calendar pasan en tu orden (2 passed) y, al forzar el orden de CI (test_b primero), el segundo falla (1 failed, 1 passed) porque encuentra la sala ya reservada. El mismo código, distinto orden, distinto veredicto. Y anotaste una pista clave: el retry no rescata un flaky de orden, porque no revierte el estado que lo causó —si --reruns no ayuda, sospecha de estado, no de azar—.

Antes de avanzar deberías poder: nombrar las cinco causas del CI-only y la condición del runner que reproduce cada una; explicar por qué "pasa cien veces en mi máquina" no prueba robustez; predecir el veredicto de dos tests con estado compartido según su orden; y argumentar por qué el retry no salva un flaky de orden.

Lo que sigue, en la lección 6, es convertir ese diagnóstico en acción: reproducir un CI-only en tu máquina. En vez de esperar a que el runner sople el viento, lo soplas tú —forzando el orden de CI, la TZ, el paralelismo con -n, la randomización— para ver el rojo aparecer en tu terminal a voluntad. Con el fallo reproducido en local, ya puedes arreglarlo (lección 7). Es el método del módulo 3, ahora afilado para el flaky de CI.

Recursos

  • Flaky tests — documentación de pytest — la sección sobre orden de tests y estado compartido: pytest explica que los tests deben ser independientes del orden, exactamente el pecado del Calendar compartido de esta lección.
  • pytest-xdist — documentación — el plugin de paralelismo (-n auto) del módulo 5, aquí visto como fuente de CI-only: correr en paralelo expone recursos compartidos que en serie no dan problema. Lo usaremos para reproducir en la lección 6.
  • pytest-randomly — PyPI — el plugin que randomiza el orden de los tests a propósito, para cazar dependencias de orden como la de esta lección antes de que CI las cace por ti. La lección 6 lo usa para reproducir.
  • Variables de entorno por defecto en runners — GitHub Actions — dónde ver la configuración del runner (zona horaria, sistema) que difiere de tu máquina; la fuente de los "vientos" de TZ/locale del catálogo.