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

6. Variables de entorno y otras diferencias ocultas

Descripción

El venv limpio de la lección anterior cierra la capa más ruidosa de la brecha —las dependencias— pero deja abiertas las capas invisibles: las diferencias que no viven en requirements.txt ni en el código, sino "en el aire" de cada máquina. Esta lección va tras ellas: las variables de entorno, la zona horaria del sistema, el orden en que corren los tests, un archivo que solo existe en tu disco, el directorio de trabajo, el locale. Al terminar vas a saber reconocerlas, cazarlas comparando los dos entornos, y replicarlas para que tu reproducción sea completa cuando el fallo no era (solo) de una dependencia.

Estas diferencias son las más difíciles de encontrar precisamente porque son invisibles: puedes leer el proyecto entero —código, tests, requirements.txt— y no hallar la causa, porque el valor que rompe la simetría no está en el proyecto; está en tu shell, en la config del sistema, en tu disco. La técnica cambia: en vez de leer el repo, hay que comparar los entornos. Vas a ver dos de estas capas en acción con salida real —una variable de entorno de la app y la zona horaria del sistema—, cada una moviendo el resultado de un test de Reservo sin tocar una sola línea de código.

Conexión con el módulo. La lección 5 te dio el venv limpio y te advirtió qué capas no cubre; esta se ocupa exactamente de esas. Es la que completa el arsenal antes de que la lección 7 lo junte todo en un método paso a paso. Con las dependencias (lección 4-5) y las diferencias ocultas (esta) bajo control, tendrás cubierta toda la brecha que un fallo de "CI rojo, local verde" suele esconder. Sobre la suite de Reservo.

La analogía: lo que la receta da por sentado

Vuelve a la receta que solo te sale a ti. Ya descartaste los ingredientes escritos (las "dependencias": misma harina, misma marca). Y aun así, a ti sale distinto. La causa está en lo que la receta da por sentado y no escribe: "el agua hierve a 100°" (pero tú vives a 2000 metros, donde hierve a 93°), "a temperatura ambiente" (pero tu cocina está a 15° y la de tu amiga a 28°), "usa tu sal de siempre" (pero la tuya es más gruesa). Nada de eso está en la lista de ingredientes; son condiciones del ambiente que la receta asume idénticas y no lo son.

Las diferencias ocultas del entorno son eso: condiciones que tu código da por sentadas sin declararlas. "La variable RESERVO_TAX_PERCENT estará puesta" (pero en CI no está). "La máquina está en horario de Ciudad de México" (pero el runner está en UTC). "El archivo sample_bookings.csv estará ahí" (pero no se commiteó). El código no las menciona porque, en la cocina donde se escribió, siempre estuvieron presentes —igual que la altitud de tu cocina, que nunca anotas porque para ti es constante—. Cazarlas es preguntarse qué está asumiendo mi código que en la otra cocina no se cumple, y para responder eso hay que mirar el ambiente, no la receta.

Dicho directo:

Las diferencias ocultas son condiciones del entorno que tu código da por sentadas sin declararlas: variables, zona horaria, archivos, directorio, locale. No se ven leyendo el proyecto —porque no están en él— sino comparando los dos entornos. La cura es hacerlas explícitas.

El catálogo de lo invisible

Repasemos las capas invisibles, con su mecanismo y cómo se cazan.

Variables de entorno

Valores que tu código lee con os.environ y que viven en el shell, no en el proyecto. Configuración, flags, credenciales, tasas. Tu shell puede tener docenas acumuladas; CI arranca con un conjunto mínimo. Cómo cazarlas: env o printenv listan todas las de tu shell; compara esa lista con las que el workflow de CI define (en su bloque env:). Lo que tú tengas y CI no —o con otro valor— es sospechoso. Cómo replicarlas: para reproducir el entorno de CI, corre el test sin las variables que CI no tiene (env -u NOMBRE pytest) o con los valores que CI usa (NOMBRE=valor pytest).

La zona horaria del sistema

La variable TZ (y la configuración de zona horaria del sistema operativo) determina qué es "la hora local" para código que no especifica una zona explícita. Tu laptop está en tu ciudad; el runner casi siempre está en UTC. Un datetime que se convierte a "local" sin decir a cuál cae en horas distintas. Cómo cazarla: echo $TZ, printenv TZ, o date (muestra la hora y la zona del sistema). Cómo replicarla: fuerza la misma con TZ=UTC pytest para imitar al runner. (Ojo: esto es distinto de la data de tz, que viaja en pytz y se controla pinneando la dependencia —lección 4—. Aquí hablamos de qué zona usa el sistema, no de qué sabe la librería sobre esa zona.)

El orden de los tests

pytest recoge y corre los tests en un orden; plugins como pytest-randomly lo barajan con una semilla. Si dos tests comparten estado por accidente, el resultado depende de quién corrió antes. CI y tu máquina pueden ordenar distinto (por el sistema de archivos, por la semilla). Cómo cazarlo: si el test culpable pasa cuando lo corres solo (pytest test_x.py::test_culpable) pero falla en la suite completa, hay acoplamiento por orden. La semilla de pytest-randomly aparece en la cabecera del log (Using --randomly-seed=...). Cómo replicarlo: corre con la misma semilla (pytest -p randomly --randomly-seed=<la de CI>) para reproducir el mismo orden. El diagnóstico de por qué los tests están acoplados —y cómo desacoplarlos— es de la guía hermana test-failure-diagnosis-guide; aquí basta con replicar el orden para reproducir.

Un archivo que solo existe en tu disco

Tu test lee un archivo —un .env, un CSV de datos, un fixture— que está en tu máquina pero no en el repositorio (por .gitignore o por olvido de commitear). En tu disco existe; en el runner, que solo tiene git, no. Cómo cazarlo: el fallo en CI suele ser FileNotFoundError; el archivo que menciona existe en tu disco pero git ls-files no lo lista. Cómo replicarlo: para reproducir la ausencia, renombra temporalmente el archivo en tu disco y corre; o mejor, clona el repo limpio en otra carpeta (que solo tendrá lo commiteado) y corre ahí. Emparentado: rutas absolutas codificadas (/Users/tunombre/...) que solo existen en tu máquina.

El directorio de trabajo y el locale

Dos más, transversales. El directorio de trabajo: desde dónde corres pytest cambia cómo resuelven los imports y las rutas relativas (correr desde la raíz vs desde tests/). Se caza con pwd y con el rootdir que pytest imprime; se replica corriendo desde el mismo lugar que CI (la raíz). El locale (LANG, LC_ALL): afecta cómo se ordenan textos, cómo se formatean números y fechas, qué codificación se asume. Tu máquina puede estar en es_MX.UTF-8 y el runner en C o C.UTF-8. Se caza con env | grep -E 'LANG|LC_' y se replica exportando el mismo valor.

Ejemplo trabajado 1: una variable de entorno de la app

Retomemos el total_with_tax_cents de Reservo, que lee la tasa de impuesto de una variable de entorno:

# reservo/config.py
import os


def tax_percent():
    return int(os.environ.get("RESERVO_TAX_PERCENT", "0"))


def total_with_tax_cents(price_cents):
    return price_cents + price_cents * tax_percent() // 100
# test_config.py
from reservo.config import total_with_tax_cents


def test_pro_3h_total_with_tax():
    assert total_with_tax_cents(6000) == 6960   # espera 16% de impuesto

El dev tiene RESERVO_TAX_PERCENT=16 exportada en su shell; CI no la tiene. Para reproducir el fallo de CI en tu máquina, la clave no es un venv limpio (que no borra las variables del shell), sino correr el test sin la variable, imitando el entorno mínimo del runner. Y para confirmar que esa variable es la causa, corres las dos versiones y comparas.

Qué esperar. Con la variable puesta (replica la máquina del dev):

$ RESERVO_TAX_PERCENT=16 python -m pytest test_config.py -q
.                                                                        [100%]
1 passed in 0.00s

Sin la variable (replica 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 __________________________

>       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

El env -u RESERVO_TAX_PERCENT corre el comando quitando esa variable —es tu forma de imitar el shell limpio de CI sin tener que cerrar sesión ni tocar tu .zshrc—. Reprodujiste el rojo: assert 6000 == 6960, el mismo que veía CI. Y de paso confirmaste la causa: con la variable, verde; sin ella, rojo; la variable es la diferencia. Nota que aquí el venv limpio no habría bastado —el fallo no era de una dependencia—; la herramienta correcta fue controlar la variable.

Ejemplo trabajado 2: la zona horaria del sistema

Reservo también tiene una funcioncita que muestra la hora de una reserva según el reloj del sistema de la máquina —sin especificar una zona explícita, confiando en la TZ del entorno—:

# reservo/localclock.py
def utc_to_system_local_hour(dt_utc):
    """Hora de pared en el reloj del sistema (depende de TZ del entorno)."""
    return dt_utc.astimezone().hour   # astimezone() sin argumento usa la TZ del sistema
# test_localclock.py
from datetime import datetime, timezone
from reservo.localclock import utc_to_system_local_hour

BOOKING_UTC = datetime(2026, 3, 10, 21, 0, tzinfo=timezone.utc)   # 21:00 UTC


def test_booking_shows_at_15_on_the_wall_clock():
    # La maquina del dev esta en America/Mexico_City (UTC-6): 21:00 UTC = 15:00.
    assert utc_to_system_local_hour(BOOKING_UTC) == 15

El dev escribió == 15 porque su laptop está en Ciudad de México (UTC−6). El runner de CI está en UTC, donde 21:00 UTC son las 21:00 "locales". Misma función, misma entrada, distinta TZ del sistema.

Qué esperar. Con la TZ del dev:

$ TZ=America/Mexico_City python -m pytest test_localclock.py -q
.                                                                        [100%]
1 passed in 0.00s

Con la TZ del runner:

$ TZ=UTC python -m pytest test_localclock.py -q
F                                                                        [100%]
=================================== FAILURES ===================================
________________ test_booking_shows_at_15_on_the_wall_clock ________________

>       assert utc_to_system_local_hour(BOOKING_UTC) == 15
E       AssertionError: assert 21 == 15
E        +  where 21 = utc_to_system_local_hour(datetime.datetime(2026, 3, 10, 21, 0, tzinfo=datetime.timezone.utc))

test_localclock.py:10: AssertionError
=========================== short test summary info ============================
FAILED test_localclock.py::test_booking_shows_at_15_on_the_wall_clock - assert 21 == 15
1 failed in 0.02s

TZ=UTC reproduce el rojo del runner: assert 21 == 15. Reprodujiste el fallo forzando la misma zona horaria de sistema que CI, sin mover nada más. Fíjate en la diferencia con el fallo de pytz de las lecciones anteriores: aquel era la data de zonas horarias (qué sabe la librería sobre el horario de verano), y se controlaba pinneando la dependencia; este es la zona del sistema (en qué ciudad "cree" estar la máquina), y se controla con la variable TZ. Dos capas distintas, dos herramientas distintas, el mismo síntoma superficial —una hora corrida—. Por eso el catálogo importa: sin él, confundirías una con otra y buscarías en el lugar equivocado.

Cómo cazar una diferencia oculta: comparar, no leer

El método general para estas capas invisibles no es leer el código —ya vimos que la causa no está ahí— sino poner los dos entornos lado a lado y buscar qué difiere. Un procedimiento concreto:

  1. Extrae el entorno de CI del log. El workflow declara su versión de Python, sus variables (bloque env:), y a veces imprime cosas útiles. La cabecera de pytest da la plataforma, la versión de Python y de pytest, y la semilla de orden si hay aleatorización.
  2. Fotografía tu entorno. python --version, pip freeze, env, echo $TZ, pwd, git status. Es la lista de todo lo que tu cocina tiene.
  3. Compara capa por capa. ¿Coincide la versión de Python? ¿Coinciden las versiones de pip freeze? ¿Qué variables tienes tú que el workflow no define? ¿Tu TZ es la misma que la del runner (UTC)? ¿Corres desde el mismo directorio? ¿Hay archivos que tú tienes y git ls-files no?
  4. Replica la diferencia sospechosa y corre. Cambia una cosa (quita una variable con env -u, fuerza TZ=UTC, corre desde la raíz) y observa si el resultado cambia. Cambiar de a una te dice cuál capa era la culpable.

Este "comparar y cambiar de a uno" es la versión de reproducción de un método científico: una hipótesis por vez, un cambio por vez, para atribuir el efecto a la causa correcta. Es lento comparado con "leer el código y adivinar", pero es certero, y con un puñado de comparaciones cierras la brecha que un mes de releer el repo no habría cerrado.

Errores comunes

Buscar en el código una causa que vive en el shell. Qué pasa: el fallo es de una variable o de la TZ, pero tú relees config.py y localclock.py una y otra vez buscando el bug. Por qué pasa: es natural buscar dentro del proyecto, donde tienes control. Cómo detectarlo: si el código es idéntico en ambas máquinas y aun así difieren, la causa no está en el código. Cómo corregirlo: deja de leer el repo y compara los entornos (env, printenv, echo $TZ, pip freeze). La diferencia que buscas no está en la receta; está en el ambiente.

Confiar en que el venv limpio atrapa todo. Qué pasa: montas un venv perfecto con las versiones de CI, el test sigue pasando (no reproduces), y te desconciertas. Por qué pasa: crees que el venv aísla todo el entorno. Cómo detectarlo: si el fallo depende de una variable o de la TZ, el venv —que no borra las variables de tu shell— no lo va a atrapar. Cómo corregirlo: recuerda qué capas cubre el venv (dependencias, intérprete) y cuáles no (variables, tz del sistema, archivos). Para esas, controla la variable directamente: env -u, TZ=..., correr desde otra carpeta.

Cambiar varias cosas a la vez al reproducir. Qué pasa: para "asegurarte", montas un venv nuevo y quitas tres variables y cambias la TZ, todo junto; reproduces el rojo pero no sabes cuál de los cambios lo causó. Por qué pasa: la prisa por reproducir empuja a cambiar todo de golpe. Cómo detectarlo: si reprodujiste pero no puedes decir qué capa era la culpable, cambiaste demasiado a la vez. Cómo corregirlo: cambia de a una cosa. Fija la hipótesis (por ejemplo, "es la TZ"), cambia solo eso, corre; si reproduce, esa era; si no, revierte y prueba la siguiente. Un cambio por vez es lo que convierte reproducir en entender qué difería.

Ejercicios

Ejercicio 1 — Elige la herramienta. Para cada fallo de CI (verde en tu máquina), di si un venv limpio bastaría para reproducirlo, y si no, con qué comando lo reproducirías. (a) CI instaló pytz 2026.3.post1 y tú tienes 2022.1. (b) Tu código lee RESERVO_TAX_PERCENT, que tú tienes exportada y CI no. (c) Una función usa datetime.astimezone() sin zona, tu máquina está en CDMX y el runner en UTC. (d) Un test lee sample.csv, que existe en tu disco pero no en el repo.

Ver solución
  • (a) El venv limpio basta. Es la capa de dependencias: crea un venv con pytz==2026.3.post1 y reproduce. python3.14 -m venv v && v/bin/pip install pytz==2026.3.post1 pytest==9.1.1 && v/bin/pytest.
  • (b) El venv no basta (no borra variables del shell). Reproduce quitando la variable: env -u RESERVO_TAX_PERCENT python -m pytest.
  • (c) El venv no basta (no cambia la TZ del sistema). Reproduce forzando la zona del runner: TZ=UTC python -m pytest.
  • (d) El venv no basta (no borra archivos de tu disco). Reproduce la ausencia clonando el repo limpio en otra carpeta y corriendo ahí, o renombrando temporalmente el archivo. El fallo esperado es FileNotFoundError.

La lección: el venv cierra la capa de dependencias (a); las capas invisibles (b, c, d) piden controlar la variable, la TZ o los archivos directamente.

Ejercicio 2 — Caza por comparación. Un test pasa en tu máquina y falla en CI con assert 21 == 15 en un cálculo de hora. Tienes el mismo Python, el mismo pip freeze que el log de CI, y el mismo directorio. ¿Qué compararías a continuación, con qué comando, y qué esperarías encontrar?

Ver solución

Con las dependencias, el intérprete y el directorio ya descartados (coinciden), el sospechoso siguiente para un valor de hora "corrido" es la zona horaria del sistema. Compararía la TZ:

$ echo $TZ            # o: printenv TZ
$ date                # muestra la hora y la zona del sistema

Esperaría encontrar que tu máquina tiene TZ=America/Mexico_City (o vacía, tomando la de tu sistema, UTC−6) mientras el runner corre en UTC. La confirmación: correr el test forzando la zona del runner:

$ TZ=UTC python -m pytest test_localclock.py -q     # deberia reproducir el rojo

Si con TZ=UTC el test falla igual que en CI (assert 21 == 15), la TZ del sistema era la diferencia oculta. El valor "corrido justo las horas del offset" (21 vs 15 = 6 horas, el offset de CDMX) es la huella que apunta a la zona horaria.

Ejercicio 3 — Hazlo explícito. El test test_booking_shows_at_15_on_the_wall_clock depende de la TZ del sistema, por eso pasa local y falla en CI. Más allá de reproducirlo, ¿qué cambiarías para que el test no dependa de una capa invisible y dé el mismo resultado en cualquier máquina? (Pista: hacer explícito lo implícito.)

Ver solución

El problema de fondo es que el test —y la función— dan por sentada una condición del ambiente (la zona horaria del sistema) sin declararla. La cura es hacerla explícita. Dos caminos:

  1. Hacer explícita la zona en el código. En vez de dt_utc.astimezone() (que usa la TZ del sistema, invisible y variable), pasar la zona a propósito: dt_utc.astimezone(ZoneInfo("America/Mexico_City")). Así el resultado no depende de en qué máquina corre; la zona es un dato del código, no del ambiente. El test pasaría igual en tu Mac y en el runner UTC.
  2. Fijar la zona en el test. Si la función debe usar la zona del sistema por diseño, el test debería fijar esa zona explícitamente (por ejemplo, forzando TZ dentro del test con monkeypatch.setenv("TZ", "America/Mexico_City") y recargando la zona), en vez de heredar la del shell.

En ambos casos, el principio es el mismo: hacer explícito lo implícito. Una condición del entorno de la que el resultado depende no debe quedar "en el aire"; debe estar declarada, sea en el código o en el test. Así la brecha de entorno se cierra de raíz para ese caso, y no reaparece cada vez que alguien corre los tests en una máquina con otra zona. (El diseño fino de tests deterministas frente al reloj y la zona lo profundizan las guías hermanas de fundamentos y de dobles de prueba.)

Resumen y siguiente paso

En esta lección cazaste las diferencias ocultas: las capas del entorno que el venv limpio no cubre porque no viven en requirements.txt sino "en el aire" de cada máquina. Recorriste el catálogo de lo invisible —variables de entorno, zona horaria del sistema (TZ), orden de los tests, un archivo que solo existe en tu disco, directorio de trabajo, locale— con su mecanismo, cómo cazarlas (env, printenv, echo $TZ, git ls-files) y cómo replicarlas (env -u, TZ=..., correr desde la raíz). Viste dos en acción con salida real: una variable de la app (RESERVO_TAX_PERCENT, que mueve el total de 6960 a 6000) y la zona horaria del sistema (TZ, que mueve la hora de 15 a 21), cada una reproducible controlando la capa correcta.

Y aprendiste el método para estas capas: comparar, no leer. La causa no está en el repo (que es idéntico en ambas máquinas), así que se caza poniendo los dos entornos lado a lado y cambiando de a una cosa hasta que el resultado se mueve. La cura de fondo, más allá de reproducir, es hacer explícito lo implícito: declarar en el código o el test toda condición del entorno de la que el resultado dependa.

Antes de avanzar deberías poder: nombrar las capas invisibles y cómo se cazan; decidir cuándo un venv basta y cuándo hay que controlar una variable o la TZ; reproducir un fallo de variable con env -u y uno de zona con TZ=; y cambiar de a una cosa para atribuir la causa correcta.

Lo que sigue es juntar todo el arsenal —el log de CI, la versión de Python, las dependencias pinneadas, el venv limpio, las variables y la zona— en un método repetible, paso a paso, para reproducir cualquier fallo de CI. La lección 7 arma ese procedimiento completo y lo aplica de principio a fin al fallo de pytz de Reservo, para que tengas una receta que seguir cada vez que el guardián y tú no estén de acuerdo.

Recursos