Módulo 7: Datos y entornos: configurar el framework por entorno

1. Presentación del módulo: la capa de datos y entornos

Descripción

Durante seis módulos le construiste a la suite de Reservo casi todas sus capas. Las fixtures que arman el mundo (módulos 2 y 3), la configuración y los marcadores que la gobiernan (módulo 4), la biblioteca de utilidades que comparte lógica sin copiarla (módulo 5), los hooks que extienden el corredor (módulo 6). Este módulo cierra el framework con la capa que hace que sea usable fuera de tu máquina y a escala de datos: la capa de datos y entornos. Son dos problemas distintos que, curiosamente, llegan juntos cuando una suite crece de verdad —y los dos tienen la misma raíz: el framework asume cosas que dejan de ser ciertas cuando la suite y el equipo crecen—.

El primer problema es un viejo conocido que vuelve por otra puerta. El módulo 1 curó el setup copy-paste con fixtures: en vez de que cada test armara su BookingService a mano, una fixture lo daba una vez. Pero el mismo dolor regresa disfrazado de datos. Cada test que necesita un Booking lo construye a mano con sus siete campos, y aunque el BookingService ya venga de una fixture, el dato sigue copiado en cada archivo. El día que al modelo Booking le agregas un campo obligatorio, la suite entera se rompe en el mismo error repetido decenas de veces. El segundo problema es que el framework vive amarrado a tu máquina: alguien lo clona, corre pytest, y falla porque escribiste una ruta de tu disco en un test; o corre, pero se comporta igual en CI que en local cuando CI necesita comportarse distinto. Al terminar este módulo vas a tener las dos piezas que resuelven eso —la fábrica de datos como módulo compartido y la configuración por entorno—, y en esta lección las vas a ver de entrada, corriendo, para que el resto del módulo tenga un destino claro.

Conexión con el módulo: esta lección es el plano de las otras siete. La lección 2 diagnostica el problema de los datos a escala —el radio de explosión de un cambio de modelo—. La 3 construye la fábrica como módulo importable. La 4 diseña sus defaults sensatos. La 5 escribe la config por entorno con --env y variables. La 6 la expone como la fixture settings parametrizada. La 7 asegura que todo sea portátil —nada de rutas absolutas—. Y la 8 junta la fábrica y la config en el framework de Reservo. La frontera se respeta desde aquí: el builder serio (Object Mother, builders encadenados) es la guía test-doubles-and-test-data-guide, y aquí la fábrica es la pieza del framework; la infra de CI (el YAML, los runners) es testing-in-cicd-guide, y aquí solo diseñamos cómo el framework se adapta a CI; y la opción --env nació en el módulo 6 —aquí se usa a fondo, que era su destino anunciado—.

Analogía: la cocina del restaurante que abre una segunda sede

Piensa en el framework de tests como la cocina de un restaurante que ha ido creciendo. Los primeros módulos fueron equipar esa cocina: las estaciones de trabajo (fixtures), el orden del almacén (la estructura de carpetas), las etiquetas de los ingredientes (marcadores), las recetas compartidas (utilidades). Con todo eso, una cocina funciona muy bien —mientras sea una cocina, con un cocinero, preparando pocos platos—.

Dos cosas rompen esa comodidad cuando el restaurante crece. La primera es el volumen de ingredientes. Al principio, cada cocinero pica su propia cebolla para cada plato; con pocos platos, se nota poco. Pero cuando la cocina sirve cientos de platos, picar la cebolla desde cero en cada uno es un desperdicio absurdo, y peor: el día que el proveedor cambia el tipo de cebolla, cada cocinero tiene que aprender a picarla de nuevo, plato por plato. La cocina que escala tiene una estación de preparación —un lugar donde la cebolla ya viene picada, la salsa base ya está hecha, los ingredientes comunes ya están listos con sus cantidades estándar—, y cada plato toma de ahí en vez de empezar de cero. Esa estación de preparación es la fábrica de datos: un lugar central que produce los ingredientes comunes (una reserva, un socio, una sala) listos para usar, con cantidades sensatas por defecto, para que cada test tome lo que necesita en vez de construirlo a mano.

La segunda cosa es que el restaurante abre una segunda sede. La receta es la misma, pero la sede nueva tiene otro horno, otra altitud, otra presión de agua. Una cocina que solo sabe funcionar en su edificio original no sirve: la buena receta dice "hornear hasta que esté dorado", no "hornear exactamente 12 minutos en mi horno". La receta portátil describe el resultado y se adapta al entorno; la receta frágil codifica los detalles de una sola cocina. Tu framework es igual: tiene que correr en tu laptop, en la de un compañero, en CI —y a veces comportarse distinto en cada una a propósito—, sin codificar los detalles de tu máquina. Esa es la configuración por entorno: la parte del framework que se adapta a dónde corre en vez de asumir dónde corre.

Las dos piezas, de un vistazo

Antes de bajar a cada una, vale la pena el mapa. Este módulo agrega exactamente dos componentes al framework, y todo lo demás son detalles de esos dos:

  • La fábrica de datos (tests/factories.py) — un módulo compartido con funciones que construyen objetos de Reservo: make_room(), make_member(), make_booking(). Cada una arma un objeto válido con defaults sensatos, y el test sobrescribe solo lo que le importa. Reemplaza la construcción a mano repetida en cada test. Lecciones 2, 3 y 4.
  • La configuración por entorno — un Settings (los valores que cambian según dónde corre la suite) que se construye desde la opción --env y variables de entorno, expuesto como la fixture settings. Reemplaza los valores amarrados a tu máquina. Lecciones 5, 6 y 7.

Las dos comparten un principio: el framework no debe asumir. No debe asumir que el modelo Booking nunca cambia (por eso la fábrica centraliza la construcción), y no debe asumir que siempre corre en tu disco (por eso la config se adapta al entorno). Un framework que asume es un framework que se rompe cuando el mundo cambia; y el mundo —el modelo, la máquina, el equipo— siempre cambia.

Ejemplo trabajado: las dos piezas, en una corrida

Bajemos el mapa a algo que corre. Tomemos la suite de Reservo con las dos piezas ya puestas —una fábrica en tests/factories.py y una config por entorno en un Settings— y veámoslas funcionar. No hace falta entender cada línea todavía; cada pieza tiene sus lecciones. El objetivo es ver la capa completa, funcionando, para que el resto del módulo tenga hacia dónde ir.

Primero, la fábrica fabricando datos. En vez de construir un Booking con sus siete campos a mano, pedimos varias reservas a la fábrica, cambiando solo lo que nos importa:

from tests.factories import make_booking, make_member, make_room

# tres reservas distintas, cada una diciendo solo lo que le importa
day = [make_booking(price_cents=7500),
       make_booking(price_cents=6000),
       make_booking(status="cancelled")]
for b in day:
    print(b.id, b.room_id, b.member_id, b.status, b.price_cents)
print("ids unicos:", len({b.id for b in day}))
print("pro:", make_member(id="m-bruno", name="Bruno", tier="pro"))
print("sala:", make_room())

Qué esperar. Corriendo eso con la fábrica cargada (Python 3.14.0):

bk-1 focus m-ana confirmed 7500
bk-2 focus m-ana confirmed 6000
bk-3 focus m-ana cancelled 7500
ids unicos: 3
pro: Member(id='m-bruno', name='Bruno', tier='pro')
sala: Room(id='focus', name='Focus', capacity=1, hourly_cents=2500)

Tres reservas distintas, cada una con su id único (bk-1, bk-2, bk-3), y cada llamada dijo solo lo que le importaba —la primera el precio basic, la segunda el precio pro, la tercera el status cancelado—. Todo lo demás (la sala Focus, la socia Ana, los horarios) salió de los defaults. Eso es lo que reemplaza a construir siete campos a mano en cada test. La fábrica es la estación de preparación: los ingredientes comunes ya listos, y cada test toma y ajusta.

Ahora la config por entorno. La suite tiene un hook que imprime, en el encabezado, la configuración del entorno donde corre —lo justo para que la veas cambiar—. Corre la suite normal (entorno local, el default):

Qué esperar. Con pytest (Python 3.14.0, pytest 9.1.1):

============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
reservo env: local  |  db: :memory:  |  timeout: 5s  |  strict: False
testpaths: tests
collected 7 items

tests/integration/test_book_flow.py .                                    [ 14%]
tests/integration/test_portability.py .                                  [ 28%]
tests/integration/test_settings.py .                                     [ 42%]
tests/unit/test_bookings.py ..                                           [ 71%]
tests/unit/test_pricing.py ..                                            [100%]

============================== 7 passed in 0.01s ===============================

Fíjate en la segunda línea del encabezado: reservo env: local | db: :memory: | timeout: 5s | strict: False. Esa es la config del entorno local. Ahora corre exactamente la misma suite, pero pidiendo el entorno ci con la opción --env —la misma que naciste en el módulo 6—:

Qué esperar. Con pytest --env=ci:

============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
reservo env: ci  |  db: :memory:  |  timeout: 30s  |  strict: True
testpaths: tests
collected 7 items

tests/integration/test_book_flow.py .                                    [ 14%]
tests/integration/test_portability.py .                                  [ 28%]
tests/integration/test_settings.py .                                     [ 42%]
tests/unit/test_bookings.py ..                                           [ 71%]
tests/unit/test_pricing.py ..                                            [100%]

============================== 7 passed in 0.01s ===============================

Compara los dos encabezados. Sin tocar un solo test, --env=ci cambió la config de local | timeout: 5s | strict: False a ci | timeout: 30s | strict: True. El mismo framework, la misma suite, dos comportamientos según el entorno: en local espera 5 segundos antes de rendirse con un test lento y no es estricto con los warnings; en CI espera 30 segundos (los runners de CI son más lentos) y trata cualquier warning como un error. Y un detalle que la lección 7 va a subrayar: en los dos entornos, db: :memory: —la base de datos es en memoria, sin una sola ruta de disco—. Eso es lo que hace el framework portátil: no importa en qué máquina corras, no hay una carpeta que tenga que existir.

Esas dos imágenes —la fábrica fabricando datos y --env cambiando la config— son el módulo entero en miniatura. Lo que sigue es construir cada pieza con cuidado.

Por qué esta capa viene al final

No es casualidad que datos y entornos sea el módulo 7 y no el 2. Las dos piezas de este módulo usan casi todo lo anterior. La fábrica produce objetos que las fixtures del módulo 2 consumen; la config por entorno se apoya en la opción --env que naciste con el hook pytest_addoption del módulo 6; la fixture settings es una fixture más, con el scope de sesión que decidiste entender en el módulo 2. Este módulo no introduce un mecanismo nuevo de pytest: compone los que ya conoces para resolver los dos problemas que solo aparecen cuando la suite crece de verdad —muchos datos, muchas máquinas—.

Y hay un orden dentro del módulo que conviene anunciar. Primero los datos (lecciones 2-4), porque el problema de los datos es más inmediato y la fábrica es más simple. Luego los entornos (lecciones 5-7), porque la config por entorno se apoya en piezas de módulos anteriores y cierra con la portabilidad, que es la prueba final de que el framework no asume nada de tu máquina. El mini-proyecto (lección 8) junta las dos mitades. Al final tendrás un framework que cualquier persona del equipo puede clonar, correr en su máquina o en CI, y extender sin copiar un solo dato a mano.

Errores comunes

Creer que las fixtures ya resolvieron el problema de los datos. Qué pasa: alguien llega a este módulo pensando "pero yo ya tengo una fixture booking que da una reserva; ¿para qué una fábrica?". Por qué pasa: la fixture y la fábrica se parecen —las dos dan objetos— y el módulo 2 sí resolvió el setup de los colaboradores (el BookingService, el Calendar). Pero una fixture da un objeto fijo; el problema de los datos a escala es que los tests necesitan muchos objetos distintos —tres reservas en un día, un socio pro y otro basic, una reserva cancelada—, y una fixture que da uno solo no cubre eso. Cómo detectarlo: si tus tests construyen Booking(...) a mano en el cuerpo porque "la fixture no me da justo el que necesito", el problema de los datos sigue vivo. Cómo corregirlo: la fábrica no reemplaza a las fixtures; resuelve un problema distinto —fabricar objetos a medida—, y este módulo muestra dónde termina la fixture y empieza la fábrica.

Meter la lógica de entorno en cada test con if. Qué pasa: alguien necesita que la suite se comporte distinto en CI y llena los tests de if os.environ.get("CI"): timeout = 30 else: timeout = 5. Por qué pasa: es la forma más directa de "hacer algo distinto en CI", y funciona para un test. Cómo detectarlo: si el mismo if os.environ... aparece en varios tests, la lógica de entorno se está copiando —el mismo copy-paste que el módulo entero combate, ahora con condicionales—. Cómo corregirlo: la decisión de entorno vive en un lugar (el Settings de la lección 5) y los tests reciben el valor ya resuelto por la fixture settings. Un test nunca pregunta "¿estoy en CI?"; recibe settings.timeout_seconds y usa el número, sin saber de dónde salió.

Confundir portabilidad con "correr en CI". Qué pasa: alguien piensa que hacer el framework portátil es configurarlo para CI. Por qué pasa: CI es el lugar más visible donde "corre en otra máquina", así que portabilidad y CI se mezclan. Pero son cosas distintas: la config por entorno es que el framework se comporte distinto donde debe (timeouts, estrictez); la portabilidad es que el framework pueda correr en cualquier máquina —sin rutas absolutas, sin puertos fijos—, algo que vale igual en tu laptop, en la de un compañero y en CI. Cómo detectarlo: un framework puede tener perfiles local/ci perfectos y aun así no ser portátil, si un test escribe en /Users/tu-nombre/tmp. Cómo corregirlo: trata las dos como capas separadas —la lección 5 hace la config por entorno, la lección 7 hace la portabilidad— y verifica cada una por su lado.

Ejercicios

Ejercicio 1 — ¿Fábrica o config por entorno? Para cada necesidad de la suite de Reservo, di si la resuelve la fábrica de datos o la config por entorno, y en una frase por qué. (a) "Este test necesita tres reservas distintas en el mismo día." (b) "En CI quiero que la suite espere más antes de matar un test lento." (c) "Cada test de reembolsos necesita una reserva pro confirmada." (d) "El framework no debe escribir en una ruta de mi disco que no existe en CI." (e) "En local quiero que los warnings sean solo warnings, pero en CI quiero que sean errores."

Ver solución
  • (a) Fábrica. "Tres reservas distintas" es fabricar varios objetos a medida —exactamente lo que una función make_booking(...) llamada tres veces resuelve, y una fixture de un solo objeto no—.
  • (b) Config por entorno. El timeout que cambia entre local y CI es un valor que depende de dónde corre la suite: vive en el Settings, con local en 5 s y ci en 30 s.
  • (c) Fábrica. "Una reserva pro confirmada" es construir un objeto con ciertos campos —make_booking(...) con los valores que el test necesita—, no algo que dependa del entorno.
  • (d) Config por entorno / portabilidad. No escribir en una ruta fija es la parte de portabilidad de la config (lección 7): usar tmp_path o :memory: en vez de una ruta de tu máquina, para que corra igual en CI.
  • (e) Config por entorno. "Warnings como errores solo en CI" es un comportamiento que depende del entorno: el campo strict_warnings del Settings, False en local y True en ci.

La regla que practica el ejercicio: si la pregunta es "¿qué objeto construyo?", es la fábrica (a, c); si la pregunta es "¿qué cambia según dónde corro?", es la config por entorno (b, d, e). Los dos ejes de este módulo, uno por cada problema.

Ejercicio 2 — Traza el radio de explosión. Sin escribir código, imagina que la suite de Reservo tiene 40 tests, y que cada uno construye su Booking a mano con los siete campos: Booking(id=..., room_id=..., member_id=..., start=..., end=..., status=..., price_cents=...). Al modelo Booking se le agrega un octavo campo obligatorio, member_tier. (a) ¿Cuántas construcciones hay que editar para que la suite vuelva a correr? (b) ¿Cuántas habría que editar si todos los tests usaran una fábrica make_booking()? (c) ¿Qué nombre le pondrías a la diferencia entre (a) y (b)?

Ver solución
  • (a) 40. Cada construcción a mano le pasa los campos uno por uno al constructor; si el constructor gana un campo obligatorio, cada una de las 40 llamadas queda incompleta y falla con TypeError: __init__() missing 1 required positional argument: 'member_tier'. Hay que editar las 40.
  • (b) 1. Si los 40 tests llaman a make_booking(), la construcción real vive en un solo lugar —la fábrica—. Agregas el campo con su default en la firma de make_booking (member_tier="basic"), y los 40 tests siguen corriendo sin tocarlos, porque ninguno construye el Booking directamente: todos toman de la fábrica.
  • (c) El radio de explosión (o el costo de un cambio de modelo). La diferencia entre 40 y 1 es exactamente lo que la fábrica compra: centralizar la construcción para que un cambio en el modelo se pague una vez y no una vez por test. Es el mismo argumento del módulo 1 —el setup en un solo lugar— aplicado a los datos.

La lección que anticipa el ejercicio: la fábrica no es una comodidad estética, es un seguro contra el cambio. El modelo va a cambiar; la pregunta es si ese cambio te cuesta una edición o cuarenta.

Ejercicio 3 — Separa las dos capas. Un compañero dice: "Ya hice el framework portátil: agregué perfiles local y ci que cambian el timeout." ¿Está en lo correcto? Distingue las dos capas de este módulo y di qué le falta verificar.

Ver solución

No necesariamente. El compañero confundió dos capas distintas. Tener perfiles local y ci que cambian el timeout es la config por entorno (lección 5): que el framework se comporte distinto según dónde corre. Eso está muy bien, pero no es lo mismo que portabilidad (lección 7): que el framework pueda correr en cualquier máquina.

Un framework puede tener los perfiles local/ci perfectos y no ser portátil. Ejemplo: si alguno de sus tests escribe en /Users/tu-nombre/reservo/tmp/data.db, esa ruta no existe en la máquina de otro compañero ni en el contenedor limpio de CI, así que la suite falla ahí sin importar qué perfil elijas. La config por entorno no salva de una ruta absoluta escondida en un test.

Lo que le falta verificar: no buscar rutas absolutas (/Users/..., C:\..., ~/...) ni supuestos de máquina (un puerto fijo, una carpeta que solo existe en su disco) en toda la suite, y reemplazarlas por tmp_path/:memory:. Solo cuando la suite corre limpia en una máquina que no es la suya —o en un contenedor recién clonado— el framework es de verdad portátil. Las dos capas son ortogonales: una es cómo se comporta, la otra es dónde puede correr, y este módulo las construye por separado justamente para no confundirlas.

Resumen y siguiente paso

En esta lección desplegaste el mapa de la última capa del framework: datos y entornos. Viste los dos problemas que llegan juntos cuando una suite crece —el dato de test duplicado a escala (el setup copy-paste del módulo 1 que vuelve como datos) y el framework amarrado a una sola máquina— y las dos piezas que los resuelven: la fábrica de datos como módulo compartido (la estación de preparación que produce ingredientes comunes) y la configuración por entorno (la receta portátil que se adapta a dónde corre). Y las viste ejecutadas: la fábrica fabricando tres reservas distintas con ids únicos diciendo cada una solo lo que le importaba, y pytest --env=ci cambiando el encabezado de la config de local | timeout: 5s | strict: False a ci | timeout: 30s | strict: True sin tocar un solo test.

Antes de avanzar deberías poder: distinguir el problema de los datos a escala del problema de los entornos; nombrar las dos piezas del módulo y qué problema resuelve cada una; y separar la config por entorno (cómo se comporta el framework) de la portabilidad (dónde puede correr).

Lo que sigue es mirar de cerca el primero de los dos problemas. En la lección 2 vas a ver el dolor de los datos a escala en su forma más cruda: una suite donde cada test construye su Booking a mano, el mismo literal 7500 repartido por todos lados, y el momento en que le agregas un campo al modelo y tres tests revientan con el mismo TypeError —el radio de explosión que el ejercicio 2 te hizo contar, ahora ejecutado—. Es el problema que la fábrica de las lecciones 3 y 4 va a curar.

Recursos