Módulo 8: Proyecto — arma un framework de tests para Reservo
8. Proyecto: arma el framework de tests para Reservo
Descripción
Este es el capstone de la guía. Durante siete módulos aprendiste las capas de un framework de tests una por una, y en las seis lecciones anteriores las rearmaste sobre Reservo, viéndolas cooperar. Ahora las armas tú, de principio a fin, y entregas dos cosas: el framework completo y una suite que lo usa y corre limpia. No hay teoría nueva; hay síntesis. Lo que este proyecto entrena —y evalúa— no es tu capacidad de escribir un test, sino tu capacidad de diseñar la arquitectura de una suite que crece: qué pieza va dónde, con qué scope, en qué conftest.py, y —lo más importante— por qué.
Al terminar esta lección vas a tener el enunciado formal del proyecto (los entregables, los requisitos por capa), la rúbrica con la que se evalúa —una rúbrica que mide decisiones de arquitectura justificadas, no cantidad de tests—, y una solución de referencia completa en un desplegable, para que contrastes tu diseño con uno que corre en verde. Y como esta es la última lección de la guía, cierra con el arco de los ocho módulos y con las cinco guías hermanas que toman el testigo desde donde esta lo deja. Es el examen y la despedida en una sola lección.
Conexión con el módulo: esta lección convierte en entrega lo que la lección 1 te mostró terminado y la 7 corriste en todos sus modos. La solución de referencia es el framework que ensamblaste capa por capa en las lecciones 2 a 6. La frontera con las lecciones anteriores: aquellas construían y explicaban cada capa; esta evalúa el conjunto (qué hace buena una arquitectura de framework) y cierra la guía. No queda una lección después: al terminar esta, la guía termina.
El encargo
Aquí está el brief, como te lo daría un equipo real. La suite de tests de Reservo empezó como un archivo con tres tests y creció sin arquitectura: hoy tiene decenas de tests que reconstruyen a mano la sala, el socio, el servicio y el calendario, con la misma cuenta de reembolso copiada en quince lugares, sin forma de correr solo los rápidos, y con una ruta absoluta escondida que la rompe en la máquina de cualquier otro. Tu encargo es darle una arquitectura: convertir esa pila de scripts en un framework mantenible y portátil.
No tienes que probar todo Reservo —la cantidad de tests no es la meta—. Tienes que construir un framework pequeño pero completo: que tenga las seis capas, que cada capa esté bien puesta y justificada, y que una suite modesta lo use de verdad y corra limpia. Un framework con seis capas bien diseñadas y quince tests que las ejercen vale más que uno con cien tests y ninguna arquitectura. Piensa como el arquitecto que entrega los planos de una casa habitable, no como el albañil que pone la mayor cantidad de ladrillos.
El sistema bajo prueba —el código de reservo/ (modelos, precios, calendario, dobles, servicio)— lo tomas como dado: no lo diseñas ni lo modificas, lo pruebas. Lo que diseñas y entregas es todo lo demás: el pyproject.toml, los conftest.py, la biblioteca compartida y la suite.
Los entregables
La entrega tiene dos partes, y las dos se evalúan.
Entregable 1: el framework (la infraestructura). Debe incluir las seis capas:
- La jerarquía de
conftest.py(M2): unconftest.pyen la raíz con los colaboradores (calendar,clock,payments,emails,repo) y la fixture compuestabooking_service; untests/conftest.pycon los datos de dominio; y al menos unconftest.pypor carpeta (tests/integration/conftest.py) con una fixture que solo esa capa necesita. Cada fixture en el nivel del ancestro común de sus usuarios. - La estructura de carpetas (M3): la suite partida en capas físicas (
tests/unit/,tests/integration/), con un criterio de partición claro y justificado. - El
pyproject.toml(M4): contestpaths,pythonpath,addopts(incluyendo--strict-markers), y los marcadores personalizados registrados (smoke,slow,integration), cada uno con su descripción. - La biblioteca de helpers y aserciones (M5):
tests/helpers.pyytests/asserts.pycomo módulos importables, con al menos una aserción personalizada que use__tracebackhide__y un mensaje de dominio. - El plugin local con hooks (M6): en el
conftest.py,pytest_report_header(el banner del framework) ypytest_terminal_summary(el hook de resumen que imprime el conteo al cierre). - La factory de datos y la config por entorno (M7):
tests/factories.pyconmake_room,make_member,make_booking; y la config por entorno con la opción--env, el diccionario de entornos y la fixturesettings, con la base de datos en memoria (portabilidad).
Entregable 2: la suite que lo usa (la demostración). Una suite modesta —del orden de una docena de tests— repartida entre unit/ e integration/, que:
- Use la columna de fixtures (ningún test reconstruye el servicio a mano).
- Use la biblioteca de aserciones (al menos los tests de reembolso llaman a
assert_refund, no a unassertcrudo). - Use la factory (ningún test construye un
Bookingcon sus siete campos a mano). - Lleve marcadores donde corresponda (
smokeen el camino crítico,slow/integrationdonde apliquen). - Corra limpia:
pytestda todo verde,pytest -m smokeselecciona los críticos, y el hook de resumen imprime.
La suite no es lo evaluado en sí; es la prueba de que el framework funciona. Un framework que ninguna suite usa es un plano sin casa.
La rúbrica: se evalúa la arquitectura
Aquí está lo que distingue este capstone de "escribe muchos tests". La rúbrica no cuenta tests: mide decisiones de arquitectura y su justificación. Un entregable excelente no es el que tiene más código, sino el que, para cada pieza, puede responder por qué está ahí y no en otro lado. Estas son las seis dimensiones, cada una con lo que separa un diseño sólido de uno pobre.
1. Ubicación en la jerarquía de conftest.py. ¿Cada fixture vive en el nivel del ancestro común de sus usuarios? Sólido: la infraestructura en la raíz, los datos de dominio en tests/conftest.py, lo específico de integración en tests/integration/conftest.py; ninguna fixture más arriba de donde se usa. Pobre: todo en la raíz "para que alcance", con fixtures de una sola capa visibles para toda la suite. Se evalúa: que sepas justificar cada ubicación con la regla del ancestro común y la naturaleza (infraestructura vs datos).
2. Composición de fixtures. ¿booking_service está compuesto de colaboradores pedidos como parámetros, o armado a mano en una fixture-monstruo? Sólido: cinco fixtures de colaboradores y un booking_service que las pide, de modo que un test pueda inspeccionar payments y que un colaborador se pueda sobrescribir por carpeta. Pobre: una fixture booking_service que construye sus colaboradores adentro, sin poder inspeccionarlos. Se evalúa: que la composición habilite la inspección y la sustitución, no solo que "funcione".
3. El contrato de configuración. ¿El pyproject.toml es un contrato completo y los marcadores están registrados y protegidos? Sólido: testpaths, pythonpath, addopts con --strict-markers, y los tres marcadores registrados con descripción; cada marcador justificado por un pytest -m real. Pobre: marcadores en crudo sin registrar, o registrados sin --strict-markers (el hueco del typo abierto), o marcadores que duplican la carpeta sin agregar combinabilidad. Se evalúa: que cada línea del contrato tenga una razón y que los marcadores sean un vocabulario corto y ortogonal.
4. DRY sin magia escondida. ¿La biblioteca comparte el cómputo y la verificación sin esconder lo que el test afirma? Sólido: assert_refund con mensaje de dominio y __tracebackhide__, y el número-ancla (6000) visible en el cuerpo del test. Pobre: un "botón mágico" check_booking_ok que hace diez cosas y deja el test mudo, o aserciones sin __tracebackhide__ que apuntan al helper. Se evalúa: que factorices la duplicación manteniendo la intención del test visible.
5. La extensión con hooks. ¿Los hooks están bien elegidos y bien escritos? Sólido: pytest_report_header que devuelve el banner leyendo la config por entorno, y pytest_terminal_summary que escribe el resumen preguntándole a terminalreporter.stats; los nombres exactos. Pobre: lógica de reporte metida en una fixture autouse, o un hook con el nombre casi correcto que falla en silencio. Se evalúa: que distingas hook de fixture por quién invoca y que uses el hook correcto para cada trabajo.
6. Datos y portabilidad. ¿La factory baja el radio de explosión a uno, y el framework corre en cualquier máquina? Sólido: make_booking con defaults sensatos y sobrescritura selectiva, base de datos :memory:, cero rutas absolutas; un cambio de modelo se absorbe en una línea de la factory. Pobre: datos construidos a mano en los tests, o una ruta /Users/... escondida que rompe el framework fuera de tu disco. Se evalúa: que la factory concentre la construcción y que la portabilidad sea real (verificable en una máquina limpia).
La regla de oro de la rúbrica: por cada pieza de tu framework, deberías poder decir en una frase por qué está donde está. Si la respuesta es "porque funcionaba", es un diseño pobre disfrazado de correcto. Si la respuesta es "porque sus usuarios están en estas dos carpetas y su ancestro común es esta, y es infraestructura, no dato", es arquitectura.
Errores comunes
Entregar cien tests y una arquitectura de cartón. Qué pasa: alguien, midiendo su esfuerzo por el número de tests, llena la suite de casos repetidos y descuida las capas: un conftest.py con todo apilado, sin aserciones compartidas, con datos a mano. La rúbrica lo penaliza aunque la suite sea grande. Por qué pasa: la cantidad es fácil de ver y de producir; la arquitectura no. Cómo detectarlo: si tu suite tiene muchos tests pero no puedes justificar la ubicación de cada fixture, invertiste en volumen, no en diseño. Cómo corregirlo: recorta tests si hace falta y gasta ese esfuerzo en las seis capas. Quince tests que ejercen bien las seis capas superan a cien que copian setup.
Justificar con "así funciona" en vez de con la regla. Qué pasa: alguien pone cada fixture donde primero se le ocurrió y, al preguntársele por qué, responde "porque la suite pasa". La suite pasar es necesario pero no suficiente: una suite puede pasar con toda la arquitectura mal puesta. Por qué pasa: el verde se siente como la prueba final. Cómo detectarlo: si tu única justificación para una decisión es que no rompe, no tienes una justificación de arquitectura. Cómo corregirlo: para cada pieza, ancla la decisión en la regla que la respalda —el ancestro común (ubicación), la composición (inspección/sustitución), el vocabulario ortogonal (marcadores), DRY sin magia (aserciones), quién invoca (hooks), el radio de explosión (factory)—. El capstone evalúa el razonamiento, no solo el resultado.
Tratar el código de Reservo como parte del entregable. Qué pasa: alguien, para "mejorar" el framework, empieza a refactorizar pricing.py o service.py. Eso está fuera del encargo: el sistema bajo prueba es dado. Por qué pasa: todo vive en el mismo repositorio, y la línea entre producto y framework se difumina. Cómo detectarlo: si estás editando reservo/, saliste del alcance. Cómo corregirlo: el entregable es el framework (pyproject.toml, los conftest.py, tests/) y la suite; reservo/ se toma como está. Diseñas cómo se prueba, no qué se prueba.
Ejercicios
Ejercicio 1 — Aplica la rúbrica a un diseño ajeno. Un compañero entrega un framework con estas decisiones. Para cada una, di qué dimensión de la rúbrica evalúa y si es sólida o pobre, con la regla que la respalda. (a) Puso focus_room, booking_service y confirmed_booking todas en el conftest.py de la raíz. (b) Registró los marcadores smoke, slow, integration, unit, fast. (c) Su assert_refund no tiene __tracebackhide__. (d) Su make_booking construye el Booking con defaults y sobrescritura selectiva.
Ver solución
- (a) Dimensión 1 (ubicación), pobre.
focus_room(datos, usada por unit e integration) debería ir entests/conftest.py, yconfirmed_booking(solo integración) entests/integration/conftest.py. Ponerlas todas en la raíz viola el ancestro común:confirmed_bookingqueda visible para los tests unitarios que no la usan. Solobooking_service(infraestructura común) va bien en la raíz. - (b) Dimensión 3 (config/marcadores), pobre. El vocabulario tiene dos etiquetas de más:
unitduplica la carpetatests/unit/(ynot integration) sin agregar combinabilidad, yfastes el opuesto exacto deslow(ya expresable connot slow). Un vocabulario ortogonal se queda en los tres originales. Registrar etiquetas redundantes ensucia el catálogo. - (c) Dimensión 4 (DRY sin magia), pobre. Sin
__tracebackhide__, cada fallo deassert_refundapunta alraisedentro detests/asserts.pyen vez de a la línea del test. Es una línea que falta y que cambia radicalmente la utilidad de la aserción al fallar. - (d) Dimensión 6 (datos), sólida. Defaults sensatos + sobrescritura selectiva (
{**defaults, **overrides}) es exactamente lo que baja el radio de explosión a uno y deja que cada test diga solo lo que le importa. Bien.
La lección: aplicar la rúbrica es preguntar, pieza por pieza, qué regla la respalda y si la cumple. Sólido no es "funciona"; es "está donde la regla dice que debe estar".
Ejercicio 2 — Justifica tres decisiones de tu diseño. Para tu propio framework (o el de referencia), escribe la justificación en una frase de tres decisiones: (a) por qué booking_service va en la raíz y confirmed_booking en tests/integration/conftest.py; (b) por qué el marcador integration no es redundante con la carpeta integration/; (c) por qué la factory es un módulo importable (tests/factories.py) y no solo una fixture.
Ver solución
- (a)
booking_servicees infraestructura que usan las dos capas (unit e integration), así que su ancestro común es la raíz;confirmed_bookingla usa solo la integración, así que su ancestro común estests/integration/, y ponerla más arriba la haría visible para unit sin necesidad. - (b) La carpeta
integration/da el corte simple (pytest tests/integration), pero el marcadorintegrationda el corte combinable:pytest -m "integration and smoke"aísla los de integración que además son críticos, algo que la carpeta sola no puede porquesmokeno es una carpeta. El marcador existe para poder cruzarlo. - (c) Como módulo importable, la factory la pueden usar tanto las fixtures de datos (
focus_roomesmake_room()) como cualquier test, script o siembra que la importe —from tests.factories import make_booking—; una fixture solo la ven los tests que la piden. La factory produce, y las fixtures de datos son adaptadores delgados que la entregan por nombre.
La lección: una justificación de arquitectura ancla la decisión en una regla (ancestro común, combinabilidad, reutilización), no en el resultado. Poder escribir estas frases es la señal de que diseñaste, no solo copiaste.
Ejercicio 3 — Predice tu prueba de estrés. Sin correr nada, predice qué le pasa a tu framework si al modelo Member se le agrega un campo obligatorio email. (a) ¿Cuántos lugares del lado del framework hay que editar y cuáles? (b) ¿Qué tests fallarían si no editas nada, y a qué archivo apuntarían? (c) ¿Qué dice tu respuesta sobre la calidad de tu arquitectura de datos?
Ver solución
- (a) Un lugar:
tests/factories.py, agregandoemail="ana@reservo.test"(o similar) aldefaultsdemake_member. (Del lado de producción, además, se toca el modelo y cualquier código dereservo/que construyaMember—pero eso es el sistema, no el framework—.) - (b) Fallarían los tests que fabrican un
Membera través de la factory —los que piden fixturesanaobruno, que sonmake_member(...)—, y todos apuntarían al mismo lugar: la línea demake_memberentests/factories.py, conTypeError: missing 1 required positional argument: 'email'. El radio de explosión se concentra en la factory. - (c) Si tu respuesta a (a) es "un lugar" y a (b) es "todos apuntan a la factory", tu arquitectura de datos es sólida: centralizaste la construcción, así que un cambio de modelo se paga una vez. Si tu respuesta fuera "hay que editar quince tests que construyen
Membera mano", tu arquitectura de datos es pobre: no centralizaste, y el radio de explosión se reparte por la suite. La prueba de estrés mental es la forma más rápida de auditar la dimensión 6 de la rúbrica sin correr nada.
Solución de referencia
Aquí está un framework completo que satisface el encargo y corre en verde. Úsalo para contrastar tu diseño —no como la única respuesta correcta (la ubicación de una fixture, el criterio de partición y el vocabulario de marcadores admiten variantes defendibles), sino como una respuesta sólida cuyas decisiones puedes justificar—. Está verificado con Python 3.14.0 y pytest 9.1.1.
Ver la solución de referencia completa (el framework + la suite)
El sistema bajo prueba (reservo/, dado — no es parte del entregable, se incluye para que la suite corra):
# reservo/models.py
from dataclasses import dataclass
from datetime import datetime
@dataclass
class Room:
id: str
name: str
capacity: int
hourly_cents: int # precio por hora, en centavos (int)
@dataclass
class Member:
id: str
name: str
tier: str # "basic" | "pro" (pro tiene 20% de descuento)
@dataclass
class Booking:
id: str
room_id: str
member_id: str
start: datetime
end: datetime
status: str # "confirmed" | "cancelled"
price_cents: int # lo cobrado por esta reserva, en centavos (int)
# reservo/pricing.py
def price_cents(room, member, hours):
"""hourly_cents * hours, menos 20% si el socio es pro. Centavos (int)."""
base = room.hourly_cents * hours
if member.tier == "pro":
return base * 80 // 100
return base
def refund_cents(booking, price_paid_cents, now):
"""Reembolso al cancelar, segun la anticipacion entre now y booking.start."""
hours_ahead = (booking.start - now).total_seconds() / 3600
if hours_ahead >= 48:
return price_paid_cents
if hours_ahead >= 24:
return price_paid_cents // 2
return 0
# reservo/calendar.py
def overlaps(a_start, a_end, b_start, b_end):
"""Dos rangos [start, end) se solapan? Tocarse en el borde NO es solaparse."""
return a_start < b_end and b_start < a_end
class Calendar:
def __init__(self):
self._bookings = []
def add(self, booking):
self._bookings.append(booking)
def confirmed_for_room(self, room_id):
return [b for b in self._bookings
if b.room_id == room_id and b.status == "confirmed"]
def is_available(self, room_id, start, end):
for b in self._bookings:
if (b.room_id == room_id and b.status == "confirmed"
and overlaps(start, end, b.start, b.end)):
return False
return True
# reservo/doubles.py
class FixedClock:
def __init__(self, now):
self._now = now
def now(self):
return self._now
class FakePaymentGateway:
def __init__(self):
self.charges = []
self.refunds = []
def charge(self, cents, member_id):
self.charges.append((cents, member_id))
def refund(self, cents, member_id):
self.refunds.append((cents, member_id))
class SpyEmailSender:
def __init__(self):
self.sent = []
def send(self, to, subject):
self.sent.append((to, subject))
class FakeBookingRepository:
def __init__(self):
self.saved = []
def save(self, booking):
self.saved.append(booking)
def all(self):
return list(self.saved)
# reservo/service.py
from itertools import count
from reservo.models import Booking
from reservo.pricing import price_cents, refund_cents
class SlotTakenError(Exception):
"""La franja ya esta reservada por otra reserva confirmada."""
class BookingService:
def __init__(self, calendar, clock, payments, emails, repo):
self.calendar = calendar
self.clock = clock
self.payments = payments
self.emails = emails
self.repo = repo
self._seq = count(1)
def book(self, room, member, start, end):
if not self.calendar.is_available(room.id, start, end):
raise SlotTakenError(f"{room.id} ocupada entre {start} y {end}")
hours = int((end - start).total_seconds() // 3600)
price = price_cents(room, member, hours)
self.payments.charge(price, member.id)
booking = Booking(
id=f"bk-{next(self._seq)}",
room_id=room.id, member_id=member.id,
start=start, end=end,
status="confirmed", price_cents=price,
)
self.calendar.add(booking)
self.repo.save(booking)
self.emails.send(member.id, "booking confirmed")
return booking
def cancel(self, booking):
now = self.clock.now()
refund = refund_cents(booking, booking.price_cents, now)
if refund:
self.payments.refund(refund, booking.member_id)
booking.status = "cancelled"
self.emails.send(booking.member_id, "booking cancelled")
return refund
Capa 3 — el contrato (pyproject.toml):
[tool.pytest.ini_options]
minversion = "9.0"
pythonpath = ["."]
testpaths = ["tests"]
addopts = "--strict-markers -ra"
markers = [
"smoke: el camino critico feliz; se corre antes de desplegar.",
"slow: el test tarda; se excluye con -m 'not slow' al desarrollar.",
"integration: arma varias piezas reales de Reservo juntas.",
]
Capas 2, 5 y 6 — el conftest.py de la raíz (columna de fixtures + hooks + config por entorno):
# conftest.py (raíz)
from datetime import datetime
import pytest
from reservo.calendar import Calendar
from reservo.doubles import (FakeBookingRepository, FakePaymentGateway,
FixedClock, SpyEmailSender)
from reservo.service import BookingService
FRAMEWORK_VERSION = "1.0.0"
# --- Configuracion por entorno (--env) ---
ENVIRONMENTS = {
"local": {"db": ":memory:", "timeout_seconds": 5, "strict_warnings": False},
"ci": {"db": ":memory:", "timeout_seconds": 30, "strict_warnings": True},
}
def pytest_addoption(parser):
group = parser.getgroup("reservo")
group.addoption(
"--env", action="store", default="local",
choices=sorted(ENVIRONMENTS),
help="entorno de ejecucion del framework (local | ci)",
)
class Settings:
def __init__(self, env, values):
self.env = env
self.db = values["db"]
self.timeout_seconds = values["timeout_seconds"]
self.strict_warnings = values["strict_warnings"]
@pytest.fixture(scope="session")
def settings(request):
env = request.config.getoption("--env")
return Settings(env, ENVIRONMENTS[env])
# --- Columna vertebral: colaboradores y el servicio compuesto ---
@pytest.fixture
def calendar():
return Calendar()
@pytest.fixture
def clock():
# 72h antes del lunes 2026-03-02 09:00, para que cancelar reembolse todo.
return FixedClock(datetime(2026, 2, 27, 9))
@pytest.fixture
def payments():
return FakePaymentGateway()
@pytest.fixture
def emails():
return SpyEmailSender()
@pytest.fixture
def repo():
return FakeBookingRepository()
@pytest.fixture
def booking_service(calendar, clock, payments, emails, repo):
return BookingService(calendar, clock, payments, emails, repo)
# --- Hooks (extension del corredor) ---
def pytest_report_header(config):
env = config.getoption("--env")
values = ENVIRONMENTS[env]
return (f"reservo framework: v{FRAMEWORK_VERSION} | env: {env} | "
f"db: {values['db']} | timeout: {values['timeout_seconds']}s | "
f"strict: {values['strict_warnings']}")
def pytest_terminal_summary(terminalreporter, exitstatus, config):
tr = terminalreporter
passed = len(tr.stats.get("passed", []))
failed = len(tr.stats.get("failed", []))
skipped = len(tr.stats.get("skipped", []))
env = config.getoption("--env")
tr.write_sep("=", "reservo framework summary", cyan=True)
tr.write_line(f"env={env} passed={passed} failed={failed} skipped={skipped}")
Capa 4 — la biblioteca (tests/helpers.py y tests/asserts.py):
# tests/helpers.py
from datetime import timedelta
def hours_before(start, n):
"""El instante que cae n horas antes de start."""
return start - timedelta(hours=n)
# tests/asserts.py
from reservo.pricing import price_cents, refund_cents
def assert_price(room, member, hours, expected):
__tracebackhide__ = True
actual = price_cents(room, member, hours)
if actual != expected:
raise AssertionError(
f"precio incorrecto: {member.tier} x {hours}h @ {room.hourly_cents}/h "
f"-> esperado {expected}, obtenido {actual}"
)
def assert_refund(booking, now, expected):
__tracebackhide__ = True
actual = refund_cents(booking, booking.price_cents, now)
if actual != expected:
hours = int((booking.start - now).total_seconds() // 3600)
raise AssertionError(
f"reembolso incorrecto: pagado {booking.price_cents}, "
f"cancelado {hours}h antes -> esperado {expected}, obtenido {actual}"
)
def assert_charged(payments, cents, member_id):
__tracebackhide__ = True
if (cents, member_id) not in payments.charges:
raise AssertionError(
f"cobro no encontrado: esperaba ({cents}, {member_id!r}) "
f"en {payments.charges}"
)
Capa 6 — la factory (tests/factories.py):
# tests/factories.py
from datetime import datetime
from itertools import count
from reservo.models import Booking, Member, Room
_booking_ids = count(1)
def make_room(**overrides):
defaults = dict(id="focus", name="Focus", capacity=1, hourly_cents=2500)
return Room(**{**defaults, **overrides})
def make_member(**overrides):
defaults = dict(id="m-ana", name="Ana", tier="basic")
return Member(**{**defaults, **overrides})
def make_booking(**overrides):
if "id" not in overrides:
overrides["id"] = f"bk-{next(_booking_ids)}"
defaults = dict(
room_id="focus", member_id="m-ana",
start=datetime(2026, 3, 2, 9), end=datetime(2026, 3, 2, 12),
status="confirmed", price_cents=7500,
)
return Booking(**{**defaults, **overrides})
Capa 2 — los datos de dominio (tests/conftest.py) y la fixture de integración (tests/integration/conftest.py):
# tests/conftest.py
from datetime import datetime
import pytest
from tests.factories import make_booking, make_member, make_room
@pytest.fixture
def focus_room():
return make_room()
@pytest.fixture
def ana():
return make_member()
@pytest.fixture
def bruno():
return make_member(id="m-bruno", name="Bruno", tier="pro")
@pytest.fixture
def monday_9am():
return datetime(2026, 3, 2, 9)
@pytest.fixture
def paid_booking():
return make_booking(member_id="m-bruno", price_cents=6000,
start=datetime(2026, 3, 2, 9), end=datetime(2026, 3, 2, 12))
# tests/integration/conftest.py
from datetime import timedelta
import pytest
@pytest.fixture
def confirmed_booking(booking_service, focus_room, bruno, monday_9am):
end = monday_9am + timedelta(hours=3)
return booking_service.book(focus_room, bruno, monday_9am, end)
La suite que usa el framework (tests/unit/ y tests/integration/):
# tests/unit/test_pricing.py
import pytest
from reservo.pricing import price_cents
from tests.asserts import assert_price
@pytest.mark.smoke
def test_basic_member_pays_full_rate(focus_room, ana):
assert price_cents(focus_room, ana, 3) == 7500
@pytest.mark.smoke
def test_pro_member_gets_discount(focus_room, bruno):
assert_price(focus_room, bruno, 3, 6000)
def test_zero_hours_costs_nothing(focus_room, ana):
assert price_cents(focus_room, ana, 0) == 0
# tests/unit/test_refund.py
import pytest
from tests.asserts import assert_refund
from tests.helpers import hours_before
@pytest.mark.smoke
def test_full_refund_72h_ahead(paid_booking, monday_9am):
assert_refund(paid_booking, hours_before(monday_9am, 72), 6000)
def test_half_refund_36h_ahead(paid_booking, monday_9am):
assert_refund(paid_booking, hours_before(monday_9am, 36), 3000)
def test_no_refund_12h_ahead(paid_booking, monday_9am):
assert_refund(paid_booking, hours_before(monday_9am, 12), 0)
# tests/unit/test_factory.py
from tests.factories import make_booking, make_room
def test_make_room_uses_focus_defaults():
room = make_room()
assert (room.id, room.hourly_cents) == ("focus", 2500)
def test_make_booking_gives_unique_ids():
ids = {make_booking().id for _ in range(3)}
assert len(ids) == 3
# tests/integration/test_booking_flow.py
import time
from datetime import timedelta
import pytest
from reservo.service import SlotTakenError
from tests.asserts import assert_charged
pytestmark = pytest.mark.integration
@pytest.mark.smoke
def test_booking_a_pro_charges_6000(booking_service, focus_room, bruno,
monday_9am, payments):
end = monday_9am + timedelta(hours=3)
booking = booking_service.book(focus_room, bruno, monday_9am, end)
assert booking.price_cents == 6000
assert booking.status == "confirmed"
assert_charged(payments, 6000, "m-bruno")
def test_room_unavailable_after_booking(booking_service, calendar, focus_room,
ana, monday_9am):
end = monday_9am + timedelta(hours=3)
booking_service.book(focus_room, ana, monday_9am, end)
assert calendar.is_available(focus_room.id, monday_9am, end) is False
@pytest.mark.slow
def test_double_booking_is_rejected(booking_service, focus_room, ana, bruno,
monday_9am):
time.sleep(0.2) # simula un flujo lento de integracion
end = monday_9am + timedelta(hours=3)
booking_service.book(focus_room, ana, monday_9am, end)
with pytest.raises(SlotTakenError):
booking_service.book(focus_room, bruno, monday_9am, end)
# tests/integration/test_cancel_flow.py
import time
from datetime import timedelta
import pytest
pytestmark = pytest.mark.integration
@pytest.mark.smoke
def test_cancel_refunds_in_full_and_frees_room(booking_service, calendar,
confirmed_booking, focus_room,
monday_9am, payments):
end = monday_9am + timedelta(hours=3)
refund = booking_service.cancel(confirmed_booking)
assert refund == 6000
assert (6000, "m-bruno") in payments.refunds
assert calendar.is_available(focus_room.id, monday_9am, end) is True
@pytest.mark.slow
def test_cancel_sends_notification(booking_service, confirmed_booking, emails):
time.sleep(0.2)
booking_service.cancel(confirmed_booking)
assert ("m-bruno", "booking cancelled") in emails.sent
# tests/integration/test_environment.py
import pytest
pytestmark = pytest.mark.integration
def test_settings_default_to_local(settings):
assert settings.env in ("local", "ci")
assert settings.db == ":memory:" # portatil: sin ruta de disco
def test_timeout_grows_in_ci(settings):
expected = 30 if settings.env == "ci" else 5
assert settings.timeout_seconds == expected
La prueba de que corre. Con pytest:
reservo framework: v1.0.0 | env: local | db: :memory: | timeout: 5s | strict: False
...
========================== reservo framework summary ===========================
env=local passed=15 failed=0 skipped=0
============================== 15 passed in 0.43s ==============================
Y con pytest -m smoke -q:
5 passed, 10 deselected in 0.01s
El arco de la guía: los ocho módulos
Con el proyecto entregado, mira el camino completo. Empezaste con una suite que era una pila de scripts y la convertiste en un framework. Este fue el arco:
- M1 — Del script suelto al framework. El porqué: una suite que crece es un sistema, no una pila de scripts. El impuesto del setup copy-paste, las cuatro capas, y el mapa de la guía.
- M2 — La arquitectura de fixtures. La columna vertebral: la fixture reutilizable, la jerarquía de
conftest.py, el scope como decisión, la composición y el grafo,yield, las factories yautouse. - M3 — Organizar la suite. La forma: carpetas por capa o por feature, un
conftest.pypor carpeta, el descubrimiento de tests, y la estructura como documentación viva. - M4 — Marcadores y configuración. El contrato: los marcadores personalizados, registrarlos,
--strict-markers, elpyproject.tomlcontestpathsyaddopts, y seleccionar subconjuntos con-m. - M5 — Utilidades y el harness. La biblioteca: helpers, aserciones personalizadas con
__tracebackhide__, el harness de setup/teardown, y DRY sin magia escondida. - M6 — Plugins: extender el framework. La extensión: el
conftest.pycomo plugin local, los hooks (pytest_addoption,pytest_report_header,pytest_terminal_summary), y cuándo un plugin y cuándo una fixture. - M7 — Datos y entornos. La portabilidad: la factory de datos, la config por entorno con
--env, la fixturesettings, y el framework portátil sin rutas absolutas. - M8 — Capstone. La síntesis: las seis capas tejidas en un framework real para Reservo, evaluado por arquitectura.
Cada módulo curó un dolor concreto de una suite que crece, y juntos son una habilidad: pasar de scripts de test sueltos a un framework mantenible y portátil. Esa es la promesa que el módulo 1 hizo, y que el proyecto que acabas de entregar cumple.
Hacia dónde seguir: las guías hermanas
Esta guía vive en un ecosistema, y respetó su frontera módulo a módulo enlazando hacia las hermanas. Ahora que la terminaste, aquí está el mapa de a dónde seguir según lo que quieras profundizar —cada una toma el testigo desde donde esta lo deja—:
testing-fundamentals-and-tdd— si sientes que la parte de escribir un test individual o el ciclo TDD te falta base. Esta guía asumió que sabes escribir un buen test; esa lo enseña. Es la que va antes de esta en el orden natural de aprendizaje.test-doubles-and-test-data— si quieres profundizar en la capa de datos que aquí tocamos en mínimo. Los builders serios (Object Mother, builders encadenados, datos aleatorios deterministas) y los dobles a fondo (fakes, stubs, mocks, spies) viven allá. Tufactories.pyfue la puerta; esa guía es la casa.test-failure-diagnosis— si quieres dominar el momento en que la suite se pone roja. Aquí construiste aserciones que fallan bien (con__tracebackhide__y mensajes de dominio); esa guía enseña a leer, aislar y diagnosticar un fallo —el arte de convertir un rojo en un arreglo—.testing-in-cicd— si quieres correr tu framework en un servidor. Aquí diseñaste la config por entorno (localvsci) como arquitectura; esa guía monta la infra —el YAML, los runners, las matrices, los reportes— que ejecuta esa config en cada push.test-strategy-and-quality-engineering— si quieres subir del framework a la estrategia. Aquí construiste la arquitectura concreta de un framework; esa guía decide, a nivel organización, cuánto invertir en cada tipo de test —la pirámide, las métricas, la política de calidad—. Es la vista de diez mil metros sobre lo que aquí construiste con las manos.
La regla del ecosistema sigue siendo la misma: cada guía es profunda en lo suyo en vez de superficial en todo, y respetar la frontera es lo que lo hace posible. Elige la siguiente según el dolor que más sientas ahora.
Resumen y cierre de la guía
En esta lección entregaste el capstone: un framework de tests pequeño pero completo para Reservo, con sus seis capas —la jerarquía de conftest.py, la estructura de carpetas, el pyproject.toml con marcadores, la biblioteca de aserciones, el plugin con hooks, y la factory con la config por entorno— y una suite que lo usa y corre limpia (15 passed). Viste la rúbrica que lo evalúa —seis dimensiones de arquitectura, cada una midiendo una decisión justificada y no la cantidad de tests— y la regla de oro: por cada pieza, poder decir en una frase por qué está donde está. Y contrastaste tu diseño con una solución de referencia verificada.
Y con esto cierras la guía. Empezaste, en el módulo 1, con una suite que era una pila de scripts y un dolor sin nombre —el setup copiado que un cambio de modelo rompía en doscientos lugares—. Terminas con un framework donde ese mismo cambio se absorbe en una línea, la suite se selecciona por subconjuntos, se anuncia y se resume, corre en cualquier máquina, y cada pieza tiene una razón de estar donde está. El carpintero que montó su taller ya no arma el espacio desde cero en cada proyecto: monta una vez, y construye sobre eso. Eso es lo que ganaste —no un conjunto de trucos de pytest, sino la capacidad de mirar una suite que crece y darle una arquitectura—.
Lo que sigue ya no es un módulo de esta guía: es la guía hermana que elijas, según el dolor que más sientas. Escribir mejores tests (fundamentos), datos y dobles a fondo (dobles), diagnosticar fallos (diagnóstico), correr en CI (CI/CD), o subir a la estrategia (estrategia). Tienes el framework; ahora tienes el mapa de a dónde llevarlo. Gracias por llegar hasta el final.
Recursos
- pytest — Full pytest documentation — la documentación completa del motor sobre el que construiste el framework entero; ahora la recorres con soltura, reconociendo fixtures, marcadores, hooks y config como piezas que sabes ensamblar. En inglés.
- pytest — Good Integration Practices — la referencia de estructura de proyecto (
tests/,conftest.py, config) que respalda el árbol de tu entrega; la lista de verificación para que tu framework sea portátil y descubrible. En inglés. - pytest — API Reference: hooks — el catálogo completo de hooks para cuando quieras extender tu framework más allá de los dos de esta guía; la puerta a plugins más ambiciosos, y el puente natural a
testing-in-cicd. En inglés.