Módulo 3: Reproducir el fallo de CI en tu máquina
3. La brecha de entorno, de cerca
Descripción
Venimos hablando de "el entorno" como si todos supiéramos qué es, pero es hora de abrirlo y mirarlo pieza por pieza. Al terminar esta lección vas a tener un modelo mental preciso de qué compone el entorno de ejecución de tus tests —Python, dependencias, variables, sistema operativo, zona horaria, sistema de archivos, directorio de trabajo— y vas a entender por qué el runner de CI y tu máquina son, por su propia naturaleza, dos mundos distintos que solo coinciden si tú haces el esfuerzo de que coincidan.
La idea central es una imagen que se te va a quedar: CI es una cocina que se monta y se derriba en cada corrida —limpia, vacía, efímera—; tu máquina es una cocina donde llevas años cocinando, con sedimento en cada rincón. Esa asimetría no es un defecto de ninguna de las dos; es lo que son. Reproducir un fallo es, en el fondo, montar en tu máquina una cocina tan limpia y controlada como la de CI. Para lograrlo primero hay que saber de qué está hecha una cocina, y eso es lo que hace esta lección.
Conexión con el módulo. La lección 2 te dio el catálogo de sospechosos. Esta te da el mapa del terreno donde viven: qué es el entorno, capa por capa, y por qué difiere entre las dos máquinas. Es la base conceptual sobre la que se paran las soluciones concretas: la lección 4 (pinnear dependencias) ataca la capa de librerías; la lección 5 (el venv limpio) ataca el sedimento acumulado; la lección 6 (variables y diferencias ocultas) ataca las capas invisibles. Aquí verás además, con salida real, una diferencia oculta en acción —una variable de entorno que mueve el resultado de un test de Reservo—.
La analogía: dos cocinas
Imagina dos cocinas donde se va a preparar la misma receta.
La cocina de CI es la de un plató de televisión: la montan de cero para cada grabación. Traen una estufa nueva, ingredientes recién comprados con fecha de hoy, utensilios limpios, y al terminar lo desarman todo. Nada sobra de la grabación anterior. Es reproducible por diseño: si mañana montan la misma cocina con la misma lista de compras, sale idéntica. Pero justamente por eso es estricta: si tu receta necesitaba una pizca de algo que "siempre estaba en la alacena", en esta cocina no está, porque la alacena está vacía salvo lo que pediste explícitamente.
La cocina de tu casa es donde cocinas todos los días desde hace años. Tiene una estufa que ya conoces sus mañas, especias abiertas de hace meses, un frasco de levadura que compraste para otra receta, sal en un salero que rellenas sin pensar. Todo funciona porque tú sabes dónde está cada cosa y qué hay disponible. Pero nada de eso está escrito en ninguna lista. Si le pasaras la receta a alguien con una cocina vacía, le faltarían diez cosas que para ti son invisibles porque "siempre están ahí".
La brecha de entorno es la distancia entre estas dos cocinas. Tu receta te sale porque tu cocina tiene, sin que lo notes, todo lo que necesita. En la cocina limpia de CI, cualquier cosa que dabas por sentada y no escribiste en la lista de compras —una librería que instalaste hace un año, una variable que exportaste una vez, un archivo que dejaste en el escritorio— sencillamente no está. Reproducir el fallo de CI es cocinar en una cocina tan vacía como la suya, para que a ti también te falte lo que a ella le falta. Y para eso hay que saber qué capas componen una cocina.
Dicho directo:
El entorno de ejecución son varias capas —intérprete, dependencias, variables, sistema, zona horaria, archivos, directorio—. CI monta esas capas limpias y explícitas en cada corrida; tu máquina las tiene sedimentadas e implícitas. La brecha es todo lo que tu máquina tiene "de más" sin que esté escrito en ninguna parte.
Las capas del entorno
Vamos capa por capa. Piensa en cada una como un estrato que puede diferir entre las dos máquinas.
El intérprete de Python
La capa más profunda: qué python corre tus tests, y de qué versión. No es solo "3.14 vs 3.13"; es también cuál 3.14 —el de tu sistema, el de un pyenv, el de un venv—. Cada versión trae su estándar, su sintaxis y sus comportamientos. CI declara su versión explícitamente en el workflow (setup-python con python-version: "3.14"); tu máquina corre el python que tengas en el PATH, que puede ser cualquiera. Cómo verla: python --version.
Las dependencias instaladas
La capa de las librerías de terceros: pytz, requests, pytest, y todo lo que tu proyecto importa y no es del estándar. No solo cuáles están instaladas, sino en qué versión exacta. Esta es la capa donde vive la reina de las causas del módulo. CI instala estas dependencias fresco en cada corrida, a partir de tu requirements.txt; tu máquina las tiene desde cuando las instalaste, con las versiones de ese día. Cómo verla: pip list o pip freeze.
Las variables de entorno
La capa invisible: valores que viven en el shell y que tu código lee con os.environ. Configuración, credenciales, flags, la zona horaria por defecto (TZ), el locale (LANG, LC_ALL). No están en ningún archivo del proyecto; están "en el aire" de cada máquina. Tu shell puede tener docenas que ni recuerdas haber puesto; CI arranca con un conjunto mínimo y limpio. Cómo verla: env o printenv.
El sistema operativo
La capa de la máquina misma: Linux, macOS o Windows; y dentro de Linux, qué distribución. Cambian los saltos de línea (\n vs \r\n), los separadores de ruta (/ vs \), las mayúsculas/minúsculas de los nombres de archivo (Linux distingue Data.csv de data.csv; macOS por defecto no), qué comandos del sistema existen. CI casi siempre corre Linux (ubuntu-latest); tu máquina suele ser macOS o Windows. Lo viste en la cabecera del log: platform linux en CI, platform darwin en tu Mac. Cómo verla: uname -a (o la cabecera de pytest).
La zona horaria y su data
Una sub-capa entre el sistema y las dependencias: la zona horaria actual de la máquina (America/Mexico_City vs UTC) y la base de datos de zonas horarias instalada (que dice, para cada zona, cuándo hubo horario de verano). La primera es del sistema; la segunda viaja dentro de librerías como pytz o de los datos del sistema (tzdata), y se actualiza con el tiempo. Es la capa que rompe el test de Reservo. Cómo verla: la variable TZ, y la versión de pytz/tzdata.
El sistema de archivos
La capa de "qué archivos hay y dónde": los del repositorio (que CI tiene porque hizo checkout), más cualquier cosa que tu máquina tenga y el repo no —un .env, datos de prueba no commiteados, un archivo que creaste a mano—. CI solo tiene lo que está en git más lo que el workflow crea explícitamente; tu máquina tiene todo lo que haya pasado por tu disco. Cómo verla: git status, git ls-files, y comparar contra lo que tu código lee.
El directorio de trabajo
La capa más sutil: desde dónde se corre pytest. Los imports y las rutas relativas resuelven distinto según el directorio actual. CI corre desde la raíz del repo (el checkout deja el proyecto ahí); tú puedes correr desde donde estés parado en la terminal. Correr pytest desde la raíz o desde tests/ puede cambiar qué se importa y qué archivos se encuentran. Cómo verla: pwd, y con qué rootdir arranca pytest (lo imprime en la cabecera).
Por qué CI está limpio y tu máquina no
La diferencia de fondo entre las dos cocinas no es de configuración, es de ciclo de vida. El runner de CI es efímero: nace cuando empieza tu corrida y muere cuando termina. Cada push estrena una máquina virtual (o un contenedor) recién creada, sin memoria de nada anterior. Eso tiene una consecuencia enorme: en CI, solo existe lo que el workflow instala o crea explícitamente. Si tu código necesita pytz, tiene que estar en requirements.txt, porque no hay ninguna pytz "de antes". Si necesita una variable, el workflow tiene que ponerla, porque el shell arranca casi vacío. Esa disciplina forzada es incómoda al principio, pero es exactamente lo que hace a CI reproducible: como no depende de ningún sedimento, dos corridas idénticas dan lo mismo.
Tu máquina es lo contrario: persistente y acumulativa. Llevas meses o años instalando paquetes, exportando variables, dejando archivos, probando cosas. Cada una de esas acciones dejó sedimento, y tu proyecto puede estar usando ese sedimento sin que tú lo sepas. Instalaste pytz hace un año para otro proyecto y quedó ahí; tu test la usa y funciona, pero no está en tu requirements.txt porque "ya estaba". Exportaste RESERVO_TAX_PERCENT en una sesión y quedó en tu .zshrc; tu test la lee y pasa, pero el runner no la tiene. Ese sedimento es cómodo —las cosas "simplemente funcionan"— y por eso es peligroso: te oculta las dependencias reales de tu proyecto, hasta que CI, con su cocina vacía, te las revela una por una en forma de rojo.
Esto reencuadra qué es realmente reproducir un fallo. No es "hacer que mi máquina se comporte raro"; es quitarle a mi máquina el sedimento que la hace demasiado amable, hasta que se parezca a la cocina limpia de CI. Por eso la herramienta central de la reproducción (lección 5) es un entorno virtual limpio: un rincón nuevo y vacío dentro de tu máquina donde solo existe lo que instalas a propósito, igual que en CI.
Ejemplo trabajado: una diferencia oculta, con salida real
Hagamos visible una capa invisible. Reservo tiene una funcioncita que calcula el total de una reserva sumándole un impuesto local, y ese porcentaje de impuesto lo lee de una variable de entorno (para poder configurarlo por país sin tocar el código):
# reservo/config.py
import os
def tax_percent():
"""Reservo lee la tasa de impuesto local del entorno (default 0)."""
return int(os.environ.get("RESERVO_TAX_PERCENT", "0"))
def total_with_tax_cents(price_cents):
return price_cents + price_cents * tax_percent() // 100
Y su test, que un dev escribió en su máquina —donde tenía RESERVO_TAX_PERCENT=16 exportada desde hacía tiempo—:
# test_config.py
from reservo.config import total_with_tax_cents
def test_pro_3h_total_with_tax():
# pro 3h = 6000; el dev tiene RESERVO_TAX_PERCENT=16 exportada en su shell.
assert total_with_tax_cents(6000) == 6960
El número esperado sale de la cuenta: 6000 centavos (lo que paga un pro por 3 horas de Focus) más el 16% de impuesto (6000 * 16 // 100 = 960) da 6960. En la máquina del dev, con la variable puesta, pasa. En el runner limpio de CI, sin la variable, el impuesto es 0 y el total es 6000, así que el assert 6000 == 6960 falla.
Lo corrí de verdad en un mismo entorno, cambiando solo si la variable está puesta o no —para aislar esa única capa—.
Qué esperar. Con la variable exportada (la máquina del dev):
$ RESERVO_TAX_PERCENT=16 python -m pytest test_config.py -q
. [100%]
1 passed in 0.00s
Y sin la variable (el runner limpio de CI):
$ env -u RESERVO_TAX_PERCENT python -m pytest test_config.py -q
F [100%]
=================================== FAILURES ===================================
_________________________ test_pro_3h_total_with_tax __________________________
def test_pro_3h_total_with_tax():
# pro 3h = 6000; el dev tiene RESERVO_TAX_PERCENT=16 exportada en su shell.
> assert total_with_tax_cents(6000) == 6960
E assert 6000 == 6960
E + where 6000 = total_with_tax_cents(6000)
test_config.py:6: AssertionError
=========================== short test summary info ============================
FAILED test_config.py::test_pro_3h_total_with_tax - assert 6000 == 6960
1 failed in 0.02s
Mismo código, mismo test, misma versión de todo. La única diferencia es una capa invisible del entorno —una variable de entorno— y con eso el resultado salta de 6960 a 6000, y el color de verde a rojo. Fíjate en lo traicionero que es: si lees el código del proyecto entero, jamás encontrarás por qué las dos máquinas discrepan, porque el valor que las diferencia (RESERVO_TAX_PERCENT=16) no está en el proyecto —está en el shell del dev—. Esta es la razón por la que las capas invisibles (variables, tz, sistema) son las más difíciles de cazar: no se ven leyendo el repo; solo se ven comparando los dos entornos. Cómo hacer esa comparación es la lección 6.
Profundización: hacer explícito lo implícito
Si la brecha de entorno es "todo lo que tu máquina tiene de más sin que esté escrito", entonces la cura de fondo —más allá de reproducir un fallo puntual— es una disciplina: hacer explícito lo implícito. Cada cosa de la que tu proyecto depende debería estar declarada en algún lugar del repositorio, no vivir como sedimento en tu máquina.
- Las dependencias van en
requirements.txt(lección 4), con versiones exactas, para que la capa de librerías sea idéntica en cualquier cocina. - La versión de Python va declarada —en el workflow de CI, y a menudo en un archivo como
.python-versiono en elpyproject.toml— para que la capa del intérprete no quede al azar. - Las variables de entorno que el proyecto necesita van documentadas (un
.env.exampleque sí se commitea, aunque el.envreal no), y el workflow de CI las define explícitamente, para que la capa invisible deje de serlo. - Los archivos de datos que los tests necesitan van commiteados (o generados por el propio test), para que la capa del sistema de archivos no dependa de tu disco.
Cuando todo lo que el proyecto necesita está declarado, la brecha de entorno se encoge hasta casi desaparecer: la cocina de CI y una cocina limpia en tu máquina tienen la misma lista de compras, así que salen iguales. Reproducir un fallo, entonces, deja de ser una arqueología ("¿qué tendrá mi máquina que la de CI no?") y se vuelve mecánico ("instalo exactamente lo declarado, en un entorno limpio, y corro el mismo comando"). Ese es el destino al que apunta el módulo, y esta lección es el mapa que lo hace posible: no puedes cerrar una brecha cuyas capas no conoces.
Errores comunes
Creer que "el entorno" es solo la versión de Python. Qué pasa: cuando algo difiere entre CI y local, solo revisas python --version y, si coincide, concluyes que "los entornos son iguales". Por qué pasa: la versión de Python es la capa más conocida y fácil de comparar. Cómo detectarlo: si tu checklist de entorno tiene un solo ítem, te faltan seis. Cómo corregirlo: el entorno son todas las capas —intérprete, dependencias, variables, sistema, tz, archivos, directorio—. Que Python coincida no dice nada sobre si pytz coincide, o si tienes una variable que el runner no tiene.
Confiar en que "ya estaba instalado" es suficiente. Qué pasa: tu proyecto importa una librería que no está en requirements.txt, pero funciona en tu máquina porque la instalaste hace tiempo para otra cosa. Por qué pasa: el sedimento de tu máquina hace que todo "simplemente funcione", y no notas la dependencia no declarada. Cómo detectarlo: si tu código hace import X y X no aparece en requirements.txt, tienes una dependencia implícita esperando a explotar en la cocina limpia de CI. Cómo corregirlo: declara toda dependencia que importes. La prueba de fuego es la lección 5: en un venv limpio, import X fallará con ModuleNotFoundError si no está declarada.
Buscar en el repositorio una diferencia que vive en el shell. Qué pasa: un fallo se debe a una variable de entorno o a la zona horaria del sistema, pero tú relees el código del proyecto una y otra vez buscando la causa. Por qué pasa: es natural buscar la diferencia "dentro del proyecto", donde tienes control. Cómo detectarlo: si llevas rato leyendo el repo y no encuentras nada que difiera —porque el repo es idéntico en ambas máquinas—, la diferencia probablemente vive en una capa que no está en el repo. Cómo corregirlo: deja de leer el código y compara los entornos: env/printenv para variables, pip freeze para dependencias, python --version para el intérprete. La diferencia que buscas no está en el proyecto; está en la cocina.
Ejercicios
Ejercicio 1 — Ubica la capa. Para cada diferencia, di en qué capa del entorno vive y con qué comando la verías. (a) CI tiene requests 2.32 y tú requests 2.28. (b) CI corre Linux y tú macOS. (c) Tu shell tiene LANG=es_MX.UTF-8 y el runner LANG=C. (d) Un CSV de prueba existe en tu disco pero no en el repo. (e) Tú corres pytest desde tests/ y CI desde la raíz.
Ver solución
- (a) Capa de dependencias instaladas; se ve con
pip freeze(opip show requests). - (b) Capa del sistema operativo; se ve con
uname -ao la cabecera de pytest (platform linuxvsplatform darwin). - (c) Capa de variables de entorno (el locale); se ve con
env | grep LANGoprintenv LANG. - (d) Capa del sistema de archivos; se ve con
git status/git ls-files(el archivo no aparece si no está commiteado). - (e) Capa del directorio de trabajo; se ve con
pwdy con elrootdirque pytest imprime en su cabecera.
Ejercicio 2 — Predice el resultado. Dado el total_with_tax_cents de la lección, ¿qué imprimiría python -m pytest test_config.py -q en cada caso? (a) RESERVO_TAX_PERCENT=0. (b) RESERVO_TAX_PERCENT=10. (c) La variable no está puesta. Justifica cada número.
Ver solución
El test espera total_with_tax_cents(6000) == 6960, que corresponde a un impuesto del 16% (6000 + 6000*16//100 = 6000 + 960 = 6960).
- (a)
RESERVO_TAX_PERCENT=0: el total es6000 + 6000*0//100 = 6000.assert 6000 == 6960falla →1 failed. - (b)
RESERVO_TAX_PERCENT=10: el total es6000 + 6000*10//100 = 6600.assert 6600 == 6960falla →1 failed. - (c) La variable no está:
os.environ.get(..., "0")devuelve"0", así que el impuesto es 0 y el total es6000. falla →1 failed(idéntico al caso a).
Solo pasaría con RESERVO_TAX_PERCENT=16. El test está atado a un valor de una capa invisible; ese acoplamiento es el problema. (Un test más robusto pondría el valor explícitamente en el test —con monkeypatch.setenv— en vez de depender del shell; pero eso es diseño de tests, tema de las guías hermanas.)
Ejercicio 3 — La lista de compras. Tu proyecto de Reservo importa pytz y lee la variable RESERVO_TAX_PERCENT, y sus tests cargan un sample_bookings.csv. Enumera qué tendrías que declarar/commitear —y dónde— para que una cocina limpia (CI o un venv nuevo) tenga todo lo que el proyecto necesita, sin depender del sedimento de tu máquina.
Ver solución
Para que cualquier cocina limpia reproduzca tu proyecto:
pytz→ declararla enrequirements.txtcon versión exacta (pytz==2026.3.post1), para que la capa de dependencias sea idéntica. Sin esto, un venv limpio daráModuleNotFoundError: No module named 'pytz'.- La versión de Python → declararla en el workflow de CI (
setup-pythonconpython-version) y, opcionalmente, en un.python-version/pyproject.toml, para que la capa del intérprete no quede al azar. RESERVO_TAX_PERCENT→ documentar que existe (por ejemplo, un.env.examplecommiteado conRESERVO_TAX_PERCENT=16) y definirla explícitamente en el workflow de CI, para que la capa invisible deje de serlo. Idealmente, además, los tests no deberían depender del shell: deberían fijar el valor ellos mismos.sample_bookings.csv→ commitearlo al repositorio (o hacer que el test lo genere), para que la capa del sistema de archivos no dependa de tu disco. Y usar rutas relativas al proyecto, no absolutas de tu máquina.
Con esas cuatro cosas declaradas, la lista de compras del proyecto está completa: una cocina vacía puede montar exactamente lo necesario y reproducir tu resultado. Todo lo que quede sin declarar es una futura sorpresa roja en CI.
Resumen y siguiente paso
En esta lección abriste "el entorno" y lo miraste por dentro: sus capas —intérprete de Python, dependencias instaladas, variables de entorno, sistema operativo, zona horaria y su data, sistema de archivos, directorio de trabajo— y el comando con que verías cada una. Y entendiste la asimetría de fondo con la imagen de las dos cocinas: CI es efímero y limpio (solo existe lo que instala explícitamente, por eso es reproducible), tu máquina es persistente y acumulativa (llena de sedimento que hace que todo "simplemente funcione", por eso oculta tus dependencias reales). Reproducir un fallo es quitarle a tu máquina ese sedimento hasta que se parezca a la cocina de CI.
Viste una capa invisible en acción, con salida real: una variable de entorno (RESERVO_TAX_PERCENT) que mueve el total de una reserva de 6960 a 6000 y el color de verde a rojo, sin que nada en el proyecto explique la diferencia —porque el valor que la causa vive en el shell, no en el repo—. Y sacaste la disciplina de fondo: hacer explícito lo implícito, declarar en el repositorio todo aquello de lo que el proyecto depende, para que la brecha se encoja hasta casi desaparecer.
Antes de avanzar deberías poder: nombrar las siete capas del entorno y cómo se inspecciona cada una; explicar por qué CI está limpio y tu máquina no, en términos de ciclo de vida; y argumentar por qué las capas invisibles (variables, tz) no se cazan leyendo el repo sino comparando entornos.
Lo que sigue es bajar a la capa más ruidosa de todas —la de las dependencias— y a la herramienta que la domestica. La lección 4 disecciona requirements.txt, la diferencia entre pinnear con == y dejar un rango con >=, y por qué ese detalle es el que hace que tu local y CI terminen con versiones distintas de una librería. Es la causa raíz del fallo de Reservo, y la vas a ver ocurrir con un venv real.
Recursos
- Entornos virtuales (
venv) — documentación de Python — la herramienta con la que se construye una "cocina limpia" dentro de tu máquina, sin el sedimento acumulado. Es la respuesta directa a la asimetría de las dos cocinas. os.environ— documentación de Python — la interfaz por la que un programa lee la capa invisible de variables de entorno. Entenderla explica por qué una variable del shell puede mover un test sin dejar rastro en el código.- Módulo
zoneinfo— documentación de Python — la base de datos de zonas horarias del estándar, que ilustra por qué la capa de tz cambia con el tiempo y por qué su data viaja dentro de dependencias. - Variables por defecto en GitHub Actions — documentación de GitHub — el conjunto mínimo de variables con que arranca un runner limpio, útil para ver qué no tiene por defecto (y por qué tu variable del shell no está ahí).