Módulo 8: Proyecto — encuentra un bug real con property-based

3. Escribir la primera propiedad

Descripción

Ya elegiste la función sospechosa: refund_cents con su bono de lealtad recién estrenado. Ahora viene la etapa donde el property-based de verdad se juega —y donde el módulo 4 fue todo el entrenamiento—: decidir qué propiedad le vas a escribir. No cualquier propiedad sirve. Una mal elegida pasa en verde aunque el bug esté ahí, dándote una falsa tranquilidad peor que no haber probado nada. Una bien elegida choca de frente con lo que el bug rompe, y lo obliga a mostrarse. Esta lección es la etapa de la puntería: aplicar el catálogo de cinco patrones del M4 a refund_cents, razonar cuál la acorrala mejor, descartar los que dan falsa confianza, y escribir la propiedad ganadora con @given y las estrategias correctas del M3. Al final tendrás el test cargado y apuntando; la lección 4 aprieta el gatillo.

Al terminar vas a saber recorrer el cuestionario de patrones frente a una función concreta y elegir el molde que más caza, no el primero que se te ocurre. Vas a entender por qué para refund_cents el patrón es el invariante 0 <= refund <= price_paid_cents, y —lección crítica de este capstone— por qué un invariante más débil (solo refund >= 0) pasaría en verde a pesar del bug, lo que lo vuelve una trampa. Vas a escribir la propiedad con las estrategias que describen bien el espacio de entradas: montos no negativos y fechas cualesquiera. Y vas a dejar el test escrito, calibrado y listo, entendiendo exactamente qué violación está diseñado para cazar. La puntería importa tanto como el disparo: un tirador que apunta al lugar equivocado no le da al blanco por mucho que dispare.

Conexión con el módulo: esta es la segunda etapa del ciclo. La lección 2 eligió la función; esta escribe la propiedad; la lección 4 la corre y la ve fallar. Todo lo que uses aquí ya se enseñó: el catálogo de patrones y el cuestionario para elegirlos son del módulo 4; las estrategias st.integers y st.datetimes para describir las entradas, del módulo 2 y 3; el @given de base, del módulo 2. No hay técnica nueva —hay aplicación bajo presión, que es lo que el capstone certifica—. Si en algún momento dudas de por qué el invariante es un patrón, la lección de referencia es la 3 del módulo 4; aquí lo usamos, no lo introducimos.

Una analogía: la red con el agujero del tamaño justo

Un pescador que quiere atrapar un pez de cierto tamaño elige la red con cuidado. Si la malla es demasiado ancha, el pez se escapa entre los huecos y el pescador vuelve a casa con la red vacía, convencido de que "no había peces" —cuando en realidad pasaron por los agujeros—. Si la malla es del tamaño justo, el pez queda atrapado. La red no atrapa por ser grande o por lanzarse muchas veces; atrapa por tener el hueco correcto para la presa que se busca.

Una propiedad es esa red, y el bug es el pez. Un invariante débil —como "el reembolso nunca es negativo"— es una red de malla ancha: el bug del bono, que hace el reembolso demasiado grande (por encima de lo pagado), se escapa limpito por el hueco, porque un reembolso de 11 sobre un pago de 10 sigue siendo perfectamente positivo. La red se lanza cien veces, no atrapa nada, y te vuelves a casa creyendo que la función está bien. Un invariante completo —"el reembolso está entre cero y lo pagado"— tiene la malla del tamaño justo: pone un techo además del piso, y ese techo es exactamente el borde que el bug cruza. El pez queda atrapado en el primer lance.

Por eso la elección de la propiedad no es un trámite: es elegir el tamaño de la malla. La parte difícil del property-based nunca fue disparar @given cien veces —eso lo hace la máquina—; fue diseñar la red con el hueco correcto para la presa que sospechas. Un invariante que solo mira el piso deja escapar todos los bugs de "demasiado grande". La disciplina es preguntarse siempre: ¿mi propiedad tiene un hueco por donde este bug podría colarse?

Ejemplo trabajado: del cuestionario del M4 a la propiedad

Recorramos el cuestionario de cinco preguntas del módulo 4 frente a refund_cents, para elegir el patrón con método y no por reflejo.

  • ¿Qué no cambia? (invariante). Da fruto de inmediato, y fuerte: un reembolso, pase lo que pase, tiene que caer entre cero y lo que el socio pagó. No puedes reembolsar una cantidad negativa (le estarías cobrando por cancelar) ni más de lo que pagó (le estarías regalando dinero de la caja). El invariante es 0 <= refund <= price_paid_cents. Este es el molde natural: la corrección de un reembolso se captura por su rango.
  • ¿Hay otra forma de calcularlo? (oráculo). Podrías reimplementar el reembolso con otra fórmula, pero sería casi idéntica —las mismas tres ramas—, así que el oráculo no aportaría mucha independencia. Descartado como principal.
  • ¿Y si lo hago dos veces? (idempotencia). No aplica: refund_cents es pura, no muta ni se reaplica sobre su salida.
  • ¿Y si lo deshago? (round-trip). No hay una inversa natural de "calcular un reembolso".
  • ¿Cómo cambia si muevo la entrada? (metamórfica). Sí hay una —cancelar más temprano nunca reembolsa menos (monotonía en el tiempo), del M4—, y es una buena propiedad complementaria. Pero para cazar este bug —un reembolso que se pasa del techo— la metamórfica de monotonía no es la más directa: el bono podría respetar la monotonía y aun así rebasar lo pagado.

Ganador claro: el invariante 0 <= refund <= price_paid_cents. Y no por descarte, sino porque su forma misma —"la salida vive en la franja [0, pagado]"— es exactamente la frontera que el bono sin tope cruza. El bug hace el reembolso mayor que lo pagado; el invariante afirma que eso nunca ocurre. Es la red con el hueco del tamaño justo.

Ahora la trampa que este capstone quiere que veas con los ojos. Es tentador escribir solo media propiedad —el piso—, porque "un reembolso obviamente no puede ser negativo" y suena suficiente:

# test_weak_property.py — el invariante DEBIL: solo el piso
from datetime import datetime
from hypothesis import given, strategies as st
from reservo import Booking, refund_cents

BOOKING = Booking("bk-1", "r-focus", "m-1",
                  datetime(2026, 6, 1, 10, 0), datetime(2026, 6, 1, 13, 0),
                  "confirmed", 6000)


@given(price_paid_cents=st.integers(min_value=0), now=st.datetimes())
def test_refund_is_non_negative(price_paid_cents, now):
    # invariante FLOJO: solo comprueba el limite inferior
    assert refund_cents(BOOKING, price_paid_cents, now) >= 0

Qué esperar. Verde. Esta propiedad pasa los cien casos aunque el bug esté presente, porque el bono hace el reembolso más grande, nunca negativo:

$ python3 -m pytest test_weak_property.py -v
test_weak_property.py::test_refund_is_non_negative PASSED                [100%]

============================== 1 passed in 0.12s ===============================

Ese verde es una mentira cómoda. La función tiene un bug —devuelve 11 sobre un pago de 10— y la propiedad no se entera, porque solo mira el piso y el bug rompe el techo. Si hubieras elegido esta red de malla ancha, habrías cerrado la sesión de testing con un verde tranquilizador y el bug intacto en producción. Este es el error más caro del property-based, y por eso el módulo entero insiste: un verde con la propiedad equivocada es peor que no haber probado, porque añade confianza falsa.

La propiedad correcta pone el techo:

# test_refund_property.py — el invariante COMPLETO: piso Y techo
from datetime import datetime
from hypothesis import given, strategies as st
from reservo import Booking, refund_cents

BOOKING = Booking("bk-1", "r-focus", "m-1",
                  datetime(2026, 6, 1, 10, 0), datetime(2026, 6, 1, 13, 0),
                  "confirmed", 6000)


@given(
    price_paid_cents=st.integers(min_value=0),
    now=st.datetimes(),
)
def test_refund_never_exceeds_paid(price_paid_cents, now):
    refund = refund_cents(BOOKING, price_paid_cents, now)
    assert 0 <= refund <= price_paid_cents

Mira las decisiones que hay en esas pocas líneas. El assert 0 <= refund <= price_paid_cents es el invariante completo: piso (0 <=) y techo (<= price_paid_cents), encadenados en una sola comparación de Python. La estrategia st.integers(min_value=0) describe el espacio de los montos pagados —cualquier entero de cero para arriba, porque un pago negativo no tiene sentido—; sin max_value, dejamos que Hypothesis explore montos tan grandes como quiera, y de hecho le gusta probar los pequeños (cerca del cero, donde vive el borde) y algunos enormes. La estrategia st.datetimes() describe el espacio de los "ahora": cualquier fecha, para que la función se ejercite en los tres tramos —más de 48 h antes, entre 24 y 48, y menos de 24—. El BOOKING es una reserva fija con start en junio de 2026; lo que varía es cuándo se cancela (now) y cuánto se pagó (price_paid_cents), que son justo las dos entradas de las que depende el reembolso.

No corremos esta propiedad todavía —ese es el momento de la lección 4—. Aquí la dejamos escrita, calibrada y apuntando: sabemos qué viola (un reembolso que rebasa lo pagado), sabemos que su malla tiene el hueco del tamaño justo para el bug del bono, y sabemos que las estrategias barren el espacio donde el bug vive. La puntería está hecha. Falta el disparo.

Profundización: calibrar la propiedad — que falle cuando debe fallar

Hay una disciplina silenciosa detrás de escribir una buena propiedad, y es la más fácil de olvidar: una propiedad solo vale si puede fallar. Un assert que es verdadero para toda entrada posible —incluso para las que un bug produciría— no prueba nada; es un adorno verde. La pregunta que hay que hacerle a cada propiedad antes de confiar en ella es: "¿qué bug plausible haría fallar esto?". Si no se te ocurre ninguno, tu propiedad es demasiado laxa.

Aplícalo a las dos que escribimos. Al invariante débil (refund >= 0), ¿qué bug lo haría fallar? Uno que produjera un reembolso negativo —un error de signo, restar de más—. Pero el bug que tenemos (un bono que suma de más) produce reembolsos demasiado grandes, no negativos, así que la propiedad débil no puede fallar con él. Está mal calibrada para esta caza. Al invariante completo (0 <= refund <= paid), ¿qué bug lo haría fallar? Cualquiera que sacara el reembolso de la franja: negativo por abajo, o —justo nuestro caso— por encima de lo pagado por arriba. Está bien calibrado: su malla cubre los dos lados por donde un reembolso puede descarriarse.

Este es el arte de fondo del property-based, y es contraintuitivo al principio: no buscas la propiedad más fácil de satisfacer, buscas la más difícil que la función correcta todavía cumple. Cuanto más ajustada la afirmación —más cerca del comportamiento exacto que exiges—, más bugs caza, siempre que siga siendo verdadera para el código correcto. Un invariante que dice "el reembolso está entre 0 y lo pagado" es más fuerte que "el reembolso es no negativo" porque afirma más, y esa afirmación de más es exactamente lo que atrapa el bug del techo. La regla práctica: cuando escribas un invariante de rango, no te quedes con la mitad —piso y techo—, porque la mitad que omitas es un hueco por donde un bug entero se escapa.

Hay una forma barata de calibrar en la práctica, y la vamos a usar sin nombrarla en la lección 4: correr la propiedad contra una versión que crees correcta (aquí, el refund_cents sin bono, que ya viste en verde en la lección 2) y confirmar que pasa, y contra la versión sospechosa y ver si falla. Si pasa en ambas, tu propiedad es demasiado laxa —no distingue el código bueno del malo—. Si falla en ambas, es demasiado estricta —rechaza incluso el código correcto—. La buena propiedad pasa con el código correcto y falla con el roto: distingue. Esa es la calibración, y es lo que separa una red que pesca de una que solo se moja.

Errores comunes

Escribir solo la mitad del invariante de rango. Qué pasa: alguien escribe assert refund >= 0 y se detiene, porque el piso "es lo obvio". Por qué pasa: el límite que primero salta a la mente (no negativo) tapa el que de verdad importa aquí (no mayor que lo pagado). Cómo detectarlo: si tu invariante de rango tiene una sola comparación en vez de una cadena a <= x <= b, probablemente omitiste un extremo. Cómo corregirlo: para todo invariante de rango, pregúntate por ambos límites —¿cuál es el mínimo válido? ¿cuál el máximo?— y afírmalos los dos. El extremo que omitas es un hueco garantizado.

Elegir el patrón por costumbre y no por el bug que buscas. Qué pasa: alguien siempre escribe invariantes de signo porque le salen rápido, sin preguntarse si ese patrón caza el bug de esta función. Por qué pasa: el patrón cómodo se vuelve reflejo. Cómo detectarlo: si no puedes decir "este invariante fallaría con un bug que hace X", no elegiste el patrón por su capacidad de cazar, sino por hábito. Cómo corregirlo: recorre el cuestionario del M4 cada vez, aunque te lleve un minuto, y elige el molde cuyo hueco calza con el tipo de bug que sospechas —para un cálculo de dinero con una regla añadida, un invariante de rango completo—.

Confiar en una propiedad sin preguntarle qué la haría fallar. Qué pasa: alguien escribe un @given, lo ve verde, y confía sin haberse preguntado qué bug lo rompería. Por qué pasa: el verde se siente como validación. Cómo detectarlo: si no puedes nombrar un bug concreto que haría fallar tu propiedad, no sabes si prueba algo. Cómo corregirlo: antes de confiar en cualquier propiedad, hazle la pregunta de calibración —"¿qué bug plausible la haría fallar?"—. Si la respuesta es "ninguno que se me ocurra", la propiedad es un adorno; ajústala hasta que un bug realista la pueda romper.

Ejercicios

Ejercicio 1

Escribe las dos propiedades de esta lección —la débil (refund >= 0) y la completa (0 <= refund <= paid)— contra el refund_cents con bono. Corre la débil y confirma que pasa en verde (el verde mentiroso). No corras aún la completa —la lección 4 lo hace—, pero, sin ejecutarla, predice qué hará y con qué caso, apoyándote en lo que ya razonaste en las lecciones 1 y 2.

Ver solución

La propiedad débil pasa en verde: 1 passed. Los cien casos cumplen refund >= 0, porque el bono solo puede sumar al reembolso, nunca volverlo negativo. Este verde es la trampa: la función tiene el bug del techo y la propiedad, que solo mira el piso, no lo detecta.

La propiedad completa, sin correrla, se predice que fallará. El techo refund <= price_paid_cents es exactamente el límite que el bono cruza en el tramo de reembolso total. Hypothesis buscará un caso donde now esté a más de 48 horas del inicio (para caer en base = pagado) y price_paid_cents sea al menos 10 (para que el bono del 10%, con división entera, valga al menos 1 centavo). El caso mínimo esperado es price_paid_cents = 10 con un now bien anterior, donde el reembolso da 11 y 11 <= 10 es falso. La diferencia entre las dos propiedades no está en cómo se corren —ambas generan cien casos— sino en su malla: la débil tiene el hueco del techo abierto y el bug se escapa; la completa lo cierra y el bug queda atrapado.

Ejercicio 2

Para cada uno de estos tres invariantes sobre una función hipotética apply_discount(price_cents, percent) que aplica un descuento, di si está bien calibrado (puede fallar con un bug plausible) o es demasiado laxo (pasa aunque haya bug), y justifica con un bug concreto que lo rompería o que se le escaparía.

  1. assert apply_discount(p, pct) >= 0
  2. assert apply_discount(p, pct) <= p
  3. assert 0 <= apply_discount(p, pct) <= p
Ver solución
  1. >= 0 — demasiado laxo para muchos bugs, pero no inútil. Caza un bug que produzca un precio con descuento negativo (un descuento mayor al 100% mal implementado). Pero se le escapa el bug simétrico: un descuento que suba el precio (un signo invertido, como el "recargo premium" del M4) daría un resultado positivo y mayor que p, y esta propiedad no lo vería. Media red.

  2. <= p — bien calibrado para el bug de "encarece". Afirma que un descuento nunca deja el precio por encima del original, que es la regla de negocio central. Caza el signo invertido (el descuento que suma). Pero se le escapa un descuento que vuelva el precio negativo (descuenta de más), porque un número muy negativo sigue siendo <= p. La otra media red.

  3. 0 <= ... <= p — bien calibrado, malla completa. Combina los dos límites: caza tanto el descuento que vuelve negativo el precio (rompe el piso) como el que lo encarece (rompe el techo). Es la red del tamaño justo, la que no deja hueco por ningún lado. La lección: un invariante de rango completo es más fuerte que cualquiera de sus dos mitades por separado, porque cierra los dos flancos por donde el resultado puede descarriarse. Igual que en refund_cents, la respuesta rica es el rango entero, no medio.

Ejercicio 3

La lección afirma que "buscas la propiedad más difícil que la función correcta todavía cumple". Aplícalo a refund_cents: además del invariante de rango, ¿qué otra afirmación más fuerte y todavía verdadera podrías hacer sobre el reembolso correcto? Enúnciala en palabras (no hace falta código) y di qué bug adicional cazaría que el rango no caza.

Ver solución

Hay varias afirmaciones más fuertes y todavía verdaderas; una buena es la monotonía en el tiempo (la metamórfica del M4): cancelar más temprano nunca reembolsa menos que cancelar más tarde. En palabras: si now_early es anterior a now_late, entonces refund_cents(bk, paid, now_early) >= refund_cents(bk, paid, now_late). Es más fuerte que el rango porque no solo acota dónde cae cada reembolso, sino que ordena cómo se relacionan dos reembolsos del mismo pago a distintas anticipaciones.

¿Qué bug cazaría que el rango no caza? Uno que invirtiera los tramos —por ejemplo, dar el reembolso completo a quien cancela tarde y cero a quien cancela con muchísimo tiempo—. Ese bug podría respetar perfectamente el rango [0, pagado] (todos los valores siguen en la franja) y aun así estar completamente al revés en su lógica; la monotonía lo detectaría porque rompería el orden esperado. La moraleja conecta con la de todo el módulo: ninguna propiedad sola es completa. El rango caza los reembolsos fuera de la franja; la monotonía caza los que están en la franja pero en el tramo equivocado. Una suite madura de refund_cents llevaría las dos —y unos ejemplos-ancla (72 h → 6000) para clavar los valores exactos—. En el capstone atacamos el bug del bono con el rango porque es el molde cuyo hueco calza con ese bug; para otros bugs, otras propiedades.

Resumen y siguiente paso

En esta lección diste la segunda etapa del ciclo: escribir la propiedad, con la puntería que separa una red que pesca de una que solo se moja. Recorriste el cuestionario del módulo 4 frente a refund_cents y elegiste el invariante 0 <= refund <= price_paid_cents —no por descarte, sino porque su forma misma es la frontera que el bono sin tope cruza—. Escribiste la propiedad con las estrategias correctas: st.integers(min_value=0) para los montos, st.datetimes() para las fechas, un BOOKING fijo mientras varían las dos entradas de las que depende el reembolso.

Y viste, con salida real, la trampa más cara del property-based: un invariante débil —solo refund >= 0— pasa en verde a pesar del bug, porque el bono rompe el techo y la propiedad solo mira el piso. Ese verde mentiroso es peor que no haber probado, porque añade confianza falsa. La disciplina que lo evita es la calibración: preguntarle a cada propiedad "¿qué bug plausible la haría fallar?", y buscar la afirmación más fuerte que el código correcto todavía cumple —piso y techo, no medio rango—.

La propiedad está escrita, calibrada y apuntando. La lección 4 aprieta el gatillo: verla fallar. Vas a correr test_refund_never_exceeds_paid contra el refund_cents con bono y ver, por fin, el rojo —y no un rojo cualquiera, sino un ejemplo falsificador que Hypothesis ya te entrega reducido al mínimo—. Vas a leer ese Failing test case línea por línea, entender qué te dice cada campo, y confirmar que el bug que sospechabas es un hecho con nombre y apellido: price_paid_cents=10, reembolso 11, un centavo de más. La red está lista; toca lanzarla al agua.

Recursos