Módulo 3: Organizar la suite: capas y estructura de carpetas
8. Mini-proyecto: reorganiza una suite plana de Reservo en capas
Descripción
Al terminar esta lección vas a haber puesto en práctica, de principio a fin, todo lo que enseñó el módulo. El proyecto es concreto: te entregan la suite plana de Reservo —los doce tests apilados en un solo directorio, con un único conftest.py que mezcla las fixtures de las dos capas— y la reorganizas en tests/unit/ y tests/integration/, cada una con su propio conftest.py, moviendo cada test a su capa y cada fixture al conftest.py que le corresponde. Correrás las dos capas por separado para ver que la separación funciona, capturarás el --collect-only del árbol nuevo para leerlo como documentación, y entregarás una suite que corre limpia y cuenta su propia arquitectura por la forma. Es el "antes" de la lección 2 convertido en el "después" del módulo entero, con tus manos.
Esto importa porque reorganizar una suite existente es una de las tareas más frecuentes y menos enseñadas de la vida real de un proyecto. Casi nunca diseñas la estructura de cero sobre una suite vacía; casi siempre heredas un balde plano que creció sin plan —tuyo de hace meses, o de alguien más— y tienes que darle forma sin romper lo que ya funciona. La clave, y lo que este proyecto entrena, es que reorganizar es una operación de forma, no de contenido: mueves archivos y repartes fixtures, pero no tocas lo que cada test afirma. El conteo de tests antes y después debe ser idéntico. Si terminas con más o menos tests, o con un rojo, cambiaste dos cosas a la vez y perdiste el control. Reorganizar bien es cambiar la caja sin tocar lo que hay dentro.
Conexión con el módulo: esta lección es la síntesis. Cada paso activa una lección anterior: separar los tests en dos carpetas es la organización por capa (lección 3) y la separación física (lección 5); repartir las fixtures entre tres conftest.py es el conftest.py por carpeta (lección 4); correr cada capa por separado y leer el árbol es el descubrimiento (lección 6) y la estructura como documentación (lección 7). Y cierra la guía hasta aquí con una pieza entregable. Recuerda la frontera: aquí categorizamos por carpeta; en el módulo 4, los marcadores le sumarán a esta estructura una segunda forma de cortar la suite sin mover un archivo.
El taller que reformas sin cambiar las herramientas
Piénsalo así. Heredas el taller de un carpintero que guardaba todo en un balde: martillos, brocas, destornilladores, todo revuelto. Tu trabajo es montarle la caja con compartimentos —la de la lección 2— para que el taller sea navegable. Pero hay una regla de oro en esa reforma: no cambias ni afilas ni reparas ninguna herramienta. El martillo que entra al compartimento es el mismo martillo que estaba en el balde; solo cambió de lugar. Si de paso decidieras afilar los cinceles, y algo saliera mal después, no sabrías si el problema fue mover las cosas o afilarlas. La reforma es de organización, punto: las mismas herramientas, ahora en compartimentos.
Reorganizar una suite es exactamente esa reforma. Los doce tests que heredas son las herramientas; el directorio plano es el balde; tests/unit/ y tests/integration/ son los compartimentos. Tu trabajo es mover cada test a su compartimento y repartir las fixtures a los conftest.py correctos —nada más—. No renombras tests, no juntas asserts, no "aprovechas para mejorar": eso sería afilar los cinceles a mitad de la mudanza. La prueba de que reformaste bien y no rompiste nada es la más simple del mundo: el mismo número de tests, todos en verde, antes y después. Doce entran al balde, doce salen a la caja.
El punto de partida: la suite plana
Esta es la suite que heredas. Doce tests de Reservo, todos en un directorio tests/, con un único conftest.py. Primero, el conftest.py plano —y aquí está el problema de fondo, además del de las carpetas—:
# tests/conftest.py — UN SOLO conftest plano: todo mezclado, unit e integración
from datetime import datetime
import pytest
from reservo.calendar import Calendar
from reservo.models import Member, Room
from reservo.service import BookingService
@pytest.fixture
def focus():
return Room(id="focus", name="Focus", capacity=4, hourly_cents=2500)
@pytest.fixture
def ana():
return Member(id="ana", name="Ana", tier="basic")
@pytest.fixture
def bruno():
return Member(id="bruno", name="Bruno", tier="pro")
@pytest.fixture
def at():
def _at(hour):
return datetime(2026, 3, 10, hour)
return _at
# --- estas dos SOLO las usan los tests de integración, pero aquí las ve TODA la suite ---
@pytest.fixture
def calendar():
return Calendar()
@pytest.fixture
def service(calendar):
return BookingService(calendar)
Léelo con los ojos del módulo. El conftest.py de la raíz tiene todo amontonado: las fixtures livianas del dominio (focus, ana, bruno, at) junto a las pesadas de integración (calendar, service). Como está en la raíz, toda la suite ve las seis —incluidos los tests unitarios que jamás usarán service—. Es la contaminación de la lección 4: la máquina de pasta guardada en la cocina comunitaria. Y los doce tests, todos en un directorio plano:
tests/
├── conftest.py
├── test_booking_flow.py # integración: arma BookingService
├── test_cancel_flow.py # integración: arma BookingService
├── test_overlaps.py # unitario: overlaps() puro
├── test_pricing.py # unitario: price_cents() puro
└── test_refund.py # unitario: refund_cents() puro
Confirmemos el estado inicial: la contaminación de fixtures y el verde de partida. Primero, qué fixtures ve la suite entera:
python3 -m pytest tests --fixtures
Qué esperar (filtrado a nuestras fixtures). En mi máquina (Python 3.14.0, pytest 9.1.1):
focus -- tests/conftest.py:12
ana -- tests/conftest.py:17
bruno -- tests/conftest.py:22
at -- tests/conftest.py:27
calendar -- tests/conftest.py:35
service -- tests/conftest.py:40
Las seis fixtures, todas en tests/conftest.py, todas visibles para todos los tests —los unitarios de precio ven service aunque no lo toquen—. Y el verde de partida:
python3 -m pytest tests -q
............ [100%]
12 passed in 0.01s
Doce en verde. Este es el número que hay que preservar: reformar bien significa que al final sigan siendo doce, verdes. Nos lo llevamos apuntado.
La reforma, paso a paso
Reorganizar es una secuencia de movimientos de forma. Ninguno toca lo que un test afirma.
Paso 1 — Clasifica cada test por naturaleza. Antes de mover nada, decide la capa de cada archivo con el criterio de la lección 5 (una pieza contra varias). Los tres unitarios son test_overlaps.py, test_pricing.py, test_refund.py —cada uno prueba una función pura—. Los dos de integración son test_booking_flow.py y test_cancel_flow.py —cada uno arma un BookingService—.
Paso 2 — Crea las dos carpetas y mueve los archivos. tests/unit/ para los tres puros, tests/integration/ para los dos sociables:
tests/unit/test_overlaps.py
tests/unit/test_pricing.py
tests/unit/test_refund.py
tests/integration/test_booking_flow.py
tests/integration/test_cancel_flow.py
Paso 3 — Reparte las fixtures entre tres conftest.py. Aquí aplicas la regla del edificio (lección 4): lo compartido baja a la raíz, lo específico va a su capa. Las fixtures del dominio (focus, ana, bruno, at) las usan las dos capas, así que se quedan en la raíz. Las pesadas (calendar, service) solo las usa integración, así que bajan a tests/integration/conftest.py. El conftest.py de la raíz queda limpio:
# tests/conftest.py — RAÍZ, ya solo con lo compartido del dominio
from datetime import datetime
import pytest
from reservo.models import Member, Room
@pytest.fixture
def focus():
return Room(id="focus", name="Focus", capacity=4, hourly_cents=2500)
@pytest.fixture
def ana():
return Member(id="ana", name="Ana", tier="basic")
@pytest.fixture
def bruno():
return Member(id="bruno", name="Bruno", tier="pro")
@pytest.fixture
def at():
def _at(hour):
return datetime(2026, 3, 10, hour)
return _at
Y las pesadas se mudan a su capa —fíjate que la raíz ya no importa Calendar ni BookingService; esos imports se van con las fixtures—:
# tests/integration/conftest.py — las fixtures pesadas, ahora en su capa
import pytest
from reservo.calendar import Calendar
from reservo.service import BookingService
@pytest.fixture
def calendar():
return Calendar()
@pytest.fixture
def service(calendar):
return BookingService(calendar)
Paso 4 — Verifica que la reforma no cambió el contenido. Corre la suite completa y confirma que siguen siendo doce, verdes. La forma cambió; el contenido no.
El árbol resultante:
tests/
├── conftest.py # dominio: focus, ana, bruno, at
├── unit/
│ ├── test_overlaps.py
│ ├── test_pricing.py
│ └── test_refund.py
└── integration/
├── conftest.py # pesadas: calendar, service
├── test_booking_flow.py
└── test_cancel_flow.py
Ejemplo trabajado: la suite reorganizada, corriendo por capas
Ahora la prueba de que la reforma funcionó, y de que las capas están de verdad separadas. Primero, la capa rápida por su cuenta:
python3 -m pytest tests/unit -v
Qué esperar:
collected 7 items
tests/unit/test_overlaps.py::test_touching_intervals_do_not_overlap PASSED [ 14%]
tests/unit/test_overlaps.py::test_nested_interval_overlaps PASSED [ 28%]
tests/unit/test_pricing.py::test_basic_member_pays_hourly_rate_times_hours PASSED [ 42%]
tests/unit/test_pricing.py::test_pro_member_gets_twenty_percent_off PASSED [ 57%]
tests/unit/test_refund.py::test_full_refund_at_72h PASSED [ 71%]
tests/unit/test_refund.py::test_half_refund_at_36h PASSED [ 85%]
tests/unit/test_refund.py::test_no_refund_at_12h PASSED [100%]
============================== 7 passed in 0.01s ===============================
Siete tests unitarios, solos. Ahora la capa de integración por su cuenta:
python3 -m pytest tests/integration -v
Qué esperar:
collected 5 items
tests/integration/test_booking_flow.py::test_booking_a_room_charges_the_pro_price PASSED [ 20%]
tests/integration/test_booking_flow.py::test_room_is_unavailable_after_it_is_booked PASSED [ 40%]
tests/integration/test_booking_flow.py::test_double_booking_the_same_slot_is_rejected PASSED [ 60%]
tests/integration/test_cancel_flow.py::test_cancelling_72h_ahead_refunds_in_full PASSED [ 80%]
tests/integration/test_cancel_flow.py::test_cancelling_frees_the_room PASSED [100%]
============================== 5 passed in 0.01s ===============================
Cinco de integración, solos. Siete más cinco son doce: los mismos doce del balde plano, ni uno más ni uno menos. La reforma preservó el contenido (doce tests, todos verdes) y ganó la forma (dos capas corribles por separado). Eso es reorganizar bien.
Y la prueba de que las fixtures se repartieron y ya no se contaminan: --fixtures en la capa unitaria ya no muestra service.
python3 -m pytest tests/unit --fixtures
Qué esperar (filtrado a nuestras fixtures):
focus -- tests/conftest.py:10
ana -- tests/conftest.py:15
bruno -- tests/conftest.py:20
at -- tests/conftest.py:25
Cuatro fixtures, todas del dominio, todas de la raíz. calendar y service desaparecieron del horizonte de la capa unitaria —viven ahora en tests/integration/conftest.py, fuera de su alcance—. La cocina comunitaria quedó limpia; la máquina pesada está en su piso. Comparado con el --fixtures del inicio, que mostraba las seis para toda la suite, esto es el fin de la contaminación.
Leer el árbol nuevo como documentación
El último entregable es el tablero. Captura el árbol de la suite reorganizada:
python3 -m pytest --collect-only
Qué esperar:
collected 12 items
<Dir tests>
<Dir integration>
<Module test_booking_flow.py>
<Function test_booking_a_room_charges_the_pro_price>
<Function test_room_is_unavailable_after_it_is_booked>
<Function test_double_booking_the_same_slot_is_rejected>
<Module test_cancel_flow.py>
<Function test_cancelling_72h_ahead_refunds_in_full>
<Function test_cancelling_frees_the_room>
<Dir unit>
<Module test_overlaps.py>
<Function test_touching_intervals_do_not_overlap>
<Function test_nested_interval_overlaps>
<Module test_pricing.py>
<Function test_basic_member_pays_hourly_rate_times_hours>
<Function test_pro_member_gets_twenty_percent_off>
<Module test_refund.py>
<Function test_full_refund_at_72h>
<Function test_half_refund_at_36h>
<Function test_no_refund_at_12h>
Este árbol es tu entregable final, y se lee solo (lección 7): dos capas, un núcleo de lógica pura (solape, precio, reembolso) bajo unit/, dos flujos completos (reservar, cancelar) bajo integration/. El mismo --collect-only que al inicio del módulo era una lista plana de cinco módulos en fila, ahora es un mapa. Sin haber tocado lo que un solo test afirma, convertiste el balde en la caja con compartimentos.
Tu entrega
El proyecto te pide tres cosas, y cada una ejercita algo distinto:
- La suite reorganizada. Toma la suite plana de Reservo (la de esta lección, o una que armes apilando los doce tests en un directorio con un
conftest.pyque mezcle las fixtures) y reorganízala entests/unit/ytests/integration/, con la raíz para las fixtures del dominio ytests/integration/conftest.pyparacalendaryservice. Debe correr en verde por capas:pytest tests/unit(7) ypytest tests/integration(5). - La prueba de que el contenido no cambió. Corre
pytest tests(opytesta secas) antes y después de la reforma y captura los dos conteos:12 passedal inicio,12 passedal final. Ese número idéntico es la demostración de que reorganizaste la forma sin tocar el contenido —moviste herramientas, no las afilaste—. - El árbol como documentación y el fin de la contaminación. Captura el
--collect-onlydel árbol reorganizado (que se lea como el mapa del sistema) y el--fixturesdetests/unitmostrando que ya no vecalendarniservice. Esos dos son la evidencia de que ganaste las dos cosas del módulo: una forma que documenta y unas capas que no se contaminan.
Un criterio de "terminado" honesto, en el espíritu de esta guía: la reforma está lista no cuando la suite pasa —el balde plano también pasaba—, sino cuando pasa por capas separadas (7 y 5, no solo 12 juntos), cuando el --fixtures de la capa unitaria ya no arrastra la maquinaria de integración, y cuando un compañero que no vio el código puede leer el --collect-only y contarte la arquitectura de Reservo. Si se cumplen esas tres cosas, montaste la caja con compartimentos.
Errores comunes
Cambiar el contenido "de paso" durante la reforma (de alcance). Qué pasa: al mover los archivos, alguien aprovecha para renombrar un test, juntar dos, o "arreglar" un assert. Por qué pasa: tener los archivos abiertos invita a mejorar todo a la vez. Cómo detectarlo: si el conteo antes y después no es idéntico (12 → 12), o si aparece un rojo, cambiaste contenido y forma a la vez y ya no sabes cuál causó qué. Cómo corregirlo: reorganiza en un paso —solo mover archivos y repartir fixtures— y verifica 12 passed → 12 passed. Mejorar los tests es otra tarea, otro momento, otro commit. Una cosa a la vez.
Olvidar bajar las fixtures pesadas a su capa (de reforma incompleta). Qué pasa: alguien mueve los archivos de test a unit/ e integration/ pero deja todas las fixtures en el conftest.py de la raíz. Por qué pasa: mover archivos se siente como "ya está", y repartir fixtures es el paso menos visible. Cómo detectarlo: corre pytest tests/unit --fixtures; si todavía ves calendar y service, la reforma quedó a medias —las carpetas se separaron pero la contaminación sigue—. Cómo corregirlo: mueve calendar y service (y su import de Calendar/BookingService) a tests/integration/conftest.py. La reorganización no es solo de archivos de test: las fixtures también viajan a su capa, o el aislamiento es solo aparente.
Clasificar un test por su nombre o su tamaño en vez de su naturaleza (de criterio). Qué pasa: alguien pone test_booking_flow.py en unit/ "porque es cortito" o test_pricing.py en integration/ "porque el precio es central". Por qué pasa: se confunde tamaño o importancia con capa. Cómo detectarlo: mira qué arma el test —si monta un Calendar/BookingService, es integración; si llama una función pura, es unidad— sin importar cuántas líneas tenga ni qué tan importante sea el tema. Cómo corregirlo: aplica el criterio de la lección 5 (una pieza contra varias). Un test que importa BookingService no es unitario aunque quepa en cinco líneas; ubicarlo mal haría que la estructura mienta (lección 7).
Ejercicios
Ejercicio 1 — Reparte estas fixtures. Además de las seis del ejemplo, la suite plana tiene tres fixtures más en su conftest.py raíz. Para cada una, di a qué conftest.py la mueves en la reforma y por qué. (a) boardroom, una Room grande que usan tests de precio (unit) y de flujo (integración). (b) booked_calendar, un Calendar con una reserva ya cargada, que solo usan tests de integración. (c) three_hours, la constante 3 que solo usan los tests de precio unitarios.
Ver solución
- (a)
boardroom→ se queda en la raíz (tests/conftest.py). La usan las dos capas, así que es compartida del dominio, comofocusyana. Baja a la cocina comunitaria para que ambas la hereden sin duplicarla. - (b)
booked_calendar→tests/integration/conftest.py. Arma unCalendarcon estado y solo la usan tests de integración: es maquinaria pesada de una sola capa. Se muda concalendaryservice. - (c)
three_hours→tests/unit/conftest.py. Solo la usan tests de precio unitarios; es específica de esa capa. Si aún no existetests/unit/conftest.py, la reforma lo crea para alojarla (es elhours_3de las lecciones anteriores). No tiene sentido en la raíz ni en integración.
La regla que aplicaste: "quién la usa" decide la altura. Todas las capas → raíz; solo integración → su conftest; solo unidad → el conftest de unit. Repartir bien las fixtures es la mitad menos visible, y más importante, de la reforma.
Ejercicio 2 — Verifica sin correr. Un compañero dice que terminó la reforma. Antes de correr nada, ¿qué tres comandos le pedirías que ejecute para demostrar que reorganizó bien, y qué esperarías ver en cada uno? Explica qué probaría cada resultado.
Ver solución
Tres comandos, cada uno prueba una cosa distinta:
pytest tests -q(opytesta secas) → esperaría12 passed. Prueba que el contenido no cambió: siguen siendo los doce tests del balde plano, todos verdes. Si mostrara otro número o algún rojo, la reforma tocó contenido, no solo forma.pytest tests/unit -qypytest tests/integration -q→ esperaría7 passedy5 passed. Prueba que las capas están de verdad separadas y son corribles por su cuenta: 7 + 5 = 12, los subconjuntos son disjuntos y suman el total. Sipytest tests/unitdiera 8, algún test de integración quedó mal ubicado enunit/.pytest tests/unit --fixtures→ esperaría verfocus,ana,bruno,atpero nocalendarniservice. Prueba que las fixtures se repartieron y la contaminación terminó: la capa unitaria ya no ve la maquinaria de integración. Si todavía mostraraservice, las fixtures pesadas siguen en la raíz y la reforma quedó a medias.
Los tres juntos cubren las dos dimensiones de una buena reforma: contenido preservado (comando 1) y forma ganada, tanto en carpetas (comando 2) como en fixtures (comando 3). Un cuarto opcional, pytest --collect-only, confirma que el árbol se lee como documentación.
Ejercicio 3 — Cierra el módulo: escribe la especificación desde el árbol. Corre pytest --collect-only sobre tu suite reorganizada y, usando solo la forma del árbol y los nombres de los tests, escribe la especificación de Reservo que la suite documenta: cuántas capas, qué reglas puras, qué flujos, y una regla del negocio por test que puedas leer de su nombre. Luego reflexiona: ¿qué te dio esta reorganización que el balde plano de la lección 2 no daba?
Ver solución
Del árbol reorganizado, leído sin abrir código, sale esta especificación:
Arquitectura: dos capas de prueba —unitaria (lógica pura) e integración (flujos completos)—.
Reglas puras (bajo unit/):
- Solape de horarios (
test_overlaps): dos intervalos que se tocan no se solapan; un intervalo contenido en otro sí. - Precio (
test_pricing): el miembro basic paga la tarifa por hora por las horas; el pro tiene 20% de descuento. - Reembolso (
test_refund): a 72 h del inicio el reembolso es total; a 36 h, la mitad; a 12 h, nada.
Flujos completos (bajo integration/):
- Reservar (
test_booking_flow): reservar cobra el precio pro correcto; tras reservar, la sala queda no disponible; una doble reserva del mismo horario se rechaza. - Cancelar (
test_cancel_flow): cancelar con 72 h de anticipación reembolsa el total; cancelar libera la sala.
Esa es la documentación viva de Reservo, extraída del puro árbol.
Qué te dio la reorganización que el balde plano no daba: las tres cosas del módulo. Poder correr por capas —pytest tests/unit como red rápida durante el desarrollo, pytest tests/integration cuando quieres verificar los flujos—, que en el balde exigía nombrar archivos a mano. Aislamiento de fixtures —la capa unitaria ya no carga calendar/service—, que en el balde contaminaba a todos. Y una forma que documenta —el árbol que acabas de leer como especificación—, que en el balde era una lista plana sin arquitectura. Todo eso sin cambiar lo que un solo test afirma: los mismos doce, ahora en una estructura que se puede navegar, correr por partes y entender de un vistazo. Ese es el salto de "una pila de tests que pasa" a "una suite con arquitectura", que fue el tema del módulo entero.
Resumen y siguiente paso
En este mini-proyecto reuniste todo el módulo en una tarea real: reorganizar la suite plana de Reservo en capas. Partiste del balde —doce tests en un directorio, con un conftest.py que amontonaba las fixtures livianas del dominio junto a las pesadas de integración, contaminando a toda la suite— y lo reformaste en cuatro pasos de pura forma: clasificaste cada test por naturaleza, moviste los archivos a tests/unit/ y tests/integration/, repartiste las fixtures (el dominio en la raíz, calendar/service en la capa de integración), y verificaste que el contenido no cambió. Lo comprobaste ejecutando: 12 passed antes y después (contenido preservado), pytest tests/unit da 7 y pytest tests/integration da 5 (capas corribles por separado), el --fixtures de la capa unitaria ya no muestra calendar ni service (contaminación terminada), y el --collect-only dibuja un árbol que se lee como la especificación de Reservo (forma que documenta). Moviste las herramientas sin afilarlas: el balde se volvió la caja con compartimentos.
Con esto sabes diseñar y aplicar la estructura física de una suite: organizar por capa o por feature, un conftest.py por carpeta que aísla, separar unit de integration para correrlas por su cuenta, entender el descubrimiento que hace posible todo, y leer —y mantener honesta— la estructura como documentación. Ya no confundes "la suite pasa" con "la suite tiene arquitectura".
Lo que sigue en la guía es el módulo 4: marcadores y configuración. Hasta aquí categorizaste los tests por su lugar físico —la carpeta donde viven—. Los marcadores (@pytest.mark.slow, @pytest.mark.integration) te darán una segunda forma de categorizar, ortogonal a las carpetas: una etiqueta que puedes pegarle a cualquier test, esté donde esté, para luego seleccionar subconjuntos con -m que cortan la suite en una dirección que la estructura de carpetas no puede —por ejemplo, "todos los tests lentos, sin importar en qué capa o feature vivan"—. Verás cómo registrar marcadores en pyproject.toml, cómo la configuración se vuelve el contrato del framework, y cómo carpetas y marcadores se complementan: la estructura física que construiste en este módulo, más una capa de etiquetas que la cruza. Los cimientos de la organización ya los tienes; lo que viene es la segunda dimensión.
Recursos
- pytest — Good Integration Practices — las convenciones oficiales de estructura de proyecto; el estándar hacia el que reformaste el balde plano de Reservo.
- pytest — conftest.py: sharing fixtures across files — la referencia de cómo repartir fixtures en varios
conftest.py; la base del paso 3 de la reforma. - pytest — How to invoke pytest (
--collect-only) — cómo correr capas por separado y dibujar el árbol; los comandos con los que verificaste y documentaste la suite reorganizada.