Módulo 4: Encontrar propiedades — los patrones

1. Presentación del módulo: la parte difícil es encontrar la propiedad

Descripción

Llegaste hasta aquí con toda la mecánica en la mano. Sabes envolver una función con @given, describir de dónde salen las entradas con estrategias básicas —st.integers, st.floats, st.datetimes— y construir objetos completos de Reservo con estrategias compuestas (@st.composite, st.builds). Corres python3 -m pytest, ves la línea verde y sabes que detrás de ese punto corrieron cien casos. La herramienta ya no te intimida. Y sin embargo, si te sientas frente a una función que nunca probaste con property-based, aparece la pregunta que este módulo existe para responder: ¿qué propiedad escribo?

Esa pregunta es, de lejos, la parte más difícil de todo el property-based testing. La sintaxis de @given se aprende en una tarde. Encontrar el invariante correcto —la afirmación que vale para toda entrada y que además dice algo— es un oficio que toma más tiempo y que a casi todo el mundo lo deja en blanco la primera vez. No es que falte imaginación; es que nadie te dio un método. Miras refund_cents y piensas "bueno... ¿qué es siempre cierto de un reembolso?", y te quedas ahí, con la mente en blanco, porque estás acostumbrado a pensar en ejemplos ("a 72 horas devuelve 6000"), no en reglas que valen para todos.

Este módulo te da el método. No vas a aprender más sintaxis —ya la tienes toda—; vas a aprender a ver. Concretamente, vas a conocer un catálogo de cinco patrones que se repiten en casi todo el software y que responden, uno por uno, a "¿de dónde saco una propiedad?": el invariante (algo de la salida siempre es cierto), el round-trip (una operación y su inversa te dejan donde empezaste), el oráculo (la función coincide con una versión más simple y correcta), la metamórfica (sabes cómo cambia la salida al cambiar la entrada, aunque no sepas el valor) y la idempotencia (aplicar dos veces es igual que aplicar una). Cinco moldes. Cuando los tengas en la cabeza, mirar una función deja de ser un vacío y se vuelve un recorrido por cinco preguntas.

Conexión con el módulo: esta es la lección-mapa. Aquí ves los cinco patrones de un vistazo, ejecutados sobre Reservo, para tener la foto completa antes de entrar en cada uno. La lección 2 se ocupa de las preguntas que disparan cada patrón —la técnica para no quedarte en blanco—. Las lecciones 3 a 7 toman un patrón cada una y lo exprimen: el invariante, el round-trip, el oráculo, la metamórfica y la idempotencia, cada uno con una función de Reservo en verde y con su ejemplo falsificador cuando le metemos un bug. La lección 8 te pone a elegir: tres funciones, y para cada una decides el patrón y escribes la propiedad. Todo sigue apoyado en las funciones puras del dominio, porque una función pura es el terreno donde los patrones se ven con más claridad.

Una analogía: el catálogo de moldes del carpintero

Imagina a alguien que acaba de aprender a usar todas las herramientas de un taller de carpintería. Sabe manejar la sierra, el cepillo, el formón, la lijadora. Técnicamente no le falta nada. Le pones enfrente una tabla y le dices "haz una unión", y se queda paralizado: sabe cómo cortar, pero no sabe qué corte hacer para unir dos piezas. La herramienta la domina; el diseño de la unión, no.

Un carpintero con oficio no improvisa cada unión desde cero. Tiene en la cabeza un catálogo de uniones estándar: la de caja y espiga, la de cola de milano, la de media madera, la de inglete. Frente a dos tablas, no piensa "¿qué invento?"; piensa "¿cuál de mis cinco o seis uniones conocidas encaja aquí?". El catálogo convierte un problema abierto y angustiante —"diseña una unión"— en una elección entre opciones conocidas —"¿cola de milano o caja y espiga?"—. Eso es lo que separa al que sabe usar las herramientas del que sabe hacer muebles.

Los cinco patrones de este módulo son ese catálogo de uniones, pero para propiedades. Tú ya sabes usar la herramienta (@given y las estrategias son tu sierra y tu cepillo). Lo que te falta es el catálogo que convierte "¿qué propiedad invento para esta función?" —un vacío— en "¿cuál de mis cinco patrones encaja aquí?" —una elección—. Y como el carpintero, con el tiempo vas a mirar una función y a ver el patrón casi de inmediato, sin recorrer la lista conscientemente. Ese es el objetivo del módulo: que los cinco moldes se te vuelvan instinto.

Los cinco patrones de un vistazo

Antes de dedicarle una lección a cada uno, conviene tener la foto completa. Aquí están los cinco, cada uno con la pregunta que lo dispara y la función de Reservo donde lo veremos a fondo. No memorices; deja que la tabla te dé el mapa.

PatrónLa pregunta que lo disparaEjemplo en Reservo
Invariante¿Qué es siempre cierto de la salida, pase lo que pase con la entrada?0 <= refund_cents <= pagado; price_cents >= 0
Round-trip¿Hay una operación inversa que me devuelva al punto de partida?book y luego cancel deja el calendario sin esa reserva
Oráculo¿Hay una forma más simple, lenta u obvia de calcular lo mismo?overlaps coincide con max(inicio) < min(fin)
MetamórficaSi cambio la entrada así, ¿cómo debe cambiar la salida?price_cents(pro) <= price_cents(basic); más horas ⇒ precio ≥
Idempotencia¿Aplicar la operación dos veces da lo mismo que aplicarla una?cancel idempotente; clamp que recorta al rango

Fíjate en algo que vamos a repetir todo el módulo: una misma función suele tener varios patrones a la vez. refund_cents tiene un invariante de rango y una relación metamórfica (cancelar antes nunca reembolsa menos). price_cents tiene un invariante de signo y dos metamórficas. No hay una correspondencia de uno a uno entre función y patrón; el catálogo te da varias formas de atacar cada función, y las buenas suites combinan varias porque cada una cierra un hueco que las otras dejan abierto. Encontrar propiedades no es hallar la propiedad; es recorrer los cinco moldes y quedarte con los que encajan.

Ejemplo trabajado: los cinco patrones, ejecutados

Nada convence más que verlos correr. Preparé un archivo con una propiedad de cada patrón sobre Reservo, todas contra el código correcto, y lo ejecuté de verdad. No lo escribas todavía —cada lección lo construye con calma—; míralo como la foto de a dónde vamos. El paquete reservo es el mismo de siempre: models.py (con Room, Member, Booking), pricing.py (price_cents), refunds.py (refund_cents), calendar.py (Calendar) y schedule.py (overlaps, is_available, book, cancel).

# test_five_patterns.py — una propiedad de cada patrón, contra el codigo correcto
from datetime import datetime, timedelta
from hypothesis import given, strategies as st
from reservo import (Booking, Room, Member, Calendar,
                     refund_cents, price_cents, overlaps, book, cancel)

START = datetime(2026, 3, 10, 12, 0)
BOOKING = Booking("bk-1", "r-focus", "m-1", START,
                  START + timedelta(hours=2), "confirmed", 6000)
FOCUS = Room("r-focus", "Focus", 4, 2500)
BASIC = Member("m-1", "Ana", "basic")
PRO = Member("m-2", "Beto", "pro")


# 1) INVARIANTE: el reembolso siempre cae en [0, lo pagado]
@given(paid=st.integers(min_value=0, max_value=10_000_000),
       now=st.datetimes(min_value=datetime(2020, 1, 1), max_value=datetime(2030, 1, 1)))
def test_invariant_refund_in_range(paid, now):
    assert 0 <= refund_cents(BOOKING, paid, now) <= paid


# 2) ROUND-TRIP: reservar y luego cancelar deja la sala vacia
@given(minutes=st.integers(min_value=1, max_value=480),
       price=st.integers(min_value=0, max_value=1_000_000))
def test_round_trip_book_then_cancel(minutes, price):
    calendar = Calendar()
    book(calendar, "bk-1", "r-focus", "m-1", START, START + timedelta(minutes=minutes),
         price_cents=price)
    cancel(calendar, "bk-1", START - timedelta(hours=72))
    assert calendar.confirmed_for_room("r-focus") == []


# 3) ORACULO: overlaps coincide con la formula max(inicio) < min(fin)
@given(a=st.integers(0, 48), la=st.integers(1, 12), b=st.integers(0, 48), lb=st.integers(1, 12))
def test_oracle_overlaps(a, la, b, lb):
    base = datetime(2026, 3, 10)
    a0, a1 = base + timedelta(hours=a), base + timedelta(hours=a + la)
    b0, b1 = base + timedelta(hours=b), base + timedelta(hours=b + lb)
    assert overlaps(a0, a1, b0, b1) == (max(a0, b0) < min(a1, b1))


# 4) METAMORFICA: pro nunca paga mas que basic por lo mismo
@given(hours=st.integers(min_value=0, max_value=24))
def test_metamorphic_pro_le_basic(hours):
    assert price_cents(FOCUS, PRO, hours) <= price_cents(FOCUS, BASIC, hours)


# 5) IDEMPOTENCIA: recortar al rango dos veces = recortar una
def clamp(cents, paid):
    return min(max(cents, 0), paid)


@given(cents=st.integers(-10_000, 10_000), paid=st.integers(0, 10_000))
def test_idempotence_clamp(cents, paid):
    assert clamp(clamp(cents, paid), paid) == clamp(cents, paid)

Qué esperar. Cinco propiedades, una por patrón, todas contra el código correcto. Guardas el archivo, corres python3 -m pytest test_five_patterns.py -v, y ves cinco puntos verdes —y detrás de cada uno, cien casos generados que no tuviste que pensar:

$ python3 -m pytest test_five_patterns.py -v
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
rootdir: /private/tmp/m4work
plugins: hypothesis-6.161.2
collected 5 items

test_five_patterns.py::test_invariant_refund_in_range PASSED             [ 20%]
test_five_patterns.py::test_round_trip_book_then_cancel PASSED           [ 40%]
test_five_patterns.py::test_oracle_overlaps PASSED                       [ 60%]
test_five_patterns.py::test_metamorphic_pro_le_basic PASSED              [ 80%]
test_five_patterns.py::test_idempotence_clamp PASSED                     [100%]

============================== 5 passed in 0.51s ===============================

Léelo despacio. Son cinco funciones de test, sí, pero ninguna de las cinco menciona un valor de salida concreto. No hay ningún == 6000 ni == 3000 por ningún lado. Cada una afirma una relación —un rango, una vuelta al origen, una coincidencia con otra fórmula, un orden entre dos precios, una igualdad al repetir— que vale para toda entrada. Eso es lo que distingue una propiedad de un ejemplo, y eso es lo que los cinco patrones te enseñan a fabricar. En este módulo desarmamos cada uno de esos cinco tests, entendemos por qué funciona, y —tan importante— vemos qué ejemplo falsificador reporta Hypothesis cuando el código tiene un bug de verdad.

Por qué cinco patrones y no una receta única

Podrías preguntar por qué necesitas cinco moldes y no una sola regla mágica para encontrar propiedades. La respuesta es que las funciones son distintas entre sí, y cada patrón aprovecha una estructura diferente que la función puede tener o no.

El invariante aprovecha que la salida vive en un rango o cumple una condición fija: sirve cuando puedes decir algo cierto de la salida sin mirar la entrada. El round-trip aprovecha que existe una operación inversa: solo aplica cuando hay un "deshacer" (cancelar deshace reservar, decodificar deshace codificar). El oráculo aprovecha que existe otra forma de calcular lo mismo, más simple o más lenta: solo aplica cuando tienes esa segunda implementación a mano. La metamórfica aprovecha que sabes cómo debe cambiar la salida al tocar la entrada, aunque no sepas el valor: sirve cuando el valor exacto es difícil pero la dirección del cambio es obvia. La idempotencia aprovecha que repetir la operación no debería cambiar nada: solo aplica a operaciones que "asientan" un estado.

Ninguna función tiene los cinco, y algunas tienen solo uno. Pero entre los cinco, es rarísima la función pura que no encaje en al menos uno. Por eso el catálogo funciona: no promete que siempre uses el mismo molde, sino que uno de los cinco casi siempre sirve. Cuando te trabes frente a una función nueva, el reflejo entrenado será recorrer las cinco preguntas de la tabla —¿qué es siempre cierto?, ¿hay inversa?, ¿hay otra forma de calcularlo?, ¿cómo cambia al cambiar la entrada?, ¿qué pasa si repito?— y en alguna se va a encender la luz.

Errores comunes

Buscar la propiedad, en singular, como si cada función tuviera exactamente una. Qué pasa: alguien encuentra el invariante de rango de refund_cents, lo escribe, ve verde y da la función por cubierta. Por qué pasa: la palabra "la propiedad" suena a que hay una sola respuesta correcta. Cómo detectarlo: si tu suite tiene exactamente una propiedad por función, casi seguro estás dejando patrones sin explotar. Cómo corregirlo: recuerda que refund_cents tiene al menos un invariante y una metamórfica, y price_cents tiene un invariante y dos metamórficas. Recorre los cinco moldes por cada función; quédate con todos los que encajen, no con el primero.

Confundir "sé cuál es la mecánica" con "sé qué probar". Qué pasa: dominas @given, escribes estrategias preciosas, y aun así te quedas en blanco ante una función nueva. Por qué pasa: la mecánica (M2 y M3) y el diseño de la propiedad (este módulo) son habilidades distintas, y es fácil creer que la primera implica la segunda. Cómo detectarlo: si te descubres pensando "ya sé usar Hypothesis, pero no se me ocurre qué afirmar", tienes exactamente el hueco que este módulo llena. Cómo corregirlo: separa las dos cosas en tu cabeza. Primero eliges el patrón (qué afirmar); solo después escribes el @given y el assert (cómo afirmarlo). El catálogo es para la primera decisión.

Saltar a Hypothesis sin haber decidido el patrón. Qué pasa: alguien abre el editor y empieza a escribir @given(...) antes de tener claro qué invariante busca, y termina con una propiedad confusa que o siempre pasa (trivial) o mezcla dos ideas. Por qué pasa: la herramienta invita a teclear. Cómo detectarlo: si tu assert tiene tres condiciones pegadas con and que no sabrías nombrar por separado, probablemente arrancaste sin elegir patrón. Cómo corregirlo: antes de tocar el teclado, di en voz alta cuál de los cinco patrones vas a usar y enuncia la propiedad en español ("cancelar antes nunca reembolsa menos"). Recién entonces la traduces a @given. Nombrar el patrón primero es la diferencia entre una propiedad limpia y un enredo.

Ejercicios

Ejercicio 1

Para cada una de estas cinco afirmaciones sobre Reservo, di a cuál de los cinco patrones —invariante, round-trip, oráculo, metamórfica, idempotencia— corresponde. No la programes; solo clasifícala.

  1. "El precio de una reserva nunca es negativo."
  2. "Reservar una sala y luego cancelar la reserva deja el calendario igual que al principio."
  3. "overlaps da el mismo resultado que comparar max(inicio) < min(fin)."
  4. "Aumentar las horas de una reserva nunca baja su precio."
  5. "Cancelar una reserva ya cancelada (en el diseño idempotente) deja el mismo estado que cancelarla una vez."
Ver solución
  1. Invariante. Afirma algo que la salida cumple siempre (price >= 0), sin relacionarla con otra entrada ni con otra forma de cálculo.
  2. Round-trip. Hay una operación (book) y su inversa (cancel); la propiedad dice que ida y vuelta te dejan donde empezaste.
  3. Oráculo. Compara la función real contra otra forma de calcular lo mismo (max(inicio) < min(fin)), más simple y obviamente correcta.
  4. Metamórfica. No menciona un valor concreto; dice cómo debe cambiar la salida (no bajar) al cambiar la entrada (más horas). Relaciona dos salidas de entradas relacionadas.
  5. Idempotencia. Aplicar cancel dos veces da el mismo estado que aplicarla una: f(f(x)) == f(x).

Fíjate en que ninguna de las cinco menciona un valor de salida exacto. Todas afirman relaciones. Ese es el sello del property-based, y por eso los cinco patrones producen propiedades y no ejemplos disfrazados.

Ejercicio 2

La función refund_cents aparece en el ejemplo trabajado con el patrón invariante (0 <= refund <= pagado). Pero antes vimos que casi ninguna función tiene un solo patrón. Sin programar, propón una propiedad metamórfica de refund_cents: una relación entre dos salidas cuando cambias la entrada de forma controlada. Pista: piensa en dos instantes de cancelación, uno más temprano que el otro.

Ver solución

La metamórfica natural de refund_cents es la monotonía en el tiempo: cancelar antes nunca reembolsa menos que cancelar después. Formalmente, para el mismo booking y el mismo price_paid_cents, si now_early es anterior a now_late (cancelas con más anticipación), entonces refund_cents(booking, paid, now_early) >= refund_cents(booking, paid, now_late).

No menciona ningún valor concreto: no dice "a 72 horas reembolsa 6000", dice "más anticipación ⇒ reembolso ≥". Es exactamente el molde metamórfico —cómo cambia la salida al mover la entrada en una dirección conocida— y la verás ejecutada en la lección 6. Que refund_cents tenga a la vez un invariante (lección 3) y una metamórfica (lección 6) es la prueba viva de que una función suele tener varios patrones.

Ejercicio 3

Un compañero te dice: "ya domino @given y las estrategias compuestas, así que ya sé property-based testing; este módulo de 'patrones' me lo puedo saltar". ¿Qué le responderías? Da un argumento concreto, apoyado en la distinción entre mecánica y diseño de la propiedad.

Ver solución

Le diría que domina la mecánica pero no necesariamente el diseño de la propiedad, y que son dos habilidades distintas. Saber escribir @given(now=st.datetimes()) es como saber usar la sierra; no te dice qué corte hacer. La prueba: ponle enfrente una función que no haya probado nunca —digamos is_available— y pídele la propiedad. Si domina de verdad, la tendrá en segundos; si se queda pensando "¿qué afirmo...?", ahí está el hueco que este módulo llena.

El argumento concreto: la mecánica (M2/M3) responde cómo se escribe una propiedad; los patrones (M4) responden qué propiedad escribir, que es la parte que deja en blanco a casi todo el mundo. Sin el catálogo de cinco moldes, frente a cada función nueva se improvisa desde cero y muchas veces se termina con una propiedad trivial (siempre pasa) o con un ejemplo disfrazado. Con el catálogo, encontrar la propiedad deja de ser un vacío y se vuelve una elección entre opciones conocidas. Saltárselo es quedarse con la herramienta y sin el oficio.

Resumen y siguiente paso

En esta lección viste que ya tienes toda la mecánica del property-based —@given, estrategias básicas y compuestas— y que lo que falta es lo verdaderamente difícil: encontrar la propiedad, decidir qué afirmar frente a una función nueva. Ese es el oficio que este módulo enseña, y lo hace con un catálogo de cinco patrones que convierten "¿qué invento?" en "¿cuál de mis cinco moldes encaja?", igual que el carpintero elige entre sus uniones estándar en vez de improvisar cada una.

Conociste los cinco de un vistazo, cada uno con su pregunta disparadora y su función de Reservo: invariante (¿qué es siempre cierto de la salida?), round-trip (¿hay una inversa?), oráculo (¿hay otra forma de calcularlo?), metamórfica (¿cómo cambia la salida al cambiar la entrada?) e idempotencia (¿repetir cambia algo?). Los viste correr, los cinco en verde, y notaste que ninguno menciona un valor de salida concreto: todos afirman relaciones. Y te quedó grabada la idea que gobierna el módulo: una función suele encajar en varios patrones, y la habilidad está en recorrerlos todos y combinar los que sirven.

Antes de avanzar deberías poder: nombrar los cinco patrones y su pregunta disparadora; clasificar una afirmación sobre Reservo en su patrón; y explicar por qué dominar @given no es lo mismo que saber qué probar.

Lo que sigue es la lección más práctica del arranque: las preguntas que disparan cada patrón. Porque tener el catálogo no basta si no sabes cómo consultarlo frente a una función concreta. En la lección 2 aprenderás las cinco preguntas que le haces a cualquier función para que sus propiedades salgan a la luz —la técnica exacta para no volver a quedarte en blanco—. Sigamos.

Recursos