Módulo 1: De tu máquina al pipeline: por qué CI

2. "En mi máquina funciona"

Descripción

Al terminar esta lección vas a entender la frase más famosa —y más peligrosa— del desarrollo de software: "en mi máquina funciona". La dice alguien cuyo código se rompió para otra persona o en producción, y cuya única evidencia de que "funciona" es que en su máquina la suite estaba verde. Suena a defensa, pero es lo contrario: es la confesión de que la prueba se hizo en el único lugar donde no vale del todo —tu propia máquina, con todo lo que tú instalaste, configuraste y olvidaste—. La frase no cierra el caso; lo abre. Dice: "probé en un entorno que solo yo tengo, y no sé qué de ese entorno hizo pasar el test".

Vas a verlo no en teoría sino medido: el mismo test, con el mismo código, dando dos veredictos opuestos —verde para ti, rojo en un entorno limpio— sin que cambie una sola línea del test ni de la función que prueba. Lo único que va a cambiar entre el verde y el rojo es el entorno. Y cuando veas esa salida real, vas a entender de golpe por qué "funciona en mi máquina" no es una excusa que hay que perdonar, sino un síntoma que hay que diagnosticar: en algún lado tu entorno le está dando a la suite algo que otro entorno no tiene. También vas a llevarte un mapa de los lugares por donde el entorno se cuela en tus tests, para reconocerlos antes de que te muerdan.

Conexión con el módulo: esta lección es el problema en carne viva. La lección 1 lo nombró —"tus tests solo protegen el lugar donde corren"—; aquí lo ves pasar, con salida real. La lección 3 va a presentar la solución: un entorno limpio y compartido que corre la suite por todos, o sea, el CI. La 4 explicará por qué conviene que ese entorno atrape el fallo cuanto antes. Y el módulo 3 entero, más adelante, se dedica a reproducir en tu máquina un fallo que solo aparece en el entorno limpio —el arte de cerrar esta brecha—. O sea: aquí abrimos la herida que el resto de la guía cierra.

La receta que solo sale en tu cocina

Piénsalo así. Tu abuela te pasa la receta de su pan, escrita con todo detalle: los gramos, los tiempos, la temperatura del horno. La sigues al pie de la letra en su cocina, con ella al lado, y el pan sale perfecto. Te llevas la receta a tu casa, la sigues igual de exacta… y el pan sale plano y crudo por dentro. ¿Mentía la receta? No. Es que la receta daba por supuestas cien cosas de su cocina que nunca escribió: que su horno de gas corre veinte grados más caliente que el marcador, que su harina es de otra molienda, que en su pueblo el agua es más dura, que su cocina está a mil metros de altura y la tuya al nivel del mar. La receta funcionaba —en su cocina—. Fuera de ahí, dependía de cosas que ni ella sabía que importaban.

"En mi máquina funciona" es exactamente ese pan. El test es la receta; tu máquina es la cocina de la abuela. El test pasa porque tu máquina, sin que tú lo hayas escrito en ninguna parte, le está dando algo —una variable de entorno, una versión, un paquete, una zona horaria— que el test necesita y que da por supuesto. En la cocina de otro, ese algo no está, y el pan sale crudo. La receta no mentía; estaba incompleta, apoyada en supuestos invisibles del lugar donde se escribió.

Un buen panadero profesional resuelve esto de una manera muy concreta: escribe la receta completa, sin supuestos —"horno a 220 °C reales, medidos con termómetro; harina de fuerza W300; hidratación al 70%"— y la prueba en una cocina distinta de la suya para descubrir qué había dado por supuesto. Ese acto —llevar la receta a una cocina neutra y ver si sale— es exactamente lo que hace el CI con tu código. Toma tu suite, la lleva a una máquina que no es la tuya, que arranca vacía, y ve si el pan sale. Si sale, la receta estaba completa. Si no, acabas de descubrir un supuesto invisible —antes de que lo descubra un cliente con un pan crudo—.

"En mi máquina funciona" no es una defensa: es un diagnóstico a medias. Significa "algo de mi entorno hace pasar el test, y no sé qué". El trabajo es encontrar ese algo y volverlo explícito —o dejar que un entorno limpio lo encuentre por ti.

Ejemplo trabajado: el mismo test, dos veredictos

Vamos a construir la escena más limpia posible del fenómeno, y a correrla de verdad. Necesitamos un test que dependa de algo de tu entorno que otra máquina no tenga garantizado. El culpable perfecto, porque es común y traicionero: una variable de entorno.

Imagina que alguien, en vez de dejar el descuento pro fijo en el código de Reservo como hace price_cents, decide leerlo de una variable de entorno —"así lo puedo cambiar sin tocar el código", se dijo—. Escribe esta versión frágil:

# env_pricing.py — una version FRAGIL de price_cents que lee el descuento
# del entorno. Anti-patron: el resultado depende de una variable de shell.
import os

from reservo.models import Room, Member


def price_with_env(room, member, hours):
    # Lee el porcentaje de descuento pro de una variable de entorno.
    # Si no esta definida, cae a 0 (sin descuento).
    pro_discount = int(os.environ.get("RESERVO_PRO_DISCOUNT", "0"))
    subtotal = room.hourly_cents * hours
    if member.tier == "pro":
        subtotal -= subtotal * pro_discount // 100
    return subtotal

Y le escribe un test perfectamente razonable, que afirma el número-ancla de siempre: un pro paga 3 horas de Focus a 6000 centavos.

# test_env_pricing.py
from env_pricing import price_with_env
from reservo.models import Room, Member

focus = Room(id="r-focus", name="Focus", capacity=1, hourly_cents=2500)
bruno = Member(id="m-2", name="Bruno", tier="pro")


def test_pro_3h_is_6000():
    # Ancla de la guia: un pro paga 3h de Focus a 6000 centavos.
    assert price_with_env(focus, bruno, 3) == 6000

Nuestro desarrollador, hace meses, exportó en su shell export RESERVO_PRO_DISCOUNT=20 —para probar algo— y se olvidó. Esa línea sigue viva en su sesión. Así que cuando él corre el test, su entorno le da el 20 que la función necesita, y el pan sale perfecto.

Qué esperar (caso A: su máquina, con la variable exportada). Corriendo RESERVO_PRO_DISCOUNT=20 python3 -m pytest test_env_pricing.py -v:

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

test_env_pricing.py::test_pro_3h_is_6000 PASSED                          [100%]

============================== 1 passed in 0.01s ===============================

Verde. 1 passed. Nuestro desarrollador ve esto, respira tranquilo, y sube el código. Para él, "funciona". Y aquí está la trampa: tiene razón, funciona —en su máquina—. No está mintiendo ni siendo descuidado a propósito. Su evidencia es real. El problema es que su evidencia solo cubre su cocina.

Ahora ese código llega a otra máquina. Puede ser la de un compañero que nunca exportó esa variable. Puede ser el runner del CI, que —como toda máquina de CI— arranca limpio, sin las variables que tú tienes en tu shell. En ese entorno, RESERVO_PRO_DISCOUNT no existe, así que la función cae a su valor por defecto —0, sin descuento— y le cobra al pro la tarifa completa: 7500 en vez de 6000.

Qué esperar (caso B: un entorno limpio, sin la variable). Corriendo el mismo test, con el mismo código, pero en un shell donde la variable no está —env -u RESERVO_PRO_DISCOUNT python3 -m pytest test_env_pricing.py -v—:

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

test_env_pricing.py::test_pro_3h_is_6000 FAILED                          [100%]

=================================== FAILURES ===================================
_____________________________ test_pro_3h_is_6000 ______________________________

    def test_pro_3h_is_6000():
        # Ancla de la guia: un pro paga 3h de Focus a 6000 centavos.
>       assert price_with_env(focus, bruno, 3) == 6000
E       AssertionError: assert 7500 == 6000
E        +  where 7500 = price_with_env(Room(id='r-focus', name='Focus', capacity=1, hourly_cents=2500), Member(id='m-2', name='Bruno', tier='pro'), 3)

test_env_pricing.py:11: AssertionError
=========================== short test summary info ============================
FAILED test_env_pricing.py::test_pro_3h_is_6000 - AssertionError: assert 7500 == 6000
============================== 1 failed in 0.03s ===============================

Detente aquí, porque esta es la escena central de todo el módulo. No cambió una sola línea del test. No cambió una sola línea del código. Lo único que cambió fue el entorno —la presencia o ausencia de una variable de shell— y el veredicto se dio la vuelta: de 1 passed a 1 failed. El assert 7500 == 6000 te muestra exactamente el pan crudo: sin la variable, el descuento no se aplicó, y el pro pagó 7500.

Esta es, en su forma más pura, la anatomía de "en mi máquina funciona". El desarrollador del caso A no es un mentiroso ni un descuidado: su verde es real. Pero su verde dependía de algo de su entorno —esa variable olvidada— que él nunca declaró y que otro no tiene. El CI es, ni más ni menos, el caso B corriendo automáticamente: una máquina limpia que dice, sin diplomacia, "tu receta estaba incompleta; en una cocina neutra, el pan sale crudo". Mejor que te lo diga el CI hoy, en un check rojo de un PR, que un cliente mañana con un cobro de más.

Por dónde se cuela el entorno: el mapa de las fugas

La variable de entorno es solo una de las puertas por donde tu máquina le pasa a un test algo que otra máquina no tiene. Conviene conocer las principales, porque el módulo 3 se dedica a cerrarlas y aquí las nombramos para que las reconozcas. A cada una la llamamos una fuga de entorno: un supuesto invisible del que tu test depende sin declararlo.

  • Variables de entorno. La que acabas de ver. Tu shell tiene RESERVO_PRO_DISCOUNT, DATABASE_URL, API_KEY, TZ… y el test se apoya en alguna sin decirlo. En otra máquina, o en un runner limpio, no están.
  • La versión de Python. Tu código corre en 3.14; el compañero tiene 3.11; el runner, 3.12. Una función que existe en 3.14 y no en 3.11, o un comportamiento que cambió entre versiones, hace verde aquí y rojo allá. La cabecera platform ... -- Python 3.14.0 de cada salida de pytest es justo el dato que delata esta fuga.
  • Los paquetes instalados y sus versiones. Tienes pytest 9.1.1 y requests 2.31; otro tiene requests 2.20, donde una función se comportaba distinto. O tienes instalado un paquete que el proyecto usa pero nunca declaró en sus dependencias: en tu máquina está "por casualidad", en la limpia no.
  • El sistema operativo y sus rutas. Tú estás en macOS (platform darwin), el runner en Linux. Las rutas de archivo (/Users/... vs /home/...), el separador (/ vs \ en Windows), el fin de línea, mayúsculas/minúsculas en nombres de archivo: todo eso difiere, y un test que hardcodea una ruta se rompe al cruzar de sistema.
  • La zona horaria y el idioma (locale). Tu máquina está en horario de Ciudad de México; el runner, en UTC. Un test que formatea una fecha, o que compara horas sin cuidado, da un resultado distinto según la zona. El idioma del sistema cambia cómo se ordenan textos o cómo se escribe un número decimal.
  • El reloj de pared. Un test que lee "ahora" con datetime.now() por dentro depende de cuándo lo corres. Pasa hoy y falla mañana, o pasa a las 23:00 y falla a las 00:00. Es la fuga más escurridiza porque el entorno que cambia es el tiempo. (La guía de fundamentos la ataca controlando el reloj con un Clock; aquí basta con saber que existe.)
  • El orden y el estado compartido. Si un test deja "basura" —un archivo, una variable global, una entrada en una lista— y otro test depende de esa basura, la suite pasa cuando corren en cierto orden y falla en otro. Tu máquina y el runner pueden descubrir los tests en orden distinto, y ahí salta la fuga.

Fíjate en el patrón común: en todos los casos, el test depende de algo que no está escrito en el test. Está en tu shell, en tu instalación, en tu sistema, en tu reloj. Un test verdaderamente robusto declara —o controla— todo lo que necesita, de modo que su veredicto no dependa de dónde corre. El CI es la máquina que te obliga a esa disciplina, porque arranca sin ninguno de tus supuestos y te muestra, uno por uno, cuáles habías dado por sentados.

Errores comunes

Tratar "en mi máquina funciona" como el final de la conversación. Qué pasa: alguien reporta que el código falla, el autor lo corre en su máquina, ve verde, responde "pues en la mía funciona" y cierra el ticket. El bug sigue ahí para todos los demás. Por qué pasa: el verde local se siente como una prueba concluyente, y es más cómodo cerrar el caso que investigar una diferencia de entorno. Cómo detectarlo: si tu única evidencia de que algo funciona es "lo corrí en mi máquina", no tienes una conclusión, tienes un punto de partida. Cómo corregirlo: trata la frase como el inicio de un diagnóstico. La pregunta correcta no es "¿funciona en mi máquina?" sino "¿qué tiene mi máquina que la otra no?". Y la forma sistemática de contestarla es correr en un entorno limpio —CI—, que es adonde va toda la guía.

Culpar al mensajero cuando el CI se pone rojo y tú estás verde. Qué pasa: el CI reporta un fallo, el desarrollador corre la suite en local, la ve verde, y concluye "el CI está mal configurado / el CI está roto". A veces lo está, pero la mayoría de las veces el CI tiene razón y tu máquina te está engañando con un supuesto que solo tú tienes. Por qué pasa: es más fácil sospechar de la máquina ajena que de la propia. Cómo detectarlo: si tu reacción automática a un CI rojo es "pero en mi máquina pasa", estás a punto de cometer este error. Cómo corregirlo: invierte la sospecha. El CI arranca limpio; tu máquina arrastra meses de configuración. Cuando difieren, el candidato más probable a estar "sucio" eres tú. Reproducir ese rojo en tu máquina es exactamente el tema del módulo 3.

Confundir "el test es frágil" con "el código es correcto". Qué pasa: alguien ve que el test del ejemplo falla en CI y concluye que hay que "arreglar el test" para que pase —por ejemplo, exportando la variable también en el CI—. A veces eso es legítimo, pero muchas veces el test frágil está señalando un problema real del código: que price_with_env dependa de una variable de shell es una mala idea, la variable puede faltar en producción y cobrarle de más a un cliente. Por qué pasa: cuando un test molesta, la tentación es callarlo, no escucharlo. Cómo detectarlo: pregúntate "si esta variable falta en producción, ¿qué pasa?". Si la respuesta es "un cobro incorrecto", el test no es frágil de más: está avisando de un bug de verdad. Cómo corregirlo: distingue las dos causas. Si el test depende del entorno por descuido (una ruta hardcodeada), arregla el test. Si depende del entorno porque el código depende del entorno de forma peligrosa (un precio que se decide en el shell), arregla el código —o al menos declara y controla ese entorno explícitamente—.

Ejercicios

Ejercicio 1 — Clasifica la fuga. Para cada test que falla al cambiar de máquina, di cuál de las fugas de entorno del mapa es la culpable (variable, versión de Python, paquete, SO/ruta, zona horaria/locale, reloj, orden/estado). (a) Pasa en tu Mac y falla en el runner de Linux porque abre C:\Users\...\data.csv… no, porque abre /Users/ana/data.csv, que no existe allá. (b) Pasa hoy y falla el 1 de enero porque compara contra el año en curso. (c) Pasa cuando corres toda la suite y falla cuando corres solo ese test. (d) Pasa con tu pandas 2.2 y falla con el pandas 1.5 del compañero.

Ver solución
  • (a) SO / rutas. La ruta /Users/ana/data.csv es específica de tu máquina (tu usuario, tu sistema de archivos). En el runner de Linux ese archivo no existe en esa ruta. La corrección: nunca hardcodear rutas absolutas; construirlas relativas al proyecto o pasarlas por configuración.
  • (b) Reloj de pared. El test lee el año en curso (algo como datetime.now().year), así que su resultado depende de cuándo se corre. Hoy da una cosa, el 1 de enero otra. La corrección: controlar el "ahora" en vez de leerlo del reloj real (el Clock de fundamentos).
  • (c) Orden / estado compartido. Que pase con la suite completa y falle en aislamiento (o al revés) es la firma de un test que depende del estado que otro dejó, o del orden de descubrimiento. La corrección: que cada test prepare y limpie lo suyo, sin apoyarse en lo que hizo otro.
  • (d) Paquete y su versión. El comportamiento cambió entre pandas 1.5 y 2.2. Tu versión hace verde; la del compañero, rojo. La corrección: pinnear la versión de las dependencias para que todos —y el CI— usen la misma (tema del módulo 3).

La lección: "en mi máquina funciona" no es una sola falla, es una familia. Saber cuál fuga es la culpable es el primer paso para cerrarla, y cada una se cierra distinto.

Ejercicio 2 — Predice el veredicto. Vuelve al ejemplo trabajado. Nuestro desarrollador, para "arreglar" el rojo del CI, decide en su máquina correr unset RESERVO_PRO_DISCOUNT y volver a correr el test en local. Sin correrlo tú, predice: (a) ¿qué veredicto va a ver ahora en su máquina, verde o rojo? (b) ¿Qué le enseña eso sobre el fallo del CI? (c) ¿Cuál sería un arreglo de verdad, y no un parche?

Ver solución
  • (a) Rojo. Al hacer unset de la variable, su shell queda como el entorno limpio del CI: sin RESERVO_PRO_DISCOUNT. La función cae al descuento por defecto (0), le cobra 7500 al pro, y el assert 7500 == 6000 falla. Verá el mismo 1 failed que el CI.
  • (b) Le enseña que el CI tenía razón. Acaba de reproducir el fallo del CI en su propia máquina, quitando el supuesto invisible que lo tapaba. El CI no estaba roto: su máquina estaba "sucia" con una variable que él había olvidado. Reproducir así el fallo es justo la técnica del módulo 3.
  • (c) El arreglo de verdad es quitar la dependencia del entorno, no acomodar el entorno al test. El descuento pro no debería vivir en una variable de shell que puede faltar en producción: debería ser una constante del código (como el PRO_DISCOUNT_PERCENT = 20 del price_cents real de Reservo), o un valor de configuración explícito y validado al arrancar. Exportar la variable "también en el CI" sería un parche: dejaría el bug latente para el día en que falte en producción.

La lección: unset en tu máquina es la forma más barata de simular el entorno limpio del CI y reproducir su rojo. Y el arreglo correcto casi nunca es "darle al test el entorno que le falta", sino "quitarle al test (o al código) la dependencia del entorno".

Ejercicio 3 — Escribe la confesión honesta. "En mi máquina funciona" es una frase que oculta un supuesto. Reescríbela como una confesión honesta y completa para el caso del ejemplo trabajado: una frase que diga exactamente de qué depende el verde. Luego explica por qué esa versión honesta, dicha en voz alta, prácticamente se arregla sola.

Ver solución

Una confesión honesta sería algo como:

"El test pasa en mi máquina porque tengo exportada la variable de entorno RESERVO_PRO_DISCOUNT=20 en mi shell, que la función price_with_env necesita para aplicar el descuento. No he verificado que esa variable esté presente en la máquina de nadie más, ni en el CI, ni en producción."

Por qué se arregla casi sola: en el momento en que dices la dependencia en voz alta, la pregunta siguiente es obvia y la haces tú mismo —"¿y esa variable está en producción?"—. La versión corta, "en mi máquina funciona", esconde justo esa pregunta, y por eso es peligrosa: no es que mienta, es que calla el supuesto. La versión honesta lo pone sobre la mesa, y una vez sobre la mesa, nadie en su sano juicio deja un precio dependiendo de una variable de shell que puede faltar. El CI hace esto por ti sin pedirte honestidad: como arranca sin tus supuestos, los saca a la luz uno por uno, con un check rojo que no se puede callar.

Resumen y siguiente paso

En esta lección viste, medido y no contado, el fenómeno que le da sentido a toda la guía: "en mi máquina funciona". El mismo test, con el mismo código, dio 1 passed en un entorno con una variable de shell y 1 failed en un entorno limpio sin ella —assert 7500 == 6000—. Lo único que cambió fue el entorno. Por eso la frase no es una defensa sino un diagnóstico a medias: significa "algo de mi entorno hace pasar el test, y no sé qué". Es la receta de la abuela que solo sale en su cocina, apoyada en supuestos que nunca escribió.

Te llevas también el mapa de las fugas por donde el entorno se cuela: variables, versión de Python, paquetes, sistema operativo y rutas, zona horaria y locale, reloj de pared, y orden/estado compartido. Todas comparten la misma raíz —el test depende de algo que no está escrito en el test— y todas se cierran llevando la prueba a un entorno limpio y declarado.

Antes de avanzar deberías poder: explicar por qué "en mi máquina funciona" es un síntoma y no una excusa; nombrar al menos cuatro fugas de entorno; y decir, para el ejemplo trabajado, qué cambió entre el verde y el rojo (el entorno, no el código).

Lo que sigue es ponerle nombre y forma a la solución. En la lección 3 vas a ver qué es exactamente la Integración Continua: la máquina limpia y compartida que corre el "caso B" automáticamente, por todos, en cada cambio —para que el fallo lo encuentre un check rojo hoy y no un cliente mañana—.

Recursos