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

1. Presentación del módulo: CI en rojo, tu máquina en verde

Descripción

En el módulo 2 armaste tu primer pipeline: un workflow de GitHub Actions que, en cada push, saca una copia de tu código, instala Python, instala tus dependencias y corre pytest. Desde entonces cada cambio que subes recibe un veredicto automático —barra verde o barra roja— sin que tengas que acordarte de correr los tests. Es un guardián que no duerme.

Este módulo trata del día en que ese guardián y tú no están de acuerdo. Subes un cambio, CI se pone rojo, abres el mismo proyecto en tu máquina, corres la misma suite y sale verde. El fallo existe —ahí está el log rojo, con su AssertionError—, pero no lo puedes tocar, porque en tu computadora no ocurre. Es el bug fantasma: real en un lado, invisible en el otro. Y es, sin exagerar, una de las situaciones que más tiempo le roba a un equipo, porque la reacción instintiva —"el CI está roto", "es un falso positivo", "vuelvo a correrlo a ver si pasa"— casi siempre es la equivocada.

Al terminar el módulo vas a tener un método frío y repetible para convertir ese fantasma en un fallo que ocurre también en tu máquina, cuando tú quieras, las veces que quieras. Porque esa es la regla de hierro que gobierna todo lo que sigue: un fallo que no puedes reproducir no lo puedes arreglar. Reproducir no es un paso opcional ni un lujo; es la puerta de entrada. Mientras el fallo solo viva en CI, cualquier "arreglo" que intentes es adivinar a ciegas: cambias algo, subes, esperas cinco minutos a que corra el pipeline, y rezas. Cuando el fallo ocurre en tu máquina, el bucle se cierra: cambias, corres, ves el resultado en segundos, y sabes con certeza si lo arreglaste.

Conexión con el módulo. Esta lección abre el módulo: te da el mapa, el vocabulario y la disciplina antes de bajar a los detalles. Las lecciones 2 y 3 diagnostican el síntoma (rojo aquí, verde allá) y su causa raíz (la brecha de entorno). Las lecciones 4, 5 y 6 atacan las tres fuentes concretas de esa brecha —las dependencias sin pinnear, un entorno sucio, y las variables y diferencias ocultas—. La lección 7 junta todo en un método paso a paso, y el mini-proyecto de la lección 8 te pone a reproducir y arreglar un fallo real de Reservo de principio a fin. Todo sobre la suite de Reservo que ya conoces, y con una demostración que corrí de verdad en mi máquina —no inventada— donde el mismo test pasa con una versión de una librería y falla con otra.

La analogía: la receta que solo te sale a ti

Piensa en una amiga que te pasa la receta de un pan que le queda perfecto. La sigues al pie de la letra —los mismos gramos, los mismos pasos, el mismo horno "a 180 grados por 25 minutos"— y a ti te sale crudo por dentro. Le mandas foto, ella jura que a ella le sale bien, y tú juras que seguiste cada línea. ¿Quién miente? Nadie. La receta —el código— es idéntica. Lo que difiere es el entorno: su horno calienta más que el tuyo, ella vive a nivel del mar y tú a 2000 metros de altura (donde el agua hierve a menos temperatura), su harina tiene más gluten que la que compras tú. La receta no está mal; el mundo alrededor de la receta es distinto en cada cocina.

Un fallo de "CI rojo, local verde" es exactamente eso. El código es el mismo —byte por byte, es el mismo commit—. Lo que difiere es la cocina: la versión de Python, las versiones de las librerías instaladas, las variables de entorno, la zona horaria de la máquina, los archivos que hay en el disco. Tu instinto de cocinera con experiencia no es tirar la receta a la basura ("este test está roto, lo borro"); es preguntarte qué es distinto entre mi cocina y la suya. Reproducir el fallo es, ni más ni menos, cocinar la receta en una cocina igual a la de CI: mismo horno, misma altitud, misma harina. Cuando logras que a ti también te salga crudo, dejaste de discutir sobre si la receta está mal y empezaste a arreglar el verdadero problema.

Vale la pena decirlo directo, porque es la tesis del módulo entero:

Cuando CI y tu máquina discrepan sobre el mismo código, el test casi nunca miente: el entorno difiere. Reproducir el fallo es cerrar esa brecha de entorno hasta que el rojo aparezca también en tu máquina. Solo entonces —no antes— lo puedes arreglar.

Qué es "reproducir" y qué no es

Conviene separar dos cosas que la gente mezcla y que en esta guía viven en módulos y guías distintas: reproducir un fallo y diagnosticarlo.

Reproducir es lograr que el fallo ocurra a voluntad en tu máquina. No te dice por qué falla; te da el fallo en la mano, vivo, para que puedas trabajar con él. Es un problema de entorno: hacer que tu cocina se parezca a la de CI en lo que importa. Reproducir se responde con preguntas como "¿qué versión de Python usó CI?", "¿qué versión de esa librería instaló?", "¿qué variables tenía puestas?", "¿qué comando exacto corrió?". Este módulo trata solo de esto.

Diagnosticar es entender por qué el código produce el resultado equivocado una vez que ya lo tienes fallando enfrente: aislar la línea culpable, usar el depurador, poner un breakpoint(), reducir el test al mínimo que reproduce el error. Eso es un oficio propio, con sus propias herramientas, y vive en la guía hermana test-failure-diagnosis-guide (pdb, aislar, bisecar). Aquí no entramos ahí. Nuestra frontera es nítida: este módulo termina en el instante en que el fallo de CI ocurre también en tu máquina. Ese es el "handoff": reproducido el fallo, la brecha de entorno está cerrada, y la pregunta cambia de "¿por qué a mí no me pasa?" a "¿por qué el código hace esto?" —y esa segunda pregunta se responde en la otra guía—.

¿Por qué separarlas tan tajantemente? Porque mezclarlas es la fuente número uno de horas perdidas. La gente intenta diagnosticar un fallo que aún no puede reproducir: se pone a leer el código a ojo, a teorizar sobre la causa, a cambiar líneas "por si acaso" y subirlas para ver si el CI cambia de color. Es depurar a través de un pipeline de cinco minutos por intento, sin poder poner un print, sin poder inspeccionar nada. Es lento, frustrante y casi siempre inútil. La disciplina es al revés: primero reproduce, después diagnostica. Consigue el fallo en tu máquina —donde tienes todas tus herramientas— y recién ahí empieza a entender por qué. Este módulo te da la primera mitad, que es la que desbloquea la segunda.

El caso que atraviesa el módulo: Reservo y una zona horaria que cambió

Para que esto no sea abstracto, todo el módulo gira alrededor de un fallo concreto, real y reproducible de Reservo —la app de reserva de salas de coworking que vienes usando en las guías de testing—. Recuerda sus piezas: Room, Member, Booking (con su campo price_cents, dinero siempre en centavos enteros), price_cents(room, member, hours), refund_cents(booking, price_paid_cents, now), el Calendar. Y sus números-ancla: un miembro basic paga 3 horas de la sala Focus a 7500 centavos; un pro paga 6000 (20% de descuento); un reembolso 72 horas antes devuelve 6000, 36 horas antes devuelve 3000, y 12 horas antes devuelve 0.

Reservo creció y ahora tiene miembros en varias ciudades. Para mostrarle a cada quien sus reservas en su hora local, alguien agregó una funcioncita que, dada la hora UTC en que empieza una reserva, calcula la hora de pared en la zona horaria del miembro —por ejemplo, para un miembro de Ciudad de México—. Se apoyó en una librería popular de zonas horarias, pytz, y escribió un test con un número esperado. El test pasó en su máquina. Lo subió. Y semanas después, sin que nadie tocara ese código, CI se puso rojo.

Ese es nuestro fantasma. El código no cambió; el test tampoco. Lo que cambió fue una pieza del entorno —la versión de pytz que CI instaló en su corrida más reciente—. Y detrás hay un hecho del mundo real que lo hace todo más jugoso: México abolió el horario de verano en octubre de 2022. Las versiones viejas de pytz todavía creían que Ciudad de México adelantaba el reloj en verano (UTC−5); las nuevas ya saben que no (UTC−6 todo el año). Una reserva a las 21:00 UTC de un día de julio de 2023 cae a las 16:00 hora local con la pytz vieja y a las 15:00 con la nueva. El test esperaba 16. Con la librería vieja —la que el dev tenía instalada— pasaba. Con la nueva —la que CI instaló fresca— falla.

A lo largo del módulo vamos a diseccionar este caso: por qué las dos máquinas terminaron con versiones distintas de pytz (lección 4), cómo montar un venv limpio que replique el de CI (lección 5), qué otras diferencias ocultas —como una variable de entorno— producen el mismo síntoma (lección 6), y el método completo para reproducir el rojo a voluntad (lección 7). Y no es un cuento: lo corrí de verdad en dos entornos, con Python 3.14.0 y pytest 9.1.1, y vas a ver las dos salidas —el verde y el rojo— con sus propios ojos en la próxima lección.

Un primer vistazo al fantasma

Antes de cerrar la presentación, mira el fenómeno con salida real, para que no quede como una promesa. Este es el mismo test de Reservo corrido en dos entornos que difieren en una sola cosa: la versión de pytz instalada. Todo lo demás es idéntico —el mismo archivo de test, el mismo código de Reservo, la misma máquina, el mismo Python 3.14.0, el mismo pytest 9.1.1—.

El test, tal como lo escribió el dev:

# test_localtime.py — el test que pasa en una máquina y falla en la otra
from datetime import datetime

import pytz

from reservo.models import Booking
from reservo.localtime import local_start_hour

UTC = pytz.utc
SUMMER_START = UTC.localize(datetime(2023, 7, 15, 21, 0))  # 21:00 UTC, un dia de verano


def a_booking():
    return Booking(id="bk-1", room_id="r-focus", member_id="m-1",
                   start=SUMMER_START, end=SUMMER_START,
                   status="confirmed", price_cents=6000)


def test_summer_booking_starts_at_16_local():
    # El dev lo escribio esperando que verano en CDMX = UTC-5 (horario de verano).
    assert local_start_hour(a_booking(), "America/Mexico_City") == 16

Qué esperar. En el entorno del dev (con pytz 2022.1, una versión que tenía instalada desde hacía tiempo), la suite sale verde:

$ python -m pytest test_localtime.py -v
============================= test session starts ==============================
platform darwin -- 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 PASSED         [100%]

======================== 1 passed, 1 warning in 0.02s =========================

Y en un entorno con instalación fresca (que agarró la última pytz, 2026.3.post1) —que es justo lo que hace el runner de CI en cada corrida—, la misma suite sale roja:

$ python -m pytest test_localtime.py -v
============================= test session starts ==============================
platform darwin -- 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 ____________________

    def test_summer_booking_starts_at_16_local():
        # El dev lo escribio esperando que verano en CDMX = UTC-5 (horario de verano).
>       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
========================== 1 failed in 0.04s ===========================

Léelo despacio. El código es idéntico. El test es idéntico. La única diferencia entre las dos corridas es la versión de una librería, y esa diferencia mueve el resultado de 16 a 15, y con eso el color de verde a rojo. Ningún test está "roto". La pytz nueva tiene razón: en 2023, Ciudad de México ya no tenía horario de verano, así que 21:00 UTC sí son las 15:00 locales. El test envejeció con una suposición que dejó de ser cierta, y solo una de las dos máquinas tenía la librería lo bastante actualizada para notarlo.

Fíjate en un detalle que también es parte de la brecha, aunque hoy no sea el protagonista: mi log dice platform darwin porque lo corrí en macOS. El runner de CI diría platform linux. Esa línea, que casi nadie mira, es un recordatorio de que las dos cocinas nunca son idénticas por defecto —hay que hacer que coincidan en lo que importa—. Ese "hacer que coincidan" es, en una frase, todo el módulo.

Profundización: la economía del bucle de feedback

Vale la pena poner números a por qué reproducir primero no es un capricho de método sino pura economía. El valor de un pipeline es su bucle de feedback: cambias algo, y una máquina te dice si rompiste algo. Ese bucle tiene un costo por vuelta —el tiempo entre "subo un cambio" y "sé el resultado"—, y ese costo decide cómo trabajas. En CI, una vuelta cuesta el pipeline entero: hacer commit, hacer push, esperar a que el runner arranque, instale dependencias y corra la suite. Fácilmente tres a cinco minutos, a veces diez o quince en proyectos grandes. En tu máquina, una vuelta cuesta lo que tarda pytest: para la suite de Reservo, fracciones de segundo.

Ahora imagina el fantasma —CI rojo, local verde— y las dos formas de atacarlo. Sin reproducir, tu único sensor es CI: cada hipótesis ("¿será esto?") cuesta una vuelta completa del pipeline, y como no puedes inspeccionar nada, aciertas por suerte. Cinco hipótesis son media hora de espera muerta, más cinco commits basura (fix attempt, fix attempt 2…) ensuciando la historia, más la incertidumbre de no saber si "pasó" porque acertaste o porque el problema era intermitente. Con reproducir, pagas un costo fijo por adelantado —diez, quince minutos de montar un venv limpio con las versiones de CI— y a cambio conviertes cada vuelta siguiente en fracciones de segundo, con todas tus herramientas disponibles (puedes poner un print, abrir el depurador, inspeccionar valores).

La cuenta se inclina rapidísimo. Reproducir "gasta de más" solo si el fallo se resuelve en la primera hipótesis a ciegas —cosa que casi nunca pasa—. En cuanto necesitas dos o tres intentos, el costo fijo de reproducir ya se pagó solo, y de ahí en adelante trabajas gratis. Por eso la regla "reproduce antes de arreglar" no es disciplina moral; es la estrategia que minimiza el tiempo total. Un pipeline lento vuelve esto aún más cierto: cuanto más caro es el bucle de CI, más rinde mover el trabajo a tu máquina, donde el bucle es instantáneo. Reproducir es, en el fondo, cambiar un bucle de feedback caro (CI) por uno barato (local) para todo el trabajo de arreglo.

Errores comunes

Desconfiar del test antes que del entorno. Qué pasa: CI se pone rojo, en tu máquina pasa, y tu primera conclusión es "el test está mal" o "el CI está flojeando". Marcas el test con @pytest.mark.skip, o lo cambias para que pase, y sigues. Por qué pasa: el test es lo que ves ponerse rojo, así que parece el culpable; y desconfiar del código propio cuesta más. Cómo detectarlo: si tu reacción a un rojo de CI es tocar el test sin haber reproducido el fallo, estás por cometer este error. Cómo corregirlo: invierte la sospecha. Cuando el mismo código da dos resultados, el sospechoso número uno es el entorno, no el test. Reproduce primero; desactivar un test sin entender qué protegía es apagar la alarma de incendios porque suena feo.

Intentar arreglar sin reproducir. Qué pasa: sin lograr que el fallo ocurra en tu máquina, empiezas a cambiar líneas "a ver si esto lo arregla" y subes cada intento para que CI te diga si acertaste. Por qué pasa: la urgencia —"hay que poner el build en verde ya"— empuja a saltarse el paso lento de reproducir. Cómo detectarlo: si llevas tres o cuatro pushes de "fix attempt", "fix attempt 2", "please work", estás depurando a ciegas a través del pipeline. Cómo corregirlo: para. Dedica el tiempo a reproducir el fallo localmente (es lo que enseña este módulo). Un fallo reproducido se arregla en un ciclo de segundos en tu máquina; uno no reproducido se persigue por horas a través de CI.

Confundir reproducir con diagnosticar. Qué pasa: consigues el rojo en tu máquina y, en vez de celebrarlo como el hito que es, te frustras porque "sigo sin saber por qué falla". Por qué pasa: es fácil creer que reproducir y entender son lo mismo. Cómo detectarlo: si esperabas que reproducir el fallo te dijera la causa, tienes las dos etapas mezcladas. Cómo corregirlo: reconoce que reproducir es la primera victoria, no la última. Ya tienes el fallo en la mano y en tu terreno, con todas tus herramientas. La causa se caza ahora con las técnicas de diagnóstico —de la guía hermana—, que sin un fallo reproducible ni siquiera podrías empezar.

Ejercicios

Ejercicio 1 — Reproducir contra diagnosticar. Clasifica cada una de estas acciones como parte de reproducir (cerrar la brecha de entorno) o de diagnosticar (entender por qué falla el código). (a) Averiguar qué versión de Python usó el runner de CI. (b) Poner un breakpoint() dentro de local_start_hour para ver qué devuelve astimezone. (c) Crear un venv limpio e instalar las dependencias con las versiones exactas que instaló CI. (d) Reducir el test a la mínima llamada que dispara el error. (e) Copiar las variables de entorno que tenía el runner.

Ver solución
  • (a) Reproducir — la versión de Python es una pieza del entorno; replicarla acerca tu cocina a la de CI.
  • (b) Diagnosticar — inspeccionar valores internos con el depurador es entender por qué, y presupone que el fallo ya ocurre en tu máquina.
  • (c) Reproducir — instalar las versiones exactas es el acto central de cerrar la brecha de dependencias.
  • (d) Diagnosticar — reducir al mínimo (minimizar el caso) es una técnica de la guía hermana para aislar la causa, una vez que ya reproduces.
  • (e) Reproducir — las variables de entorno son parte de la cocina; copiarlas cierra otra parte de la brecha.

La línea divisoria: (a), (c) y (e) hacen que el fallo ocurra en tu máquina; (b) y (d) sirven para entenderlo una vez que ya ocurre. Este módulo cubre las primeras; la guía de diagnóstico, las segundas.

Ejercicio 2 — El sospechoso correcto. Un compañero te escribe: "El test test_summer_booking_starts_at_16_local lleva meses en verde y hoy CI lo marcó en rojo, pero en mi máquina sigue verde. Ya lo marqué con skip para desbloquear el merge." Escribe, en dos o tres frases, qué le responderías —qué hizo mal y qué debería hacer en su lugar—.

Ver solución

Una respuesta razonable: "Marcarlo con skip apaga la alarma sin apagar el incendio: si el test protegía una regla real, ahora esa regla queda sin cubrir y el merge entra a ciegas. Que el mismo código dé verde en tu máquina y rojo en CI casi siempre significa que el entorno difiere, no que el test esté mal —lo más probable aquí es una versión distinta de alguna dependencia—. En vez de saltártelo, reproduce el fallo: mira qué versiones instaló CI, arma un venv limpio con esas versiones exactas y corre el mismo comando. Cuando te salga rojo a ti también, sabremos qué cambió y si el que está mal es el código o el número esperado del test."

El punto clave: no desconfiar del test antes que del entorno, y no desactivar una protección sin entender qué cubría.

Ejercicio 3 — Por qué reproducir primero. Explica con tus palabras, y con un ejemplo de tiempos, por qué intentar arreglar un fallo sin reproducirlo localmente sale más caro que dedicar un rato a reproducirlo primero.

Ver solución

Sin reproducir, tu único "sensor" de si arreglaste el fallo es el propio CI: cambias una línea, haces commit, push, y esperas a que el pipeline corra —fácilmente tres a cinco minutos por intento, a veces más—. Cada hipótesis cuesta ese ciclo completo, no puedes poner un print ni inspeccionar nada, y si te equivocas (lo normal las primeras veces), vuelves a empezar. Cinco intentos a la ciega son media hora larga de espera, más el ruido de cinco commits basura en la historia.

Reproducido en tu máquina, el fallo ocurre en segundos y con todas tus herramientas: corres pytest local, ves el rojo al instante, pruebas una hipótesis, vuelves a correr, otra vez segundos. Reproducir puede costar diez o quince minutos de preparar un venv limpio con las versiones correctas, pero después cada iteración es casi gratis. La cuenta es simple: un rato fijo de setup a cambio de ciclos de segundos, contra ciclos de minutos multiplicados por cada intento. Y hay un beneficio que no se mide en minutos: cuando reproduces, sabes que lo que ves es el fallo real; cuando adivinas a través de CI, nunca estás seguro de si "pasó" porque lo arreglaste o porque el problema era intermitente.

Resumen y siguiente paso

En esta lección le pusiste nombre al fantasma más caro de un pipeline: CI en rojo, tu máquina en verde (o al revés). Viste que no es magia ni un CI defectuoso, sino una brecha de entorno: el código es idéntico, pero las dos máquinas difieren en algo —una versión de Python, una versión de librería, una variable, una zona horaria, un archivo—. Y viste la regla que gobierna el módulo entero: un fallo que no puedes reproducir no lo puedes arreglar, así que reproducir —cerrar la brecha hasta que el rojo aparezca en tu máquina— es el primer paso obligatorio, antes de cualquier intento de arreglo.

Separaste dos oficios que la gente mezcla y que cuesta caro mezclar: reproducir (hacer que el fallo ocurra en tu terreno, que es todo este módulo) y diagnosticar (entender por qué falla, que es la guía hermana test-failure-diagnosis-guide). Y conociste el caso que atraviesa el módulo: el test de hora local de Reservo que pasa con una pytz vieja y falla con una nueva, con las dos salidas reales enfrente —PASSED con 2022.1, AssertionError: assert 15 == 16 con la última—.

Antes de avanzar deberías poder: explicar qué es la brecha de entorno; decir por qué reproducir precede a diagnosticar; distinguir una tarea de reproducción de una de diagnóstico; y argumentar por qué desconfiar del test antes que del entorno suele ser el error inicial.

Lo que sigue es abrir el síntoma en canal. La lección 2 hace el catálogo completo de la brecha de entorno —todas las cosas que pueden diferir entre CI y tu máquina y producir el mismo "rojo aquí, verde allá"— para que cuando te topes con uno, ya tengas la lista de sospechosos en la cabeza y sepas por dónde empezar a buscar.

Recursos