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 , 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:

  1. La jerarquía de conftest.py (M2): un conftest.py en la raíz con los colaboradores (calendar, clock, payments, emails, repo) y la fixture compuesta booking_service; un tests/conftest.py con los datos de dominio; y al menos un conftest.py por 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.
  2. 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.
  3. El pyproject.toml (M4): con testpaths, pythonpath, addopts (incluyendo --strict-markers), y los marcadores personalizados registrados (smoke, slow, integration), cada uno con su descripción.
  4. La biblioteca de helpers y aserciones (M5): tests/helpers.py y tests/asserts.py como módulos importables, con al menos una aserción personalizada que use __tracebackhide__ y un mensaje de dominio.
  5. El plugin local con hooks (M6): en el conftest.py, pytest_report_header (el banner del framework) y pytest_terminal_summary (el hook de resumen que imprime el conteo al cierre).
  6. La factory de datos y la config por entorno (M7): tests/factories.py con make_room, make_member, make_booking; y la config por entorno con la opción --env, el diccionario de entornos y la fixture settings, 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 un assert crudo).
  • Use la factory (ningún test construye un Booking con sus siete campos a mano).
  • Lleve marcadores donde corresponda (smoke en el camino crítico, slow/integration donde apliquen).
  • Corra limpia: pytest da todo verde, pytest -m smoke selecciona 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 en tests/conftest.py, y confirmed_booking (solo integración) en tests/integration/conftest.py. Ponerlas todas en la raíz viola el ancestro común: confirmed_booking queda visible para los tests unitarios que no la usan. Solo booking_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: unit duplica la carpeta tests/unit/ (y not integration) sin agregar combinabilidad, y fast es el opuesto exacto de slow (ya expresable con not 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 de assert_refund apunta al raise dentro de tests/asserts.py en 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_service es infraestructura que usan las dos capas (unit e integration), así que su ancestro común es la raíz; confirmed_booking la usa solo la integración, así que su ancestro común es tests/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 marcador integration da 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 porque smoke no 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_room es make_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, agregando email="ana@reservo.test" (o similar) al defaults de make_member. (Del lado de producción, además, se toca el modelo y cualquier código de reservo/ que construya Member —pero eso es el sistema, no el framework—.)
  • (b) Fallarían los tests que fabrican un Member a través de la factory —los que piden fixtures ana o bruno, que son make_member(...)—, y todos apuntarían al mismo lugar: la línea de make_member en tests/factories.py, con TypeError: 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 Member a 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 y autouse.
  • M3 — Organizar la suite. La forma: carpetas por capa o por feature, un conftest.py por 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, el pyproject.toml con testpaths y addopts, 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.py como 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 fixture settings, 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á. Tu factories.py fue 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 (local vs ci) 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.