Módulo 3: Reproducir el fallo de CI en tu máquina

2. El síntoma: rojo aquí, verde allá (y sus causas)

Descripción

En la lección anterior le pusiste nombre al fantasma y a la regla que lo caza —reproduce antes de arreglar—. Esta lección abre el síntoma en canal: cuando CI dice rojo y tu máquina dice verde sobre el mismo commit, ¿qué puede estar diferente entre las dos, en concreto? Vas a salir con un catálogo de sospechosos: la lista de las cosas que difieren entre un runner de CI y tu computadora y que producen este desacuerdo. Tener esa lista en la cabeza convierte el pánico ("no entiendo, ¡funciona en mi máquina!") en una investigación ordenada ("veamos cuál de estas seis cosas es distinta esta vez").

Vas a aprender también la mentalidad correcta frente al síntoma, que es más importante que la lista: cuando el mismo código da dos resultados, el test casi nunca miente; el entorno difiere. Esa frase te ahorra el error más caro —desactivar un test que en realidad está detectando una diferencia real— y te apunta desde el primer segundo hacia donde de verdad está el problema.

Conexión con el módulo. La lección 1 dio el mapa y la disciplina. Esta da el diccionario de causas: los seis o siete tipos de brecha de entorno que producen "rojo aquí, verde allá". La lección 3 profundiza en qué es el entorno y por qué las dos máquinas son mundos distintos; las lecciones 4, 5 y 6 atacan una por una las causas más comunes (dependencias, entorno sucio, variables y diferencias ocultas). Aquí construimos la lista completa para que las siguientes lecciones tengan dónde colgar cada solución. Seguimos con la suite de Reservo y su fallo de pytz.

La analogía: el detective y la lista de sospechosos

Un detective que llega a una escena no interroga al azar. Tiene una lista mental de categorías de sospechosos —quién tenía motivo, quién tenía oportunidad, quién estaba cerca— y va descartando. Sin esa lista, cada caso sería empezar de cero, mirando la nada. Con ella, la investigación es un procedimiento: revisar cada categoría, ver cuál encaja, seguir esa pista.

Un fallo de "CI rojo, local verde" tiene una lista de sospechosos sorprendentemente corta y estable. Casi siempre es una de estas: una versión de Python distinta, una dependencia con otra versión, una variable de entorno, el orden en que corrieron los tests, la zona horaria de la máquina, o un archivo que solo existe en tu disco. Seis sospechosos habituales. Cuando te topes con el síntoma, no mires la nada: recorre la lista. "¿Misma versión de Python? ¿Mismas versiones de librerías? ¿Alguna variable que yo tenga y el runner no?". Ese recorrido metódico es lo que separa una tarde perdida de una investigación de veinte minutos.

Dicho en una frase, que es la que gobierna la lección:

El desacuerdo entre CI y tu máquina sobre el mismo código siempre se explica por una diferencia de entorno. El catálogo de esas diferencias es corto y conocido; reproducir el fallo es recorrerlo hasta encontrar cuál aplica.

El catálogo de la brecha de entorno

Aquí está la lista de sospechosos, cada uno con su mecanismo y una señal para reconocerlo. No los memorices como quien memoriza una tabla; entiende por qué cada uno mueve el resultado, y la lista se te quedará sola.

1. Una versión de Python distinta

El runner de CI corre, digamos, Python 3.13, y tú tienes 3.14 (o al revés). Entre versiones de Python cambian cosas: una función del estándar se comporta distinto, una sintaxis nueva no existe en la vieja, el orden de un diccionario o de un set puede diferir en casos límite, un mensaje de error cambia de texto. Un test que dependa de cualquiera de esos detalles pasa en una versión y falla en otra. Señal: el log de CI, en su primera línea de pytest, dice Python 3.13.x, y tú corres python --version y ves 3.14.0. Esa línea —que casi nadie lee— es el primer lugar donde mirar.

2. Una dependencia con otra versión

Esta es la reina de las causas, y la que Reservo sufre en este módulo. Tu máquina tiene instalada una versión de una librería —digamos pytz 2022.1, que instalaste hace un año y nunca actualizaste—; CI hace una instalación fresca en cada corrida y agarra la última —pytz 2026.3.post1—. Si el comportamiento de esa librería cambió entre las dos versiones, el mismo test da resultados distintos. Señal: el fallo involucra una llamada a una librería de terceros (formateo, fechas, zonas horarias, serialización, red), y pip show <lib> en tu máquina muestra una versión distinta a la del log de CI. Le dedicamos entera la lección 4.

3. Una variable de entorno

Tu shell tiene exportada una variable —RESERVO_TAX_PERCENT=16, DATABASE_URL=..., TZ=America/Mexico_City— que tu código lee con os.environ. El runner de CI arranca limpio y no la tiene (o la tiene con otro valor). El código toma un camino distinto según el valor, y el test cambia de color. Señal: el fallo desaparece si borras una variable de tu shell, o si tu test lee algo de configuración. Las variables son especialmente traicioneras porque son invisibles en el código y en requirements.txt; viven en tu shell y en la config del runner. Las cazamos en la lección 6.

4. El orden de los tests

pytest corre los tests en un orden; algunos plugins (como pytest-randomly) los barajan. Si dos tests comparten estado por accidente —uno deja una variable global modificada, un archivo escrito, una entrada en un dict de módulo— el que corra segundo puede pasar o fallar según quién corrió antes. CI y tu máquina pueden recoger los tests en orden distinto (por el sistema de archivos, por una semilla de aleatorización) y así uno ve el fallo y el otro no. Señal: el fallo cambia si corres los tests en otro orden, o si corres el test culpable solo y entonces pasa. El diagnóstico a fondo de esto —el "acoplamiento por orden"— vive en la guía hermana test-failure-diagnosis-guide; aquí basta con reconocerlo como sospechoso y saber que replicar la semilla de orden es parte de reproducir.

5. La zona horaria (y la data de zonas horarias)

La máquina tiene una zona horaria de sistema, y las librerías de fechas traen una base de datos de zonas horarias que se actualiza con el tiempo. Un test que construye o interpreta una fecha "local" depende de ambas. Si tu máquina está en America/Mexico_City y el runner en UTC, o si tu pytz trae la data vieja (con horario de verano) y el runner la nueva (sin él), el mismo instante cae en horas distintas. Señal: el fallo involucra fechas, horas, datetime, offsets o nombres de zona, y el número esperado está "corrido" una o dos horas. Es exactamente el caso de Reservo: pytz vieja dice 16:00, pytz nueva dice 15:00. Nota que este sospechoso se solapa con el #2 (una dependencia con otra versión), porque la data de tz vive dentro de una dependencia —eso hace al caso de Reservo tan ilustrativo: es a la vez el sospechoso #2 y el #5—.

6. Un archivo que solo existe en tu disco

Tu código o tu test lee un archivo —un .env, un CSV de datos de prueba, un archivo de configuración, un fixture— que está en tu máquina pero no se subió al repositorio (por un .gitignore, o porque lo creaste a mano y olvidaste commitearlo). En tu máquina el archivo existe y el test pasa; en el runner, que solo tiene lo que está en git, el archivo no existe y el test falla (a menudo con un FileNotFoundError). Señal: el fallo en CI es un FileNotFoundError o un No such file or directory, y el archivo que menciona existe en tu disco pero git status no lo lista o git ls-files no lo incluye. Emparentado: rutas absolutas codificadas (/Users/tunombre/proyecto/data.csv) que solo existen en tu máquina.

Un séptimo, transversal: el directorio de trabajo y el estado acumulado

Hay un sospechoso más difuso que conviene tener presente: desde dónde corriste los tests y qué se acumuló en tu máquina. Si corres pytest desde la raíz del proyecto, los imports y las rutas relativas resuelven distinto que si corres desde una subcarpeta. Y tu máquina lleva meses acumulando cosas —paquetes instalados "temporalmente" y nunca desinstalados, cachés, un conftest.py viejo, un .pth olvidado— que el runner limpio no tiene. Este séptimo sospechoso es el tema de la lección 5 (el venv limpio), porque la mejor forma de descartarlo es empezar de cero como hace CI.

Por qué el test casi nunca miente

De todo el catálogo, la lección más valiosa no es la lista sino la actitud. Cuando ves rojo en CI y verde en local, tu cerebro salta a la explicación que más te conviene emocionalmente: "el test está mal", "el CI está flojo", "es un falso positivo". Es tentador porque desactivar un test es más rápido que investigar, y porque duele menos culpar a la herramienta que al propio código. Pero piénsalo con frialdad: el test es un pedazo de código determinista. Dados los mismos insumos —mismo código bajo prueba, mismas versiones, mismas variables— produce siempre el mismo resultado. Si produce dos resultados distintos, entonces los insumos no fueron los mismos. Y "los insumos" es, precisamente, el entorno.

Por eso la regla: cuando el mismo commit da rojo en un lado y verde en el otro, el sospechoso es el entorno, no el test. El test está haciendo justo su trabajo —detectar que, bajo las condiciones de CI, el código produce un resultado que no esperabas—. Desactivarlo es matar al mensajero. En el caso de Reservo, el test que falla en CI tiene razón: con la data de zonas horarias actual, la reserva sí empieza a las 15:00 locales, no a las 16:00. El "16" era una suposición vieja. El test no está roto; está avisando que el mundo cambió. Si lo hubieras marcado con skip, habrías enterrado esa señal.

Hay un matiz honesto que conviene decir: existe una excepción a "el test casi nunca miente", y es el test flaky —uno que falla de forma intermitente incluso en el mismo entorno, por depender del reloj, del azar, de la red o del orden—. Ese es un problema distinto (lo trata el módulo 7 de esta guía, y su diagnóstico la guía hermana). Pero incluso ahí, la cura no es desactivarlo a ciegas: es entender por qué es intermitente. La regla general aguanta: antes de culpar al test, sospecha del entorno y reproduce.

Ejemplo trabajado: leer el rojo como un detective

Volvamos al fallo de Reservo y practiquemos el recorrido del catálogo sobre un log real. Este es el resumen del fallo tal como lo pintaría CI (el formato es el de pytest; en un runner de GitHub Actions aparece dentro del log del step que corre los tests):

============================= test session starts ==============================
platform linux -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
collected 1 item

test_localtime.py::test_summer_booking_starts_at_16_local FAILED          [100%]

=================================== FAILURES ===================================
____________________ test_summer_booking_starts_at_16_local ____________________

>       assert local_start_hour(a_booking(), "America/Mexico_City") == 16
E       AssertionError: assert 15 == 16

test_localtime.py:21: AssertionError
=========================== short test summary info ============================
FAILED test_localtime.py::test_summer_booking_starts_at_16_local - assert 15 == 16

Ahora recorre el catálogo con la información que tienes. ¿Versión de Python? El log dice Python 3.14.0; si tú también corres 3.14.0, descartado (sospechoso #1 fuera). ¿Un archivo que falta? No hay FileNotFoundError; es un AssertionError de un valor numérico. Descartado (#6 fuera). ¿Orden de tests? Solo se recogió 1 test, así que no hay interacción entre tests. Descartado (#4 fuera). ¿Fechas y horas? Sí: el test se llama ..._starts_at_16_local, pasa una zona horaria ("America/Mexico_City"), y el valor obtenido (15) está corrido justo una hora del esperado (16). Ese "corrido una hora" es la huella clásica del sospechoso #5 (zona horaria) y, como la data de tz vive en pytz, también del #2 (versión de dependencia). ¿Variable de entorno? Posible, pero el patrón de "una hora de diferencia en un cálculo de zona horaria" apunta con mucha más fuerza a la tz/dependencia.

En treinta segundos, sin tocar el código, el catálogo te llevó de "no entiendo nada" a "esto huele a versión de pytz". El siguiente paso —confirmarlo comparando la versión que tú tienes con la que instaló CI, y reproducir— es lo que construyen las lecciones 4 y 5. Pero fíjate en lo que acabas de hacer: convertiste un rojo desconcertante en una hipótesis concreta y comprobable, solo por tener la lista de sospechosos en la cabeza y leer el log con método.

Qué esperar. Cuando de verdad tengas los dos entornos delante, la confirmación se ve así de nítida. En la máquina con la pytz vieja:

$ python -c "import pytz; print(pytz.__version__)"
2022.1
$ python -m pytest test_localtime.py -q
1 passed in 0.02s

Y en un entorno con instalación fresca (la última pytz), idéntico en todo lo demás:

$ python -c "import pytz; print(pytz.__version__)"
2026.3.post1
$ python -m pytest test_localtime.py -q
1 failed in 0.04s

Dos versiones de una sola dependencia, dos colores. El catálogo apuntó al sospechoso correcto, y la comparación de versiones lo confirmó. Eso es reproducir bien encaminado: no adivinar, sino recorrer la lista y comprobar.

Errores comunes

Tratar el síntoma como un misterio único e irrepetible. Qué pasa: cada vez que aparece un "rojo aquí, verde allá" lo vives como un caso sin precedentes y empiezas a investigar desde cero, mirando el código a ojo. Por qué pasa: el síntoma da pánico y el pánico borra el método. Cómo detectarlo: si tu primer movimiento es releer el código de la función en vez de comparar los dos entornos, estás sin lista. Cómo corregirlo: memoriza el catálogo de seis sospechosos y recórrelo siempre en el mismo orden. La inmensa mayoría de estos fallos cae en una de esas categorías; el método te lleva al sospechoso en minutos.

Leer solo el AssertionError y saltarse la cabecera del log. Qué pasa: vas directo a la línea del fallo y nunca miras la primera línea de pytest (platform ... Python 3.x.y, pytest-...). Por qué pasa: la cabecera parece ruido de arranque. Cómo detectarlo: si no sabes qué versión de Python usó CI, no leíste la cabecera. Cómo corregirlo: la cabecera es la ficha de identidad del entorno de CI —versión de Python, versión de pytest, plataforma—. Es el primer lugar donde comparar contra tu máquina. Léela siempre.

Asumir que "verde en mi máquina" significa "el código está bien". Qué pasa: como en tu máquina pasa, concluyes que el código no tiene problema y que el rojo de CI es cosa del CI. Por qué pasa: confiamos más en lo que vemos con nuestros propios ojos (tu terminal en verde) que en un log remoto. Cómo detectarlo: si tu conclusión es "el código está bien, el problema es CI", sin haber comparado entornos, cometiste este salto. Cómo corregirlo: "verde en tu máquina" solo significa "el código pasa bajo las condiciones de tu máquina". CI corre bajo otras condiciones, y el rojo dice que bajo esas el código falla. Ambos son datos reales; la pregunta no es quién tiene razón, sino qué difiere entre los dos.

Ejercicios

Ejercicio 1 — Clasifica el sospechoso. Para cada fallo de CI (verde en tu máquina), di cuál de los sospechosos del catálogo es el más probable y por qué. (a) En CI: ModuleNotFoundError: No module named 'reservo.localtime', y el archivo existe en tu disco. (b) En CI: AssertionError: assert 15 == 16 en un cálculo de hora local. (c) En CI: SyntaxError en una línea que usa match/case. (d) En CI: un test pasa cuando corre solo, pero falla cuando corre después de otro.

Ver solución
  • (a) Sospechoso #6 (un archivo que solo existe en tu disco). El módulo existe en tu máquina pero el runner no lo tiene: probablemente reservo/localtime.py no se commiteó (revísalo con git ls-files). También podría ser un problema de empaquetado/imports (sospechoso #7, directorio de trabajo), pero la primera sospecha con un módulo "que existe local" es que falta en git.
  • (b) Sospechosos #5 y #2 (zona horaria + versión de dependencia). El valor corrido justo una hora en un cálculo de zona horaria es la huella clásica; la data de tz vive en una dependencia, así que es a la vez tz y versión de librería. Es el caso de Reservo.
  • (c) Sospechoso #1 (versión de Python). match/case existe desde Python 3.10; un SyntaxError ahí significa que el runner corre una versión más vieja que la tuya.
  • (d) Sospechoso #4 (orden de los tests). Que pase solo pero falle en secuencia es la definición del acoplamiento por orden: algún estado compartido entre tests. (La cura a fondo es de la guía de diagnóstico; aquí basta con reconocerlo.)

Ejercicio 2 — El sospechoso oculto. Un test lee la variable de entorno RESERVO_TAX_PERCENT para calcular un total con impuesto. Pasa en tu máquina y falla en CI. Explica el mecanismo exacto —por qué tu máquina y el runner difieren— y por qué este sospechoso es más difícil de ver que una versión de dependencia.

Ver solución

El mecanismo: en tu shell tienes exportada RESERVO_TAX_PERCENT (por ejemplo, export RESERVO_TAX_PERCENT=16 en tu .zshrc o de una sesión anterior), así que cuando el código hace os.environ.get("RESERVO_TAX_PERCENT", "0") obtiene "16" y el total incluye el impuesto que el test espera. El runner de CI arranca con un entorno limpio y no tiene esa variable, así que el código obtiene el default "0", el total no lleva impuesto, y el número esperado del test no cuadra.

Por qué es más difícil de ver que una versión de dependencia: una versión vive en requirements.txt y en pip freeze —es visible y comparable entre las dos máquinas—. Una variable de entorno vive en tu shell y en la configuración del runner, no en ningún archivo del repositorio; es invisible al leer el código o las dependencias. Puedes mirar el proyecto entero y no encontrarla, porque el valor que rompe la simetría no está en el proyecto, está en el aire de cada máquina. Por eso las variables se cazan comparando los entornos con env/printenv, no leyendo el repo (lección 6).

Ejercicio 3 — Defiende al test. Un compañero propone: "Este test de la hora local nos da lata; falla en CI y pasa local. Pongámosle @pytest.mark.skip y sigamos." Da dos razones concretas por las que saltarse el test es la peor opción aquí, y qué propondrías en su lugar.

Ver solución

Dos razones para no saltarlo:

  1. El test tiene razón, no está roto. Con la data de zonas horarias actual, la reserva de las 21:00 UTC sí empieza a las 15:00 en Ciudad de México (que abolió el horario de verano). El 16 era una suposición que envejeció. Saltar el test entierra una señal correcta: que el número esperado del código quedó desactualizado. Si lo saltas, en algún momento un usuario verá la hora equivocada y nadie se enterará.
  2. Apagas una protección para siempre por un problema de un día. Un skip rara vez se quita; el test queda muerto y la regla que cubría —"la hora local se calcula bien"— deja de estar protegida. Cualquier bug futuro en ese cálculo pasará sin ser detectado.

Qué proponer en su lugar: reproducir el fallo (comparar la versión de pytz de CI contra la local, armar un venv limpio con la versión de CI, correr el mismo comando), confirmar que es un cambio de data de zonas horarias, y entonces decidir con conocimiento —lo más probable, corregir el número esperado a 15 y pinnear pytz para que CI y local coincidan de forma reproducible—. Eso arregla la causa; el skip solo esconde el síntoma.

Resumen y siguiente paso

En esta lección construiste el catálogo de la brecha de entorno: la lista corta y estable de cosas que difieren entre CI y tu máquina y producen "rojo aquí, verde allá". Una versión de Python distinta; una dependencia con otra versión (la reina de las causas); una variable de entorno invisible; el orden de los tests; la zona horaria y su data; un archivo que solo existe en tu disco; y, transversal, el directorio de trabajo y el sedimento acumulado de tu máquina. Practicaste recorrer esa lista sobre el log real de Reservo y llegar, en treinta segundos y sin tocar código, a una hipótesis concreta: "esto huele a versión de pytz".

Y grabaste la actitud que evita el error más caro: cuando el mismo código da dos resultados, el test casi nunca miente; el entorno difiere. Desactivar el test es matar al mensajero; el test de Reservo que falla en CI tiene razón, y saltarlo habría enterrado una señal correcta.

Antes de avanzar deberías poder: recitar los seis sospechosos del catálogo con su mecanismo; leer la cabecera de un log de pytest para extraer la versión de Python y de pytest; reconocer la huella de una diferencia de zona horaria (un valor corrido una hora); y argumentar por qué no se desactiva un test que discrepa entre CI y local.

Lo que sigue es entender qué es ese "entorno" del que tanto hablamos, pieza por pieza, y por qué el runner de CI y tu máquina son, por naturaleza, dos mundos distintos. La lección 3 diseca la brecha de entorno —Python, dependencias, variables, sistema, tz, archivos, directorio— y te muestra con salida real una diferencia oculta en acción, para que la idea deje de ser una lista y se vuelva algo que puedes ver y tocar.

Recursos