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

7. Arreglar el determinismo, no reintentar

Descripción

Llevas seis lecciones acumulando herramientas para sobrevivir a los flaky: el retry los desbloquea, la cuarentena los aísla, la reproducción los pone en tu mano. Todas comparten un límite que ya nombramos varias veces y que ahora toca enfrentar de frente: ninguna arregla nada. El retry reintenta un flaky que sigue tan flaky como antes; la cuarentena esconde un flaky que sigue vivo; reproducir te da un bug que aún no has tocado. Son triaje —analgésicos que te mantienen funcionando— y el triaje sin cura es una condena a repetirlo para siempre. Esta lección es la cura: arreglar el determinismo de raíz, quitar la fuente de no-determinismo que hace que el test baile. Cuando un test es determinista, no necesita retry ni cuarentena, porque ya no falla al azar. Es donde el flaky deja de ser flaky.

La tesis es simple y poderosa: todo flaky tiene, en su fondo, una fuente de no-determinismo —el reloj, el orden, el estado compartido, la aleatoriedad sin semilla, la red—. Curar el flaky es identificar esa fuente y quitarla, no aprender a convivir con ella. Vas a ver las dos curas más comunes ejecutadas de verdad sobre los dos flaky del módulo: inyectar el reloj mata el flaky de should_audit (y de paso hace probable la otra rama, antes intestable), y una fixture que da un Calendar fresco por test mata el flaky de orden (el orden deja de importar). Las dos, corridas muchas veces, salen estables —el parpadeo desaparece—.

Conexión con el módulo: esta es la lección de cierre conceptual, la que da sentido a las anteriores. El retry (3) y la cuarentena (4) son triaje; la reproducción (5–6) es el paso previo a la cura; y esta lección es la cura. La 8, el mini-proyecto, te pone a decidir entre triaje y cura ante un flaky que bloquea el CI de Reservo —y esta lección es la que te dice que la cura siempre es la respuesta correcta a mediano plazo, aunque el triaje te desbloquee hoy—. La diagnosis que precede a la cura (aislar la fuente exacta) se apoya en la guía hermana; aquí las fuentes son evidentes y vamos directo a quitarlas.

Nivelar el piso en vez de acostumbrarse a tropezar

Imagina una oficina donde hay una baldosa suelta a la entrada. Cada tanto, alguien la pisa mal y tropieza. Las "soluciones" que un equipo puede adoptar son reveladoras:

  • Poner un letrero "cuidado con la baldosa". Ayuda a los que lo leen, pero el peligro sigue ahí y los nuevos tropiezan igual. (Es la cuarentena: marcas el problema, no lo quitas.)
  • Pedirle a todos que "pisen con cuidado ahí". Funciona a veces; bajo prisa, la gente olvida y tropieza. (Es el retry: reintentas cruzar hasta que pasas.)
  • Nivelar la baldosa. Una vez hecho, el problema desaparece para siempre: nadie tropieza, no hace falta letrero ni cuidado, y los nuevos ni se enteran de que hubo un peligro.

Las dos primeras son gestión del síntoma —convivir con la baldosa suelta—. La tercera es la cura —quitar la causa—. Y fíjate en la asimetría de esfuerzo: nivelar la baldosa cuesta un rato una vez; poner letreros y pedir cuidado cuesta para siempre, porque el peligro nunca se va. El triaje parece más barato ("solo un letrero") pero se paga en cada tropiezo futuro; la cura parece más cara ("hay que levantar la baldosa") pero se paga una sola vez.

Un flaky es la baldosa suelta. El retry y la cuarentena son el letrero y el "pisa con cuidado" —útiles para no romperte la cara hoy, inútiles para que el peligro desaparezca—. Arreglar el determinismo es nivelar la baldosa: quitas la fuente de no-determinismo, y el flaky no vuelve, no necesita letrero, y el siguiente que llegue al proyecto ni sabrá que hubo un problema. Este módulo te enseñó a poner letreros y pedir cuidado porque a veces hay que cruzar la entrada ya; pero la meta, siempre, es nivelar la baldosa.

Todo flaky esconde una fuente de no-determinismo: el reloj, el orden, el estado compartido, la aleatoriedad sin semilla, la red. Retry y cuarentena conviven con ella; arreglar el determinismo la quita. La cura no es aprender a cruzar la baldosa suelta con cuidado —es nivelarla, para que nadie vuelva a tropezar.

Cura uno: inyectar el reloj (matar el flaky de reloj)

El flaky de should_audit tiene una fuente de no-determinismo clarísima: la función lee el reloj de pared (datetime.now()) por su cuenta, así que su resultado depende del instante de la llamada. La cura no es reintentar hasta que el reloj caiga bien —eso es cruzar la baldosa con cuidado—; es quitarle a la función el poder de leer el reloj sola y, en su lugar, inyectarle el instante desde afuera. Ese punto de inyección es la costura (seam) que should_audit ya tenía: el parámetro now=None.

Recuerda la firma:

# reservo/audit.py
def should_audit(now=None) -> bool:
    if now is None:
        now = datetime.now()          # <- la fuente de no-determinismo
    return now.microsecond % 2 == 0

El flaky nacía porque el test llamaba should_audit() sin argumento, dejando que la función cayera en el datetime.now(). La cura, del lado del test, es pasarle un now fijo: un instante congelado que tú eliges, con un microsegundo conocido. Así el test deja de preguntar "¿qué hora es ahora?" (no determinista) y pregunta "¿qué hace should_audit con este instante concreto?" (determinista).

# demo_fixed/test_audit_fixed.py
from datetime import datetime

from reservo.audit import should_audit

# El reloj congelado que ANTES estaba escondido: ahora es un dato explicito
# del test. microsecond par (123456) -> should_audit devuelve True SIEMPRE.
FROZEN_EVEN = datetime(2026, 8, 1, 9, 0, 0, 123_456)
FROZEN_ODD = datetime(2026, 8, 1, 9, 0, 0, 123_457)


def test_booking_is_audited_when_microsecond_is_even():
    # Determinista: le inyectamos el reloj. Ya no depende del momento de la corrida.
    assert should_audit(FROZEN_EVEN) is True


def test_booking_is_skipped_when_microsecond_is_odd():
    # Y ahora podemos probar la OTRA rama, imposible de fijar con el reloj real.
    assert should_audit(FROZEN_ODD) is False

Fíjate en el segundo test, test_booking_is_skipped_when_microsecond_is_odd. Inyectar el reloj no solo curó el flaky: habilitó probar la rama que antes era intestable. Con el reloj real, no podías forzar un microsegundo impar a voluntad —dependías de la suerte—, así que la rama "impar → no se audita" nunca se probaba de forma confiable. Con el now inyectado, eliges FROZEN_ODD y verificas esa rama con certeza. La costura que cura el flaky es la misma que te da cobertura de verdad: un test determinista no solo es estable, es más completo.

Ejemplo trabajado 1: el flaky de reloj, ahora estable corrida tras corrida

La prueba de que una cura funcionó es que el parpadeo desaparece. Corramos los dos tests deterministas ocho veces seguidas —igual que hicimos con el flaky en la lección 1, cuando bailaba entre 1 failed y 8 passed—. Con Python 3.14.0 y pytest 9.1.1, medido ejecutando las ocho corridas:

for i in $(seq 1 8); do python -m pytest demo_fixed/test_audit_fixed.py -q; done

Qué esperar. Ocho corridas idénticas, todas verdes, sin un solo parpadeo:

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 excepción. Compara con la lección 1, donde la misma clase de test daba 1 failed una de cada dos veces. La baldosa está nivelada: el reloj ya no manda, así que el resultado es una función pura de las entradas (FROZEN_EVEN, FROZEN_ODD) que tú controlas. Este test ya no necesita --reruns, ni @pytest.mark.flaky, ni cuarentena —no falla al azar porque no depende de ningún azar—. Y en verboso:

collecting ... collected 2 items

demo_fixed/test_audit_fixed.py::test_booking_is_audited_when_microsecond_is_even PASSED [ 50%]
demo_fixed/test_audit_fixed.py::test_booking_is_skipped_when_microsecond_is_odd PASSED [100%]

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

Las dos ramas de should_audit probadas, deterministas, ambas verdes, siempre. Eso es curar un flaky: no que pase esta vez, sino que no pueda fallar al azar nunca.

Un matiz honesto: inyectar el reloj cura el test. Si además quieres que la función de producción sea determinista (que en producción no muestree por reloj, que es una idea dudosa de por sí), el arreglo de fondo sería cambiar la estrategia de muestreo por una determinista —por ejemplo, muestrear según un hash estable del id de la reserva, no según el reloj—. Ese es un rediseño de la feature, más allá del alcance del módulo; aquí la lección es la costura que vuelve el test determinista, que es lo que quita el flaky del CI.

Cura dos: aislar el estado (matar el flaky de orden)

El flaky de orden de la lección 5 tenía otra fuente de no-determinismo: un Calendar compartido a nivel de módulo entre dos tests, así que el veredicto de uno dependía de si el otro corrió antes. La cura no es forzar un orden fijo ni reintentar —eso es cruzar la baldosa con cuidado—; es quitar el estado compartido, dándole a cada test su propio Calendar fresco. Sin estado común, el orden deja de importar: cada test parte de cero, aislado de sus vecinos.

La herramienta de pytest para esto es la fixture: una función marcada con @pytest.fixture que produce un objeto nuevo, y que pytest ejecuta una vez por cada test que la pida. Cada test recibe su propia instancia, recién creada, sin rastro de lo que hicieron los demás.

# demo_fixed/test_isolated_calendar.py
from datetime import datetime, timedelta

import pytest

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

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)


@pytest.fixture
def calendar():
    # Cada test recibe un Calendar NUEVO. Sin estado compartido, sin orden.
    return Calendar()


def test_a_focus_free_at_nine(calendar):
    assert is_available(calendar, "r1", start, start + timedelta(hours=1)) is True


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

Compara con la versión enferma de la lección 5. Allá, shared = Calendar() vivía a nivel de módulo, uno solo para los dos tests. Aquí, calendar es una fixture, y cada test la recibe como parámetro (def test_a_focus_free_at_nine(calendar)): pytest llama a la función calendar() de nuevo para cada test, así que test_a obtiene un Calendar vacío y test_b obtiene otro Calendar vacío, independientes. Cuando test_b reserva Focus, muta su calendario, no el de test_a. El estado compartido desapareció, y con él, la dependencia de orden.

Ejemplo trabajado 2: el flaky de orden, ahora indiferente al orden

La prueba de esta cura es que el fallo que la lección 5 provocaba cambiando el orden ya no ocurre en ningún orden. Corramos los dos tests aislados en el orden de definición y en el orden inverso —el que antes ponía todo rojo—. Con Python 3.14.0 y pytest 9.1.1, medido ejecutando:

# orden de definicion
python -m pytest demo_fixed/test_isolated_calendar.py -v
# orden inverso (el que rompia antes)
python -m pytest \
  "demo_fixed/test_isolated_calendar.py::test_b_book_focus" \
  "demo_fixed/test_isolated_calendar.py::test_a_focus_free_at_nine" -v

Qué esperar. Los dos órdenes, verdes:

# --- orden de definicion ---
demo_fixed/test_isolated_calendar.py::test_a_focus_free_at_nine PASSED   [ 50%]
demo_fixed/test_isolated_calendar.py::test_b_book_focus PASSED           [100%]
============================== 2 passed in 0.01s ==============================

# --- orden inverso (el que rompia antes) ---
demo_fixed/test_isolated_calendar.py::test_b_book_focus PASSED           [ 50%]
demo_fixed/test_isolated_calendar.py::test_a_focus_free_at_nine PASSED   [100%]
============================== 2 passed in 0.01s ==============================

2 passed en ambos órdenes. En la lección 5, invertir el orden daba 1 failed, 1 passedtest_a encontraba Focus ya reservado por test_b—. Ahora, con la fixture, test_a corra antes o después, siempre recibe un Calendar vacío recién creado, así que Focus siempre está libre para él, y test_b reserva en su propio calendario sin afectar a nadie. El orden dejó de importar porque el estado compartido —la baldosa suelta— desapareció. Este flaky, que el retry no podía rescatar (lección 6), quedó curado de raíz por el aislamiento.

Fíjate en algo que amarra el módulo: este flaky era de los que el --reruns no salvaba (lección 6), porque su causa era estructural, no de azar. La cura estructural —aislar el estado— es la única que funciona para él. Es la confirmación de la tesis: la herramienta que cura un flaky depende de su fuente de no-determinismo, y para el estado/orden, esa herramienta es el aislamiento, nunca el reintento.

El mapa de curas por fuente de no-determinismo

Las dos curas que ejecutaste son casos de un principio general. Cada fuente de no-determinismo tiene su cura, y todas comparten la forma: quitar la dependencia del mundo no controlado, e inyectar/aislar en su lugar.

  • Reloj (datetime.now(), time.time()): inyecta el instante como parámetro o fixture; usa un reloj fijo. (Bibliotecas como freezegun automatizan congelar el reloj para toda una función.) Lo hiciste con should_audit(now=...).
  • Orden / estado compartido (variables de módulo, singletons, archivos comunes): aísla con fixtures que dan un objeto fresco por test; nunca compartas estado mutable entre tests. Lo hiciste con la fixture calendar.
  • Aleatoriedad (random, UUIDs, datos generados): fija la semilla (random.seed(0)) o inyecta el valor; haz el resultado reproducible.
  • Red / servicios externos (APIs, bases de datos): usa dobles de prueba (mocks/fakes) para la suite unitaria; aísla los tests de integración reales del gate rápido, o dales retry legítimo (el caso del "primer cajero" de la lección 3).
  • Concurrencia / recursos limitados (timeouts, puertos, temporales): da a cada test su propio recurso (tmp_path), evita timeouts basados en tiempo real de pared, no asumas velocidad de hardware.

El patrón unificado: un test determinista no depende de nada que no controle desde el propio test. Si su resultado puede cambiar sin que tú cambies su entrada —porque el reloj avanzó, porque otro test corrió antes, porque el dado del random cayó distinto—, hay una fuente de no-determinismo que inyectar o aislar. Curar el flaky es cerrar esa puerta al mundo no controlado.

Errores comunes

Tratar el triaje como destino. Qué pasa: el equipo pone --reruns o xfail sobre un flaky "por ahora", y ese "por ahora" se vuelve permanente —el flaky vive en triaje para siempre, nunca se cura—. Por qué pasa: el triaje desbloquea, y una vez desbloqueado desaparece la urgencia de curar. Cómo detectarlo: si tienes flaky con retry o en cuarentena desde hace meses sin un intento de arreglo, el triaje se volvió destino. Cómo corregirlo: cada triaje lleva ticket con la cura pendiente (lección 4), y la cura —inyectar el reloj, aislar el estado— se agenda, no se pospone indefinidamente. El letrero "cuidado con la baldosa" nunca fue la solución; nivelar la baldosa sí.

Curar el síntoma sin encontrar la fuente. Qué pasa: alguien "arregla" el flaky de orden fijando el orden de los tests (-p no:randomly, o renombrándolos para que corran en cierto orden) en vez de aislar el estado. Por qué pasa: fijar el orden hace pasar la suite hoy, y parece un arreglo. Cómo detectarlo: si tu arreglo depende de que los tests corran en cierto orden, no quitaste el estado compartido —solo escondiste su síntoma, y volverá con el paralelismo o un cambio de orden—. Cómo corregirlo: encuentra la fuente (el estado compartido) y quítala (la fixture). Un test que necesita correr en cierto orden sigue siendo frágil, aunque hoy pase. La cura ataca la causa, no acomoda el síntoma.

Creer que "pasó ocho veces" prueba el determinismo sin entender por qué. Qué pasa: alguien corre el test arreglado varias veces, ve verde, y da por curado sin entender qué fuente quitó. Por qué pasa: el verde repetido tranquiliza. Cómo detectarlo: si no puedes nombrar la fuente de no-determinismo que eliminaste (el reloj, el estado) y cómo la eliminaste (inyección, aislamiento), no sabes si curaste o tuviste suerte. Cómo corregirlo: la cura se entiende, no se adivina —debes poder señalar la línea que introducía el azar/estado y la línea que lo cerró—. Ocho verdes son evidencia de la cura, no la cura; la cura es la costura o la fixture, y saber por qué funcionan es lo que te deja aplicarlas al siguiente flaky.

Ejercicios

Ejercicio 1 — Cura por fuente. Para cada flaky, nombra la fuente de no-determinismo y la cura concreta. (a) Un test que hace assert booking.id == "b1" donde el id se genera con uuid4(). (b) Un test que compara datetime.now().date() con una fecha esperada. (c) Dos tests que leen y escriben el mismo contador en una variable global de módulo.

Ver solución
  • (a) Fuente: aleatoriedad (UUID). uuid4() genera un id aleatorio distinto cada vez, así que == "b1" es imposible de cumplir de forma estable. Cura: inyecta el id (pásalo como parámetro o usa una fixture/factory que asigne ids predecibles), o si el id no importa para la prueba, no lo asertes —afirma otra propiedad determinista—. Hacer el valor reproducible es la cura.
  • (b) Fuente: reloj. datetime.now().date() es la fecha de hoy, que cambia cada día —el test pasa hoy y falla mañana—. Cura: inyecta la fecha (un now fijo, como hiciste con should_audit), o usa freezegun para congelar el reloj. El test debe preguntar por una fecha que tú controlas, no por "hoy".
  • (c) Fuente: estado compartido (variable global de módulo). El contador compartido hace que el resultado de cada test dependa de qué corrió antes —el flaky de orden—. Cura: aísla con una fixture que dé un contador fresco por test, o encapsula el contador en un objeto que cada test cree de nuevo. Sin estado global mutable, no hay dependencia de orden.

El patrón: nombra la fuente (reloj/aleatoriedad/estado), luego inyéctala o aíslala. La cura siempre es cerrar la puerta al mundo no controlado.

Ejercicio 2 — La costura que da cobertura. Al inyectar el reloj en should_audit, no solo curaste el flaky sino que pudiste agregar test_booking_is_skipped_when_microsecond_is_odd. Explica por qué esa segunda rama era imposible de probar de forma confiable con el reloj real, y qué dice esto sobre la relación entre determinismo y cobertura.

Ver solución

Con el reloj real, should_audit() devuelve True o False según la paridad del microsegundo en el instante de la llamada, y ese instante no lo controlas: es azar. Para probar la rama "impar → False" de forma confiable, necesitarías garantizar que la llamada cae en un microsegundo impar —imposible con el reloj real, solo podrías esperar a tener suerte, y un test que depende de la suerte para ejercitar una rama es justamente un flaky—. En la práctica, esa rama quedaba sin probar de forma estable: unas corridas la tocaban, otras no, y ninguna la garantizaba.

Al inyectar el reloj (should_audit(FROZEN_ODD)), eliges el microsegundo, así que puedes forzar la rama impar a voluntad y afirmarla con certeza. La costura que curó el flaky es la misma que te dio acceso determinista a ambas ramas.

Lo que dice sobre determinismo y cobertura: el no-determinismo no solo causa flaky, también impide la cobertura. Un código que depende del reloj/azar tiene ramas que no puedes ejercitar a voluntad, así que no puedes probarlas de forma confiable. Hacerlo determinista —inyectar la dependencia— es prerequisito tanto de la estabilidad como de la cobertura completa. Un buen diseño para tests (con costuras) te regala las dos cosas a la vez: tests estables y ramas alcanzables.

Ejercicio 3 — Síntoma vs. causa. Un compañero "arregla" el flaky de orden del Calendar compartido agregando @pytest.mark.run(order=1) a test_a para forzar que corra primero, y la suite pasa. Argumenta por qué esto es acomodar el síntoma, no curar la causa, y qué pasaría en CI con paralelismo.

Ver solución

Forzar que test_a corra primero hace pasar la suite hoy, pero no quita el estado compartido —el shared = Calendar() a nivel de módulo sigue ahí, y test_b sigue mutándolo—. Solo escondió el síntoma amarrando el orden a la fuerza. El problema de fondo, que dos tests dependen de un estado común, persiste intacto.

Qué pasaría en CI con paralelismo (-n auto, del módulo 5): con pytest-xdist, los tests se distribuyen entre varios procesos, y el orden forzado dentro de un proceso no garantiza el orden global —peor, si test_a y test_b caen en procesos distintos que comparten... en realidad con xdist cada proceso tiene su propio módulo, pero el punto general se mantiene: cualquier cambio en cómo se distribuyen o corren los tests puede volver a exponer la dependencia—. Y aunque el orden se respetara, el diseño sigue siendo frágil: un test que necesita correr en cierto orden es un test que asume cosas sobre sus vecinos, exactamente lo que un test no debe hacer. El día que alguien agregue un test_c que también toque el shared, o que el orden cambie por cualquier razón, el flaky vuelve.

La cura real es la fixture: dar a cada test un Calendar fresco elimina el estado compartido, y entonces el orden deja de importar —no porque lo forzaste, sino porque ya no hay nada que dependa de él—. Acomodar el síntoma (forzar el orden) es frágil y temporal; quitar la causa (aislar el estado) es robusto y permanente. Nivelar la baldosa, no pedir que todos pisen en cierto orden.

Resumen y siguiente paso

En esta lección llegaste a la cura, la que da sentido a todo el módulo: arreglar el determinismo de raíz, quitar la fuente de no-determinismo en vez de convivir con ella. El retry y la cuarentena son el letrero "cuidado con la baldosa" y el "pisa con cuidado" —te mantienen en pie hoy—; arreglar el determinismo es nivelar la baldosa, para que el flaky no vuelva y nadie tropiece.

Ejecutaste las dos curas más comunes sobre los dos flaky del módulo. Inyectar el reloj (la costura now= de should_audit) mató el flaky de reloj: los tests deterministas salieron 2 passed ocho veces seguidas, sin un parpadeo, y de paso habilitaste probar la rama impar, antes intestable —el determinismo regala estabilidad y cobertura—. Una fixture que da un Calendar fresco por test mató el flaky de orden: 2 passed en el orden de definición y en el inverso, porque sin estado compartido el orden dejó de importar —justo el flaky que el retry no podía rescatar—. Y viste el mapa de curas por fuente: reloj → inyectar, estado → aislar, azar → sembrar, red → doblar, recursos → dar a cada test el suyo.

Antes de avanzar deberías poder: nombrar la fuente de no-determinismo de un flaky y su cura; inyectar un reloj y aislar estado con una fixture; explicar por qué el determinismo habilita cobertura además de estabilidad; y distinguir acomodar el síntoma (forzar el orden) de curar la causa (aislar el estado).

Lo que sigue, en la lección 8, es el mini-proyecto: un flaky bloquea el CI de Reservo y frena tres PRs, y tú decides —reintentar, poner en cuarentena o arreglar— con una matriz de decisión honesta, y ejecutas la decisión con evidencia real. Todo el módulo converge ahí: sabrás que el triaje desbloquea hoy y la cura resuelve para siempre, y tendrás que justificar, para este flaky, cuál corresponde y por qué. Es la baldosa de Reservo, y te toca decidir si le pones un letrero o la nivelas.

Recursos

  • Fixtures — documentación de pytest — la referencia de la cura del flaky de orden: fixtures que dan un objeto fresco por test para aislar el estado. Lee sobre el scope de las fixtures (por qué el default function es lo que garantiza un objeto nuevo por test).
  • tmp_path — documentación de pytest — la fixture que da a cada test su propio directorio temporal, la cura del flaky de recursos compartidos (archivos temporales que dos tests se pisan). El mismo principio de aislamiento que la fixture calendar.
  • freezegun — PyPI — la biblioteca que congela datetime.now() para toda una función de test, automatizando la inyección del reloj cuando la costura now= no existe o es incómoda. La cura del flaky de reloj, industrializada.
  • datetime — documentación de Python — la fuente de no-determinismo que inyectamos: entender que now() lee el reloj de pared en cada llamada es entender por qué inyectarlo cura el flaky. La costura now= es lo que le quita ese poder a la función.