Módulo 2: Tu primer test con Hypothesis

8. Mini-proyecto: tres propiedades de Reservo con `@given`

Descripción

Llegaste al final del módulo, y toca hacer lo que hace todo capstone: juntar las piezas sueltas en un flujo completo, con tus manos, de principio a fin. En las siete lecciones anteriores instalaste Hypothesis, abriste el decorador @given, aprendiste a describir dominios con las cuatro estrategias básicas y a leer el ejemplo falsificador. Ahora vas a usar todo eso junto para escribir tres propiedades reales de Reservo, correrlas en verde, y —el paso que sella el aprendizaje— provocar un rojo a propósito para leer su reporte con el ojo que ya tienes entrenado.

Al terminar vas a haber producido, tú mismo, un archivo de tests de propiedades que cubre tres invariantes distintos de Reservo, cada uno de un tipo que reaparecerá toda la guía: un invariante de rango (el reembolso siempre entre cero y lo pagado), una propiedad metamórfica (un socio pro nunca paga más que uno basic por lo mismo) y una de monotonía (cancelar antes nunca reembolsa menos que cancelar después). Vas a correr los tres en verde y confirmar, con las estadísticas, cuántos casos probó Hypothesis por cada uno. Y vas a escribir una cuarta propiedad, falsa a propósito, para verla caer y leer su ejemplo falsificador. La entrega es concreta: las tres propiedades, la corrida verde y el contraejemplo de la falsa.

Conexión con el módulo: esta es la síntesis. No introduce nada nuevo —usa exactamente lo de las lecciones 2 a 7— y por eso es la mejor prueba de que el módulo cuajó: si puedes escribir estas tres propiedades sin volver atrás, dominas la mecánica de Hypothesis. Es importante ver qué queda fuera, porque marca la frontera con lo que viene: aquí los objetos de Reservo (el Booking, el Room, los Member) siguen fijos como constantes, y solo generamos valores simples —montos, horas, fechas—. Construir esos objetos generándolos enteros, con @st.composite, es el corazón del módulo 3. Y elegir qué propiedad probar —el arte de encontrar invariantes— es el módulo 4; aquí las tres propiedades vienen dadas para que el foco esté en escribirlas y correrlas bien.

El encargo

Ponte en situación. El equipo de Reservo quiere empezar a proteger su lógica de dinero con property-based testing, y te toca escribir el primer archivo de propiedades sobre las dos funciones más críticas: refund_cents (el reembolso de una cancelación) y price_cents (el precio de una reserva). El encargo es concreto:

  1. Propiedad de rango del reembolso. El reembolso de cualquier cancelación cae siempre entre cero y lo que el socio pagó. Es la propiedad estrella del módulo.
  2. Propiedad metamórfica del precio. Para la misma sala y las mismas horas, un socio pro nunca paga más que un socio basic. (El descuento nunca sube el precio.)
  3. Propiedad de monotonía del reembolso. Cancelar con más anticipación nunca reembolsa menos que cancelar con menos anticipación. (El reembolso no baja al cancelar antes.)

Y, para practicar el diagnóstico, un cuarto paso:

  1. Un rojo a propósito. Escribe una propiedad falsa sobre el reembolso, córrela, y lee su ejemplo falsificador para confirmar que sabes distinguir "falló el código" de "falló la propiedad".

Vamos por partes, montando el terreno primero y construyendo cada propiedad con su razonamiento.

Paso 0: el terreno

Necesitas el entorno del módulo y el reservo.py de siempre. Si vienes de la lección 2, ya lo tienes; si empiezas limpio, este es el recordatorio:

python3 -m venv .venv
source .venv/bin/activate
pip install hypothesis pytest

Y el módulo de dominio, reservo.py, con las dos funciones que vamos a probar (lo incluyo completo para que corras el proyecto sin ir a buscarlo):

# reservo.py — el dominio, funciones puras de stdlib
from dataclasses import dataclass
from datetime import datetime


@dataclass
class Room:
    id: str
    name: str
    capacity: int
    hourly_cents: int


@dataclass
class Member:
    id: str
    name: str
    tier: str           # "basic" | "pro"


@dataclass
class Booking:
    id: str
    room_id: str
    member_id: str
    start: datetime
    end: datetime
    status: str
    price_cents: int


def price_cents(room, member, hours):
    """hourly_cents * hours, menos el descuento de tier. Centavos (int)."""
    base = room.hourly_cents * hours
    if member.tier == "pro":
        return base - base * 20 // 100
    return base


def refund_cents(booking, price_paid_cents, now):
    """Reembolso según la anticipación desde now hasta booking.start."""
    hours_until = (booking.start - now).total_seconds() / 3600
    if hours_until >= 48:
        return price_paid_cents
    if hours_until >= 24:
        return price_paid_cents * 50 // 100
    return 0

En el archivo de tests, arriba del todo, ponemos los objetos fijos que las propiedades comparten —el Booking de referencia, la sala Focus y los dos socios—:

# test_reservo_properties.py — cabecera con los objetos fijos
from datetime import datetime
from hypothesis import given, strategies as st
from reservo import Room, Member, Booking, price_cents, refund_cents

BOOKING = Booking(
    id="bk-1", room_id="r-focus", member_id="m-1",
    start=datetime(2026, 6, 1, 10, 0), end=datetime(2026, 6, 1, 13, 0),
    status="confirmed", price_cents=6000,
)
FOCUS = Room(id="r-focus", name="Focus", capacity=4, hourly_cents=2500)
BASIC = Member(id="m-b", name="Basic Ann", tier="basic")
PRO = Member(id="m-p", name="Pro Ben", tier="pro")

Paso 1: la propiedad de rango (invariante)

La primera ya la conoces de memoria, pero razonémosla una vez más como si la escribieras por primera vez. El invariante: el reembolso cae siempre entre cero y lo pagado, o sea 0 <= refund <= price_paid_cents. El dominio: el monto pagado es dinero, un entero no negativo (st.integers(min_value=0)); el now es cualquier instante (st.datetimes()), incluidas las cancelaciones tardías. El Booking queda fijo.

# Propiedad 1 (invariante de rango): el reembolso siempre en [0, lo pagado].
@given(price_paid_cents=st.integers(min_value=0), now=st.datetimes())
def test_refund_is_between_zero_and_paid(price_paid_cents, now):
    refund = refund_cents(BOOKING, price_paid_cents, now)
    assert 0 <= refund <= price_paid_cents

Cada pieza está donde debe: la charola de price_paid_cents acotada al dominio del dinero, la de now abierta a cualquier fecha, el cuerpo con el invariante. Es la propiedad más importante de Reservo porque protege la regla sagrada del dinero: nunca reembolsar de más (que costaría plata) ni de menos de cero (que sería absurdo).

Paso 2: la propiedad metamórfica (pro ≤ basic)

La segunda es de un tipo que verás mucho en el módulo 4: una propiedad metamórfica, que no afirma un valor de salida, sino una relación entre dos salidas del mismo código con entradas relacionadas. Aquí la relación es: para la misma sala y las mismas horas, el precio pro nunca supera al basic. No decimos cuánto paga cada uno —eso sería un ejemplo—; decimos que uno nunca es mayor que el otro, pase lo que pase con las horas.

El dominio: las horas de una reserva, un entero acotado a un rango razonable (st.integers(min_value=0, max_value=24)). La sala y los dos socios quedan fijos. El invariante compara las dos llamadas:

# Propiedad 2 (metamórfica): un pro nunca paga más que un basic por lo mismo.
@given(hours=st.integers(min_value=0, max_value=24))
def test_pro_never_pays_more_than_basic(hours):
    assert price_cents(FOCUS, PRO, hours) <= price_cents(FOCUS, BASIC, hours)

Fíjate en dos decisiones. Primera, usamos <= y no <: el pro paga menos o igual, no estrictamente menos, porque con hours=0 ambos pagan 0 y 0 <= 0 debe ser verdadero (si hubiéramos puesto <, la propiedad se caería en el borde, un error clásico que viste en los ejercicios de la lección 7). Segunda, el rango 0..24 describe horas legítimas; el borde 0 (una reserva de cero horas) entra a propósito, porque los bordes son donde se rompen las cosas, y aquí confirma que la relación aguanta incluso cuando el precio es cero.

Paso 3: la propiedad de monotonía (cancelar antes no reembolsa menos)

La tercera captura una intuición fuerte del negocio: cancelar con más anticipación nunca te da menos reembolso. Si cancelo tres días antes, no puedo recibir menos que si cancelo un día antes; la política premia la anticipación. Formalizarlo con Hypothesis tiene un truco elegante: generamos dos instantes de cancelación, los ordenamos para saber cuál es el temprano y cuál el tardío, y afirmamos que el reembolso del temprano es mayor o igual que el del tardío.

El dominio: el monto pagado (st.integers(min_value=0)) y dos fechas (st.datetimes() dos veces). El truco de ordenarlas con sorted convierte "dos fechas cualquiera" en "una temprana y una tardía":

# Propiedad 3 (monotonía): cancelar antes nunca reembolsa menos que cancelar después.
@given(
    price_paid_cents=st.integers(min_value=0),
    now_a=st.datetimes(), now_b=st.datetimes(),
)
def test_refund_is_monotonic_in_time(price_paid_cents, now_a, now_b):
    earlier, later = sorted([now_a, now_b])       # earlier <= later
    assert refund_cents(BOOKING, price_paid_cents, earlier) >= \
        refund_cents(BOOKING, price_paid_cents, later)

El sorted([now_a, now_b]) es la pieza clave: sin importar en qué orden las genere Hypothesis, earlier siempre queda con la fecha más temprana (más anticipación) y later con la más tardía (menos anticipación). Cancelar en earlier deja más horas hasta la reserva, así que su reembolso debe ser mayor o igual al de later. Esta propiedad relaciona dos llamadas a refund_cents con el mismo monto y distinta fecha, y afirma el orden entre sus resultados —otro patrón que el módulo 4 nombra y sistematiza—.

Ejecutar los tres: la corrida verde

Con las tres propiedades en el archivo, las corremos juntas. Añade -v para ver cada una por nombre:

python3 -m pytest test_reservo_properties.py -v

Qué esperar. Los tres en verde. Cada punto verde esconde, como sabes, muchos casos por dentro:

$ python3 -m pytest test_reservo_properties.py -v
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/ana/reservo
plugins: hypothesis-6.161.2
collected 3 items

test_reservo_properties.py::test_refund_is_between_zero_and_paid PASSED   [ 33%]
test_reservo_properties.py::test_pro_never_pays_more_than_basic PASSED    [ 66%]
test_reservo_properties.py::test_refund_is_monotonic_in_time PASSED       [100%]

============================== 3 passed in 0.18s ==============================

Tres propiedades, tres invariantes distintos de Reservo, todos verdes. Ahora confirmemos cuántos casos corrió cada uno con las estadísticas, porque hay un detalle instructivo:

python3 -m pytest test_reservo_properties.py --hypothesis-show-statistics

Qué esperar. En la sección Hypothesis Statistics, los conteos por propiedad:

  test_refund_is_between_zero_and_paid:
    - 100 passing, 0 failing, and 0 invalid test cases
    - Stopped because settings.max_examples=100

  test_pro_never_pays_more_than_basic:
    - 25 passing, 0 failing, and 0 invalid test cases

  test_refund_is_monotonic_in_time:
    - 100 passing, 0 failing, and 0 invalid test cases
    - Stopped because settings.max_examples=100

Mira la del medio: 25 passing, no cien. La propiedad metamórfica genera hours en el rango 0..24, o sea 25 valores posibles, y ni uno más; Hypothesis los prueba todos y se detiene porque agotó el espacio (prueba exhaustiva, como viste en la lección 4). Las otras dos, con montos y fechas de espacios enormes, muestrean los cien casos por defecto. Un mismo archivo, dos regímenes: exhaustivo donde el espacio es pequeño, muestreado donde es grande. Que sepas leer esa diferencia es señal de que el módulo cuajó.

Paso 4: un rojo a propósito, para leer su ejemplo falsificador

El último paso es el que prueba que sabes diagnosticar, no solo escribir. Añadimos una cuarta propiedad falsa a propósito: afirmamos que cancelar siempre reembolsa todo lo pagado. Sabemos que es falsa —una cancelación tardía reembolsa menos o nada—, y la escribimos justo para ver su reporte:

# Propiedad FALSA a propósito: "cancelar siempre reembolsa todo".
@given(price_paid_cents=st.integers(min_value=0), now=st.datetimes())
def test_refund_always_returns_everything(price_paid_cents, now):
    refund = refund_cents(BOOKING, price_paid_cents, now)
    assert refund == price_paid_cents      # FALSO

Qué esperar. Rojo, con el ejemplo falsificador reducido a su forma mínima:

$ python3 -m pytest test_reservo_properties.py::test_refund_always_returns_everything
=================================== FAILURES ===================================
_________________ test_refund_always_returns_everything _________________

price_paid_cents = 1, now = datetime.datetime(2027, 1, 1, 0, 0)

    @given(price_paid_cents=st.integers(min_value=0), now=st.datetimes())
    def test_refund_always_returns_everything(price_paid_cents, now):
        refund = refund_cents(BOOKING, price_paid_cents, now)
>       assert refund == price_paid_cents
E       assert 0 == 1
E       Failing test case: test_refund_always_returns_everything(
E           price_paid_cents=1,
E           now=datetime.datetime(2027, 1, 1, 0, 0),
E       )

Léelo con el ojo de la lección 7. Valores reducidos: price_paid_cents=1 (el monto positivo más pequeño), now=2027 (posterior a la reserva, una cancelación tardía). El assert 0 == 1: se obtuvo refund=0 y se esperaba 1. Diagnóstico: la rota es la propiedad, no el código. refund_cents hizo lo correcto —una cancelación tardía reembolsa cero, que es la política sana—; nuestra afirmación "siempre reembolsa todo" era falsa. El contraejemplo no destapó un bug de Reservo; destapó una descripción equivocada de Reservo. Esa distinción —código correcto, propiedad falsa— es exactamente lo que el módulo te enseñó a ver, y por eso es el cierre perfecto.

La entrega

Tu entrega del mini-proyecto tiene tres partes, y conviene que las reconozcas como el patrón que repetirás en los capstones de toda la guía:

  1. Las tres propiedades (rango, metamórfica, monotonía) en un archivo test_reservo_properties.py, cada una con su @given, sus estrategias acotadas al dominio y su invariante. Son código que se queda en la suite y protege a Reservo de aquí en adelante.
  2. La corrida verde: la salida de python3 -m pytest -v con los 3 passed, y las estadísticas que muestran los 100 / 25 / 100 casos. Es la evidencia de que las propiedades se cumplen sobre cientos de entradas, no sobre las que se te ocurrieron.
  3. El contraejemplo de la propiedad falsa: el Failing test case: con price_paid_cents=1, now=2027 y el assert 0 == 1, más tu diagnóstico en una línea de que la rota fue la propiedad, no el código. Es la prueba de que sabes leer un rojo.

Con eso cierras el módulo habiendo hecho el recorrido entero: instalar, escribir, describir dominios, correr, y leer el resultado —verde y rojo—. No memorizaste una API; aprendiste un flujo de trabajo.

Errores comunes

Poner < donde va <= en la propiedad metamórfica. Qué pasa: escribes price_cents(FOCUS, PRO, hours) < price_cents(FOCUS, BASIC, hours) (estrictamente menor) y la propiedad se cae con hours=0, donde ambos pagan 0. Por qué pasa: uno piensa "el pro paga menos" y traduce a <, olvidando el borde donde pagan igual. Cómo detectarlo: el contraejemplo es hours=0 con assert 0 < 0. Cómo corregirlo: la relación verdadera es "nunca paga más", o sea <=; el pro puede pagar igual (cuando el precio base es 0). Las propiedades de orden casi siempre son <= o >=, no estrictas, justo por los bordes.

Olvidar ordenar las dos fechas en la propiedad de monotonía. Qué pasa: generas now_a y now_b y afirmas refund(now_a) >= refund(now_b) sin ordenarlas, y la propiedad se cae cuando Hypothesis genera now_a más tardía que now_b. Por qué pasa: asumes un orden que la charola no garantiza —st.datetimes() produce las dos fechas independientemente, en cualquier orden—. Cómo detectarlo: el contraejemplo tiene now_a posterior a now_b. Cómo corregirlo: ordena con sorted([now_a, now_b]) para nombrar explícitamente la temprana y la tardía; la monotonía habla de "antes contra después", y hay que construir ese "antes" y ese "después", no suponerlos.

Generar el Booking en vez de dejarlo fijo (adelantarse al módulo 3). Qué pasa: con el entusiasmo, alguien intenta generar también el Booking con estrategias, se enreda construyendo fechas de start y end coherentes, y el ejercicio se vuelve un lío que no toca aún. Por qué pasa: es la evolución natural, pero es material del módulo 3. Cómo detectarlo: si te encuentras metiendo st.datetimes() dentro de un Booking(...) dentro del cuerpo, te adelantaste. Cómo corregirlo: en este módulo el Booking, el Room y los Member son constantes fijas; solo generas los valores simples (monto, horas, fechas). Construir objetos de dominio generados —con @st.composite— es justo lo que abre el módulo 3.

Ejercicios

Ejercicio 1 — Escribe el proyecto entero. Reúne los cuatro pasos en un solo test_reservo_properties.py: las tres propiedades verdes más la falsa. Córrelo con python3 -m pytest test_reservo_properties.py -v y confirma 3 passed y 1 failed (la falsa). Luego corre solo las tres verdaderas con --hypothesis-show-statistics y anota los tres conteos. ¿Cuál dio menos de cien, y por qué?

Ver solución

El archivo junta la cabecera (los objetos fijos), las tres propiedades verdaderas (rango, metamórfica, monotonía) y la falsa. Al correr las cuatro, ves 3 passed, 1 failed: la falsa (test_refund_always_returns_everything) cae con price_paid_cents=1, now=2027, assert 0 == 1.

Las estadísticas de las tres verdaderas: test_refund_is_between_zero_and_paid100 (max_examples), test_pro_never_pays_more_than_basic25 (nothing left to do), test_refund_is_monotonic_in_time100 (max_examples). La que dio menos de cien es la metamórfica, porque su única estrategia, st.integers(min_value=0, max_value=24), tiene solo 25 valores posibles: Hypothesis los agota todos y no necesita muestrear cien. Espacio pequeño ⇒ prueba exhaustiva; espacio grande ⇒ muestreo de cien.

Ejercicio 2 — Una cuarta propiedad verdadera: pagado 0 ⇒ reembolso 0. Escribe y corre una propiedad más, verdadera: si el socio no pagó nada, el reembolso es siempre cero, sin importar cuándo cancele. Elige la charola correcta para el now y confirma el verde. ¿Por qué es verdadera para todo now?

Ver solución
@given(now=st.datetimes())
def test_zero_paid_gives_zero_refund(now):
    assert refund_cents(BOOKING, 0, now) == 0

Da 1 passed. El monto pagado es fijo en 0 (es la hipótesis de la propiedad: "si no pagó nada"), y now se genera libre con st.datetimes(). Es verdadera para toda fecha porque, mires el tramo que mires, el reembolso de 0 es 0: en el tramo completo devuelve price_paid_cents, que es 0; en el del 50% devuelve 0 * 50 // 100 = 0; en el cero, 0. No hay now que rompa esto, porque cero por cualquier fracción es cero. Es un invariante simple pero útil: documenta que no se reembolsa dinero que no se pagó, y complementa el invariante de rango (que solo garantizaba 0 <= refund, sin fijar el valor cuando el pago es cero).

Ejercicio 3 — Convierte la falsa en verdadera bajo condición. La propiedad "cancelar siempre reembolsa todo" es falsa como regla universal. Pero es verdadera bajo una condición: cuando se cancela con 48 horas o más de anticipación. Sin usar herramientas del módulo 3 (nada de assume), ¿cómo escribirías una propiedad que afirme el reembolso completo solo en ese caso? Pista: fija un now con 48+ horas de anticipación y genera solo el monto.

Ver solución

La clave es no generar el now libre —eso traería cancelaciones tardías que rompen la regla—, sino fijarlo en un instante con 48 o más horas de anticipación, y generar solo el monto:

from datetime import timedelta

FULL_REFUND_NOW = BOOKING.start - timedelta(hours=72)   # 72 h antes: tramo completo

@given(price_paid_cents=st.integers(min_value=0))
def test_full_refund_when_cancelled_early(price_paid_cents):
    assert refund_cents(BOOKING, price_paid_cents, FULL_REFUND_NOW) == price_paid_cents

Da 1 passed. Al fijar now a 72 horas antes del inicio, siempre caemos en el tramo hours_until >= 48, donde refund_cents devuelve todo lo pagado; así, para cualquier monto generado, el reembolso es exactamente price_paid_cents. Convertimos una afirmación falsa ("siempre todo") en una verdadera acotando el dominio del now a la condición donde la regla se cumple. Nota la diferencia con la propiedad de rango: aquí sí fijamos un valor de salida exacto (== price_paid_cents), lo que la acerca a un ejemplo; es legítima porque la condición (48+ horas) la hace verdadera para todo monto. En el módulo 3 verás una forma más flexible de expresar "solo los now con 48+ horas" generando fechas y descartando las que no cumplen, con assume.

Resumen y siguiente paso

Lo que lograste en este mini-proyecto, y con él en el módulo entero:

  • Escribiste tres propiedades reales de Reservo, cada una de un tipo que reaparecerá toda la guía: invariante de rango (0 <= refund <= paid), metamórfica (pro <= basic, con <= por el borde del cero) y monotonía (cancelar antes no reembolsa menos, con las dos fechas ordenadas por sorted).
  • Las corriste en verde (3 passed) y leíste sus estadísticas: 100 / 25 / 100 casos, donde el 25 de la metamórfica reveló una prueba exhaustiva del espacio pequeño frente al muestreo de cien de los espacios grandes.
  • Provocaste un rojo a propósito con una propiedad falsa y leíste su ejemplo falsificador (price_paid_cents=1, now=2027, assert 0 == 1), diagnosticando que la rota era la propiedad, no el código —el reflejo que separa usar Hypothesis de entenderlo—.
  • Mantuviste los objetos de Reservo fijos y generaste solo valores simples, respetando la frontera con el módulo 3.

Ese flujo —instalar, escribir con @given, describir dominios con estrategias, correr, y leer verde y rojo— es la mecánica completa de Hypothesis, y ya es tuya. A partir de aquí, la guía deja de enseñarte cómo funciona la herramienta y empieza a enseñarte a sacarle jugo.

El siguiente módulo, el 3, ataca la limitación que arrastraste todo este: los objetos fijos. Vas a aprender a generar Room, Member y Booking completos y válidos con estrategias compuestas —st.builds y el decorador @st.composite—, para que Hypothesis no solo varíe el monto y la fecha, sino la reserva entera: distintas salas, distintos tiers, distintos rangos de horas coherentes. Es el salto de "probar una función con un objeto de muestra" a "probar una función contra todo el universo de objetos que tu dominio permite". Con eso, tus propiedades pasan de fuertes a temibles. Sigamos.

Recursos