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

8. Mini-proyecto: reproduce y arregla un fallo por una dependencia sin pinnear

Descripción

Llegó el momento de ejecutar el módulo entero con tus propias manos. En este mini-proyecto tomas un fallo real de Reservo —CI en rojo, tu máquina en verde, sin que nadie tocara el código— causado por una dependencia sin pinnear, y lo llevas de principio a fin: lo reproduces en tu máquina con el método de la lección 7, lo confirmas rojo, lo arreglas (pin correcto + expectativa actualizada), y dejas el build verde de forma reproducible. No es un ejercicio de leer: es de hacer. Al final vas a tener el requirements.txt pinneado, el venv limpio, la salida de la reproducción y la del arreglo —el entregable completo de un fallo de CI cerrado bien—.

Este es el cierre de la guía de reproducir fallos de entorno, y usa la herramienta central del módulo —un venv limpio que replica al de CI— sobre el caso que lo atravesó: el test de hora local de Reservo que pasa con una pytz vieja y falla con una nueva. Todo lo que viste demostrado en las lecciones anteriores, ahora lo produces tú.

Conexión con el módulo. Las siete lecciones anteriores te dieron los conceptos (la brecha, las capas), las herramientas (el pin, el venv limpio, el control de variables) y el método (los cinco pasos). El mini-proyecto los integra en un solo flujo real. Y marca la frontera del módulo con un caso limpio: este fallo se cierra aquí porque su causa es una expectativa que envejeció, no un bug del código —si fuera un bug, la reproducción sería el punto de partida de la guía hermana de diagnóstico—.

El encargo

Eres quien está de guardia en Reservo esta semana. Llega esta alerta:

El build de master está en rojo. El test test_summer_booking_starts_at_16_local falla en CI con AssertionError: assert 15 == 16. Nadie ha tocado ese archivo en semanas. En la máquina de quien lo escribió, la suite pasa. Necesitamos el build en verde y entender qué pasó, sin apagar el test a lo bruto.

Tienes a la mano el material del proyecto.

El código de la funcioncita (no ha cambiado):

# reservo/localtime.py
import pytz


def local_start_hour(booking, tz_name):
    """Hora de pared en que empieza una reserva, en la zona del miembro.

    booking.start es un datetime UTC con zona. Reservo usa la hora local
    para etiquetar la reserva como diurna/vespertina en la ciudad del miembro.
    """
    tz = pytz.timezone(tz_name)
    return booking.start.astimezone(tz).hour

El test (no ha cambiado):

# test_localtime.py
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():
    assert local_start_hour(a_booking(), "America/Mexico_City") == 16

El requirements.txt (aquí está la semilla del problema):

# requirements.txt
pytz>=2022.1

El log de CI (el fallo y las versiones que instaló):

Run actions/setup-python@v5 with python-version 3.14
$ pip install -r requirements.txt
$ pip freeze
iniconfig==2.3.0
packaging==26.2
pluggy==1.6.0
pytest==9.1.1
pytz==2026.3.post1
$ pytest -q
============================= 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%]
FAILED test_localtime.py::test_summer_booking_starts_at_16_local - assert 15 == 16
1 failed in 0.04s

Tu tarea

En cinco partes, siguiendo el método del módulo:

  1. Diagnostica la brecha (en papel). Con el catálogo de la lección 2 y el log, di cuál es el sospechoso, y por qué el mismo requirements.txt produjo versiones distintas de pytz en la máquina del dev y en CI.
  2. Reproduce el fallo. Monta un venv limpio con la versión de Python de CI, pinnea pytz a la versión exacta que instaló CI, corre el mismo comando y confirma que obtienes el mismo rojo (assert 15 == 16).
  3. Confirma la causa. Monta un segundo venv con la versión vieja de pytz (la que tenía el dev) y muestra que ahí el test pasa —probando que la versión de la dependencia es la única diferencia—.
  4. Arréglalo. Decide y aplica el arreglo correcto: pinnear pytz para que CI y local no vuelvan a divergir, y corregir la expectativa del test que envejeció. Justifica por qué 15 es el valor correcto y no 16.
  5. Entrega. Reúne el requirements.txt pinneado, el test arreglado, y las salidas de la reproducción (rojo) y del arreglo (verde reproducible).

Intenta hacerlo tú antes de mirar la solución. Todo lo que necesitas está en las lecciones 2, 4, 5 y 7.

Pistas

  • Parte 1: el valor está corrido justo una hora (15 vs 16), en un cálculo con zona horaria. Piensa en qué capa vive la data de zonas horarias y por qué un >= deja que dos máquinas instalen versiones distintas según cuándo instalaron.
  • Parte 2: el paso 0 del método ya te dio la ficha en el log: Python 3.14, pytz==2026.3.post1, comando pytest -q. Crea el venv con python3.14, instala el pin exacto, verifica la versión antes de correr.
  • Parte 4: ¿México tenía horario de verano en julio de 2023? Búscalo (lo abolió en octubre de 2022). Si en 2023 no había horario de verano, 21:00 UTC en Ciudad de México (UTC−6) son las 15:00, no las 16:00. El test tenía razón cuando se escribió (con la data vieja), y la pytz nueva lo corrige. El arreglo no es "hacer que dé 16"; es aceptar que 15 es la verdad y pinnear para que el resultado sea estable.

Solución de referencia

Ver la solución completa (diagnóstico + reproducción + confirmación + arreglo + entrega, con salidas reales)

Parte 1 — Diagnóstico de la brecha

El sospechoso es una dependencia con otra versión (lección 2, sospechoso #2), que además se solapa con la zona horaria (#5) porque la data de zonas horarias viaja dentro de pytz. La huella lo confirma: el valor está corrido justo una hora (15 vs 16) en un cálculo con zona horaria.

Por qué el mismo requirements.txt produjo versiones distintas: la línea pytz>=2022.1 es un rango, no un pin. Significa "la 2022.1 o cualquiera más nueva". El dev instaló hace tiempo, cuando la más nueva era la 2022.1, y esa quedó sedimentada en su máquina. CI es efímero: instala fresco en cada corrida, y hoy "la más nueva que sea ≥ 2022.1" es la 2026.3.post1, así que agarra esa. Mismo archivo, dos versiones —2022.1 en local, 2026.3.post1 en CI—, porque el >= dejó que "el momento de instalar" eligiera. Y como México abolió el horario de verano en octubre de 2022, la pytz vieja todavía cree que en verano CDMX está en UTC−5 (16:00) y la nueva ya sabe que está en UTC−6 (15:00).

Parte 2 — Reproducir el fallo

Ficha del log (paso 0): Python 3.14.0, pytz==2026.3.post1, pytest 9.1.1, comando pytest -q, fallo assert 15 == 16. Montamos el venv limpio con esa versión de Python, pinneamos pytz a la de CI, verificamos y corremos:

$ python3.14 -m venv repro-venv
$ repro-venv/bin/python --version
Python 3.14.0
$ repro-venv/bin/python -m pip install pytz==2026.3.post1 pytest==9.1.1
$ repro-venv/bin/python -c "import pytz; print('pytz:', pytz.__version__)"
pytz: 2026.3.post1
$ repro-venv/bin/python -m pytest test_localtime.py -q
F                                                                        [100%]
=================================== FAILURES ===================================
____________________ test_summer_booking_starts_at_16_local ____________________

>       assert local_start_hour(a_booking(), "America/Mexico_City") == 16
E       AssertionError: assert 15 == 16
E        +  where 15 = local_start_hour(Booking(id='bk-1', ...), 'America/Mexico_City')

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.03s

Reproducido. El mismo assert 15 == 16 de CI, ahora en la máquina, a voluntad.

Parte 3 — Confirmar la causa

Montamos un segundo venv, idéntico salvo por la versión de pytz (la vieja, la del dev), y corremos lo mismo:

$ python3.14 -m venv old-venv
$ old-venv/bin/python -m pip install pytz==2022.1 pytest==9.1.1
$ old-venv/bin/python -c "import pytz; print('pytz:', pytz.__version__)"
pytz: 2022.1
$ old-venv/bin/python -m pytest test_localtime.py -q
1 passed in 0.02s

Verde con la vieja. Dos venvs limpios, misma máquina, mismo código, mismo comando; la única diferencia es el número de versión de pytz, y con eso el color cambia. Queda probado que la dependencia es la causa: 2026.3.post1 → rojo, 2022.1 → verde.

Parte 4 — El arreglo

¿México tenía horario de verano en julio de 2023? No: lo abolió en octubre de 2022. Así que en verano de 2023, Ciudad de México estaba en UTC−6 todo el año, y 21:00 UTC son las 15:00 locales, no las 16:00. El test esperaba 16 porque se escribió con una pytz que aún traía la regla vieja del horario de verano. La pytz nueva no está "rota": está corrigiendo un dato del mundo. Por lo tanto, el valor correcto es 15, y el arreglo tiene dos partes:

  1. Pinnear pytz para que CI y local instalen siempre la misma versión (fin de la divergencia). El requirements.txt pasa de un rango a un pin exacto:
# requirements.txt (arreglado)
pytz==2026.3.post1
  1. Actualizar la expectativa del test al valor correcto, y de paso renombrarlo y comentar por qué, para que no vuelva a envejecer en silencio:
# test_localtime.py (arreglado)
def test_summer_booking_starts_at_15_local():
    # CDMX abolio el horario de verano en oct-2022: en verano es UTC-6 (CST),
    # asi que 21:00 UTC = 15:00 local. (Antes de 2022 habria sido 16:00 con DST.)
    assert local_start_hour(a_booking(), "America/Mexico_City") == 15

Corremos el test arreglado en el venv con la pytz pinneada de CI:

$ repro-venv/bin/python -m pytest test_localtime_fixed.py -v
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
collected 1 item

test_localtime_fixed.py::test_summer_booking_starts_at_15_local PASSED    [100%]

============================== 1 passed in 0.02s ===============================

Verde, y reproducible. Ahora el test afirma la verdad (15), y el requirements.txt pinneado garantiza que CI y cualquier máquina instalen pytz==2026.3.post1, así que el resultado es el mismo en todas partes. La brecha se cerró de raíz: no volverá a haber "rojo aquí, verde allá" por la versión de pytz, porque ya no hay margen para que difiera.

Nota sobre por qué no se resolvió apagando el test: marcar skip habría dejado el build verde en falso —el cálculo de hora local quedaría sin cobertura, y algún usuario vería la hora equivocada sin que nadie se enterara—. El test tenía razón; lo que estaba mal era su número esperado, no el test. Arreglar la expectativa (y pinnear) conserva la protección; apagarlo la tira.

Parte 5 — La entrega

El paquete completo del fallo cerrado:

  • requirements.txt cambiado de pytz>=2022.1 a pytz==2026.3.post1 (pin exacto → fin de la divergencia).
  • test_localtime.py con la expectativa corregida a 15 (con comentario que explica el porqué, para que no vuelva a envejecer en silencio).
  • Salida de la reproducción (Parte 2): assert 15 == 16 en un venv limpio con pytz==2026.3.post1 → el rojo de CI, reproducido.
  • Salida de la confirmación (Parte 3): 1 passed con pytz==2022.1 → la dependencia era la única diferencia.
  • Salida del arreglo (Parte 4): 1 passed con el test corregido y pytz pinneada → verde reproducible.

Con esto, el build vuelve a verde entendiendo qué pasó, no tapándolo, y el problema no puede repetirse porque la causa (el rango sin pinnear) quedó eliminada.

Cómo se ve el arreglo en el pipeline

Cerraste el fallo en tu máquina, pero el objetivo último era el build de CI. Vale la pena ver cómo tu arreglo se traduce en el pipeline, porque cierra el círculo con el workflow que montaste en el módulo 2. El workflow no cambia; lo que cambia es que ahora instala una versión fija de pytz y corre un test cuya expectativa es correcta:

# .github/workflows/ci.yml (sin cambios; el arreglo vive en requirements.txt y el test)
- uses: actions/setup-python@v5
  with:
    python-version: "3.14"
- run: pip install -r requirements.txt   # ahora resuelve pytz==2026.3.post1, no "la mas nueva"
- run: pip freeze                          # buena practica: deja las versiones en el log
- run: pytest -q

Con el requirements.txt pinneado, el step de pip install ya no depende de cuándo corre: instala siempre pytz==2026.3.post1, la misma versión en la que reprodujiste y arreglaste. Y como agregaste el pip freeze, la próxima vez que algo se rompa, el log de CI traerá las versiones exactas listas para copiar —el paso 0 del método (lección 7) se vuelve copiar y pegar—. Así se leería el step de tests en verde, en el formato del log de CI:

$ pytest -q
============================= 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_15_local PASSED          [100%]
1 passed in 0.03s

Nota que aquí la cabecera dice platform linux —es el runner— mientras que tu reproducción local decía platform darwin —tu Mac—. Esa diferencia de plataforma es la única capa que no igualaste (ni hacía falta: el fallo de pytz no dependía del SO). Es un recordatorio honesto de que reproducir no busca clonar toda la cocina de CI, sino igualarla en las capas que importan para este fallo —y para un fallo de versión de dependencia, esas capas eran el intérprete y pytz, no el sistema operativo—. El build vuelve a verde, en el runner real, por la misma razón por la que volvió a verde en tu venv: la versión de pytz quedó fija y la expectativa quedó correcta.

Errores comunes

"Arreglarlo" cambiando 16 por lo que sea que haga pasar el test, sin entender por qué. Qué pasa: ves assert 15 == 16, cambias el 16 por 15 mecánicamente para que pase, sin averiguar si 15 es correcto. Por qué pasa: la prisa por el verde. Cómo detectarlo: si no puedes explicar por qué 15 es la verdad (el horario de verano abolido), estás ajustando números a ciegas. Cómo corregirlo: entiende la causa antes de tocar el número. Aquí resultó que 15 es correcto, pero en otro caso el fallo podría ser un bug del código y cambiar el esperado tapría el bug. Cambia la expectativa solo cuando compruebes que la nueva realidad es la correcta.

Arreglar la expectativa pero olvidar pinnear. Qué pasa: corriges el 16 a 15, el build pasa, y dejas el requirements.txt con pytz>=2022.1. Por qué pasa: el síntoma visible (el test) ya está verde, así que parece resuelto. Cómo detectarlo: si tu requirements.txt sigue teniendo un >=, la causa raíz sigue viva. Cómo corregirlo: pinnea. Si no lo haces, la próxima vez que pytz cambie algo, CI y local volverán a divergir y tendrás otro fantasma. Arreglar la expectativa cura este síntoma; pinnear cura la enfermedad (la divergencia por versión).

Reproducir en el entorno global en vez de un venv limpio. Qué pasa: instalas pytz==2026.3.post1 encima de tu Python de siempre para reproducir, y de paso rompes otros proyectos que dependían de tu pytz vieja. Por qué pasa: crear un venv se siente como un paso de más. Cómo detectarlo: si corriste pip install sin un venv activo/apuntado, tocaste tu entorno global. Cómo corregirlo: siempre reproduce en un venv limpio y desechable (rm -rf al terminar). Aísla el experimento y no dejas daño colateral en tu máquina.

Ejercicios

Ejercicio 1 — Otra dependencia, mismo patrón. Supón que el fallo no fuera de pytz sino de una librería de formateo de fechas, dateformat, con dateformat>=1.0 en el requirements.txt; CI instaló dateformat==3.0 y tu máquina tiene 1.0. Escribe los comandos para reproducir el fallo en un venv limpio, asumiendo Python 3.14 y comando pytest -q.

Ver solución

El patrón es idéntico al de pytz; solo cambia el nombre de la dependencia:

# 1. Venv limpio con la version de Python de CI
python3.14 -m venv repro-venv

# 2. Pinnear la dependencia a la version EXACTA que instalo CI (del pip freeze del log)
repro-venv/bin/python -m pip install dateformat==3.0 pytest==9.1.1

# 3. Verificar la version antes de correr
repro-venv/bin/python -c "import dateformat; print(dateformat.__version__)"   # -> 3.0

# 4. Correr el mismo comando, desde la raiz del proyecto
repro-venv/bin/python -m pytest -q

Si reproduce el rojo, confirma la causa montando un segundo venv con dateformat==1.0 (la vieja) y viendo que ahí pasa. El arreglo: pinnear dateformat a una versión decidida (==3.0 si el nuevo comportamiento es el correcto) y ajustar el test si su expectativa envejeció. Mismo método, cualquier dependencia.

Ejercicio 2 — El arreglo que tapa un bug. En un caso distinto, reproduces un fallo assert 5000 == 6000: el test de reembolso a 72h esperaba 6000 y ahora da 5000. Investigando, ves que la versión nueva de una dependencia no tiene nada que ver —alguien cambió refund_cents para devolver price_paid_cents * 5 // 6 en el tramo de 100%—. ¿Por qué aquí no debes cambiar el 6000 por 5000, y qué deberías hacer?

Ver solución

Aquí 6000 es la verdad y 5000 es el error: el ancla de Reservo dice que un reembolso 72 horas antes (≥ 48h) devuelve el 100% de lo pagado, y para un precio de 6000 eso es 6000, no 5000. El 5 // 6 que alguien metió en refund_cents es un bug —convierte el 100% en un ~83%—. Cambiar el test a assert 5000 == 5000 taparía ese bug: dejaría el build verde mientras el reembolso real quedaría mal, y los clientes recibirían de menos.

Lo correcto: no tocar la expectativa (el 6000 es correcto) y arreglar el código —revertir refund_cents a devolver price_paid_cents completo en el tramo de 100%—. Este es justo el caso que se bifurca a diagnóstico: reproducir te dio el fallo, pero entender que el bug está en el código (y no en una expectativa que envejeció) es lo que decide que el arreglo va en el código, no en el test. La regla: cambia el número esperado solo cuando la nueva realidad sea la correcta; si el esperado seguía siendo la verdad, el bug está en otra parte.

Ejercicio 3 — Prevención. Más allá de este fallo puntual, propón dos cambios en el proyecto de Reservo que reduzcan la probabilidad de volver a tener un "CI rojo, local verde" por versiones de dependencias. Explica qué previene cada uno.

Ver solución

Dos cambios preventivos:

  1. Pinnear todo con un lockfile (pip freeze), no solo pytz. Reemplazar el requirements.txt de rangos por la salida de pip freeze del entorno verde, con == en cada línea, incluidas las dependencias indirectas. Previene la divergencia de raíz: CI y local instalan exactamente el mismo árbol de versiones, así que el "cuándo instalaste" ya no puede mover nada. Las actualizaciones se vuelven deliberadas (cambias el pin, corres la suite, subes si sigue verde).

  2. Hacer que el workflow de CI imprima pip freeze (un - run: pip freeze de una línea). No previene el fallo, pero hace la reproducción trivial la próxima vez: el log tendrá las versiones exactas listas para copiar, sin que tengas que adivinar qué resolvió un rango. Convierte el paso 0 del método (extraer los hechos) en copiar y pegar.

Complemento (adelanto del módulo 4): correr la suite en una matriz de versiones de Python y de dependencias a propósito, para enterarte de una incompatibilidad antes de que un usuario o CI la descubran por sorpresa. En vez de solo evitar que las versiones cambien, pruebas contra varias adrede y sabes con cuáles funcionas.

Resumen y siguiente paso

En este mini-proyecto ejecutaste el módulo entero con tus manos: tomaste un fallo real de Reservo —CI rojo, local verde, por un pytz>=2022.1 sin pinnear— y lo cerraste bien. Diagnosticaste la brecha (una dependencia con otra versión, con la data de tz dentro), lo reprodujiste en un venv limpio con la versión exacta de CI (assert 15 == 16), confirmaste la causa mostrando que con la pytz vieja el test pasa, y lo arreglaste de raíz: pinneando pytz==2026.3.post1 para acabar con la divergencia y corrigiendo la expectativa del test a 15 —la verdad, porque Ciudad de México ya no tiene horario de verano—, con salida real verde y reproducible. Y viste por qué apagar el test con skip habría sido la peor salida: el test tenía razón; lo viejo era su número esperado.

Con esto cierras la guía de reproducir un fallo de CI en tu máquina. Ya sabes convertir el fantasma más caro de un pipeline —"funciona en mi máquina"— en un fallo que ocurre a voluntad, cerrando la brecha de entorno capa por capa: el intérprete, las dependencias pinneadas, el venv limpio, las variables y la zona horaria, y el método de cinco pasos que lo junta todo. Y sabes dónde termina tu trabajo (reproducido el fallo) y dónde empieza el de la guía hermana de diagnóstico (entender por qué falla un bug real).

Lo que sigue es dar vuelta a la moneda. En vez de reproducir una versión que rompió tu suite después de que pasó, el módulo 4 te enseña a anticiparte: la matriz de versiones, correr tu suite a propósito contra varias versiones de Python (3.12, 3.13, 3.14) y de sistema operativo en cada push, para enterarte de una incompatibilidad antes de que te sorprenda en rojo. Pasaste de apagar incendios a instalar detectores de humo.

Recursos