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

1. Presentación del módulo: el ciclo completo de cazar un bug

Descripción

Llegaste al último módulo de la guía, y este no se parece a los siete anteriores. Cada módulo hasta aquí te entregó una pieza —una técnica, un patrón, una mecánica— y la ejerció en un mini-proyecto que la aislaba. Este módulo no tiene una pieza nueva que enseñar. Es el capstone: el lugar donde las ocho piezas dejan de vivir en compartimentos separados y se tejen en el único proceso que justifica que hayas aprendido todo lo demás —encontrar un bug real y arreglarlo—. En las próximas lecciones vas a recorrer, paso a paso, el ciclo completo: tomar una función de Reservo con un bug sutil, escribir la propiedad que lo caza, verla fallar, dejar que Hypothesis encoja el contraejemplo hasta el reproductor mínimo, diagnosticarlo, arreglarlo, y clavar una regresión que impida que el bug vuelva. Ese ciclo —propiedad → falla → shrink → diagnóstico → fix → regresión— es el property-based testing en su forma más pura, y esta lección te da el mapa completo antes de recorrerlo.

Al terminar la lección vas a saber nombrar las seis etapas del ciclo, entender por qué cada una necesita a la anterior, y —lo más importante para todo el módulo— por qué en este capstone se evalúa el método y no el resultado. En los mini-proyectos anteriores, "verde" era la meta: escribías la propiedad, corría, pasaba, listo. Aquí la meta es distinta. Un bug bien cazado se reconoce no por el color final, sino por cómo llegaste a él: si escribiste la propiedad correcta, si leíste el caso mínimo en vez de adivinar, si el fix ataca la causa y no el síntoma, si la regresión clava el caso exacto. Vas a ver ese ciclo entero en miniatura ahora, ejecutado de verdad, y luego lo desmenuzaremos etapa por etapa en las lecciones siguientes.

Conexión con el módulo: esta es la lección de apertura, el plano general antes de bajar al detalle. Las lecciones 2 a 7 toman cada etapa del ciclo y la recorren con lupa sobre una función concreta de Reservo —refund_cents, a la que le acaban de añadir un "bono de lealtad" que esconde un bug—. La lección 8 es el enunciado formal del proyecto, con su rúbrica por método y un segundo bug para que lo caces tú. Aquí no bajamos todavía a esa función: damos el recorrido completo de un extremo al otro, para que cuando en la lección 2 empieces a elegir la función sospechosa, ya sepas hacia dónde va el camino entero. Piensa en esta lección como el mapa que el guía de montaña extiende sobre la mesa antes de la primera etapa: todavía no caminamos, pero ya sabes cuántos tramos hay, en qué orden y a dónde llegan.

Una analogía: el método del detective, no la corazonada

Un detective novato y un detective con oficio miran la misma escena del crimen, pero trabajan de forma opuesta. El novato tiene una corazonada —"fue el mayordomo"— y busca pruebas que la confirmen. Si encuentra una, se detiene, satisfecho. El detective con oficio hace lo contrario: no parte de una corazonada, parte de un método. Recorre la escena de forma sistemática, formula una hipótesis que podría ser falsa, busca activamente el hecho que la rompería, y solo cuando ha reducido el caso a su explicación más simple y a prueba de dudas, cierra. La diferencia no está en la inteligencia, sino en el proceso: uno confirma lo que ya creía, el otro deja que la evidencia lo lleve.

Cazar un bug con property-based testing es el trabajo del detective con oficio, y por las mismas razones. No partes de "creo que esta función está mal"; partes de una propiedad —una hipótesis que la función debe cumplir siempre y que, si está rota, será falsa—. No buscas el ejemplo que confirma que funciona; dejas que Hypothesis busque activamente el ejemplo que la rompe. Y cuando lo encuentra, no te quedas con el primer contraejemplo enorme y confuso: dejas que lo encoja hasta la explicación más simple posible —el caso mínimo, que se lee casi como una confesión—. El property-based testing es, en el fondo, el método del detective aplicado al código: hipótesis falsable, búsqueda del contraejemplo, reducción a lo mínimo, y solo entonces el veredicto.

Por eso este módulo evalúa el método y no el resultado. Un detective que acierta el culpable por corazonada, sin método, tuvo suerte; no aprendió a resolver el siguiente caso. Un programador que ve verde sin haber escrito la propiedad correcta también tuvo suerte: la próxima función lo va a agarrar en blanco. Lo que este capstone certifica no es que hayas arreglado este bug, sino que sabes ejecutar el ciclo que caza cualquier bug de esta forma.

Ejemplo trabajado: el ciclo entero, en miniatura

Antes de recorrer cada etapa con lupa, veamos el ciclo completo de una sola sentada, para que el mapa tenga forma. No te preocupes por seguir cada línea —las lecciones siguientes las desmenuzan—; fíjate en la forma del proceso, en cómo cada paso alimenta al siguiente.

El escenario: a refund_cents, la función de reembolso de Reservo, le acaban de añadir una regla de negocio —un "bono de lealtad" del 10% para premiar a quien cancela con tiempo—. La función quedó así:

# reservo.py — refund_cents con la nueva regla del bono de lealtad
def refund_cents(booking, price_paid_cents, now):
    """Reembolso segun la anticipacion desde now hasta booking.start,
    MAS un bono de lealtad del 10% para premiar la reprogramacion temprana."""
    hours_until = (booking.start - now).total_seconds() / 3600
    if hours_until >= 48:
        base = price_paid_cents
    elif hours_until >= 24:
        base = price_paid_cents * 50 // 100
    else:
        base = 0
    loyalty_bonus = base * 10 // 100     # bono de lealtad
    return base + loyalty_bonus

Etapa 1 — elegir la función sospechosa. refund_cents acaba de cambiar, maneja dinero y tiene condiciones (>= 48, >= 24). Tres señales de alarma. Es la primera donde apuntar. (Lección 2.)

Etapa 2 — escribir la propiedad. ¿Qué debe cumplir siempre un reembolso? Que nunca sea negativo ni supere lo que el socio pagó: 0 <= refund <= price_paid_cents. Es un invariante (el patrón del M4), y lo escribimos con Hypothesis. (Lección 3.)

# test_refund_property.py
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

Etapa 3 y 4 — verla fallar y leer el caso mínimo. Corremos, y Hypothesis no tarda en encontrar un contraejemplo, que además ya viene encogido al mínimo.

Qué esperar. Rojo, con el ejemplo falsificador reducido a los valores más simples que rompen la propiedad:

$ python3 -m pytest test_refund_property.py
=================================== FAILURES ===================================
________________________ test_refund_never_exceeds_paid ________________________

price_paid_cents = 10, now = datetime.datetime(2000, 1, 1, 0, 0)

    @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
E       assert 11 <= 10
E       Failing test case: test_refund_never_exceeds_paid(
E           price_paid_cents=10,
E           now=datetime.datetime(2000, 1, 1, 0, 0),
E       )

Ese assert 11 <= 10 es la confesión: con un pago de 10 centavos, la función reembolsó 11 —un centavo más de lo que se pagó—. El bug es real. (Lecciones 4 y 5.)

Etapa 5 — diagnosticar y arreglar. El caso mínimo apunta al culpable: en el tramo de reembolso total (hours_until >= 48), base ya es igual a lo pagado, y el bono del 10% lo empuja por encima. El fix es quirúrgico —topar el reembolso en lo pagado—:

    loyalty_bonus = base * 10 // 100     # bono de lealtad
    # el reembolso nunca puede superar lo que el socio pago
    return min(base + loyalty_bonus, price_paid_cents)

Etapa 6 — clavar la regresión y confirmar verde. Añadimos un @example con el caso mínimo para que se pruebe siempre, y corremos de nuevo.

Qué esperar. Verde, con los cien casos cumpliendo el invariante:

$ python3 -m pytest test_refund_property.py -v --hypothesis-show-statistics
test_refund_property.py::test_refund_never_exceeds_paid PASSED           [100%]
============================ Hypothesis Statistics =============================

  - during generate phase (0.03 seconds):
    - 100 passing, 0 failing, and 0 invalid test cases
  - Stopped because settings.max_examples=100

============================== 1 passed in 0.05s ===============================

Ese es el ciclo completo: partimos de una función sospechosa, escribimos la propiedad que la acorrala, la vimos fallar con un caso que Hypothesis encogió a price_paid_cents=10, diagnosticamos que el bono no tenía tope, lo topamos, y confirmamos verde. Seis etapas, de la sospecha al fix probado. Cada lección de este módulo toma una de esas etapas y la recorre despacio.

Profundización: por qué se evalúa el método y no el resultado

Vale la pena detenerse en la afirmación que gobierna todo el módulo: aquí se evalúa el método, no el resultado. Suena abstracto; hagámoslo concreto con la diferencia entre dos alumnos que entregan "el bug arreglado".

El primer alumno miró refund_cents, sospechó del bono, escribió a mano un test por ejemplo (assert refund_cents(bk, 10, hace_tres_dias) == 10), lo vio fallar (dio 11), cambió el base + loyalty_bonus por base a secas —borrando el bono entero— y el ejemplo pasó. Entregó "el bug arreglado". Pero su método tiene tres grietas. Descubrió el caso 10 por suerte, no porque una propiedad lo generara; su fix borró una regla de negocio legítima en vez de acotarla; y no dejó ninguna regresión, así que si alguien reintroduce el bono, nada lo detecta. Ese alumno arregló este bug y no aprendió a cazar el siguiente.

El segundo alumno escribió el invariante 0 <= refund <= pagado, dejó que Hypothesis encontrara y encogiera el contraejemplo a 10, leyó el caso mínimo para entender por qué fallaba justo en el tramo de reembolso total, aplicó un fix que conserva el bono donde es seguro y solo lo topa donde rebasa, y clavó un @example(price_paid_cents=10, ...) de regresión. Su función terminó igual de verde que la del primero, pero su proceso es reproducible: enfrentado a otra función con otro bug, volvería a acertar, porque no dependió de la suerte en ningún paso.

Los dos entregan "verde". Solo uno demuestra la capacidad que este módulo certifica. Por eso, cuando en la lección 8 leas la rúbrica, verás que no puntúa "¿encontraste el bug?" sino "¿escribiste la propiedad correcta?", "¿leíste el caso mínimo o adivinaste?", "¿el fix ataca la causa?", "¿dejaste regresión?". El resultado —el bug arreglado— es la consecuencia natural de un buen método, pero es el método lo que se puede llevar a la siguiente función, y es el método lo que estás aquí para dominar.

Hay una razón práctica y honesta detrás de esto. En tu trabajo real, casi nunca vas a saber de antemano que hay un bug ni dónde está —si lo supieras, ya lo habrías arreglado—. Lo que vas a tener es una función y la pregunta "¿está bien?". El método del ciclo es lo que convierte esa pregunta vaga en un procedimiento concreto: formula el invariante, deja que la máquina busque el contraejemplo, lee el mínimo, diagnostica, arregla, clava. Un resultado no se generaliza; un método sí.

Errores comunes

Saltarse la propiedad y buscar el bug a ojo. Qué pasa: alguien, ansioso por "encontrar el bug ya", se pone a leer refund_cents línea por línea buscando el error a simple vista, sin escribir ninguna propiedad. Por qué pasa: leer código se siente más directo que montar un test. Cómo detectarlo: si llevas diez minutos mirando la función y no has escrito un solo @given, te saliste del método. Cómo corregirlo: la fuerza del property-based es justo que no dependes de ver el bug —la máquina lo encuentra por ti—. Escribe el invariante primero; la propiedad es la que trabaja, no tus ojos. Leer el código viene después, en el diagnóstico, y guiado por el caso mínimo.

Confundir "verde al final" con "bien hecho". Qué pasa: alguien llega al verde por un camino torcido —un fix que borra la regla de negocio, o una propiedad tan floja que habría pasado con el bug— y cree que terminó. Por qué pasa: en los módulos anteriores el verde era la meta, y el hábito se arrastra. Cómo detectarlo: pregúntate si tu propiedad habría fallado con el bug; si no estás seguro, tu verde no prueba nada. Cómo corregirlo: en este módulo el verde es necesario pero no suficiente. Un fix correcto deja verde y conserva el comportamiento legítimo y viene con regresión. Evalúa el camino, no solo el destino.

Arreglar el síntoma en vez de la causa. Qué pasa: alguien ve assert 11 <= 10, piensa "el problema es que da 11", y añade un if refund > paid: refund = paid al final sin entender por qué daba 11. Por qué pasa: el síntoma (un número de más) es visible; la causa (el bono sin tope en el tramo de reembolso total) hay que razonarla. Cómo detectarlo: si tu fix no lo puedes explicar en términos de la regla de negocio ("el bono no debía superar lo pagado"), estás parcheando el síntoma. Cómo corregirlo: usa el caso mínimo para diagnosticar la causa antes de tocar nada. A veces el parche del síntoma es el fix correcto (topar en lo pagado es razonable), pero solo lo sabes si primero entendiste por qué fallaba.

Ejercicios

Ejercicio 1

Sin correr nada todavía, nombra las seis etapas del ciclo de cazar un bug en el orden correcto, y en una frase di qué produce cada una. El objetivo es que el mapa te quede en la cabeza antes de recorrerlo, para que en cada lección siguiente sepas en qué etapa estás.

Ver solución

Las seis etapas, en orden, y su producto:

  1. Elegir la función sospechosa — produce una función concreta sobre la que apuntar, elegida por sus señales de riesgo (bordes, cambios recientes, dinero, condiciones).
  2. Escribir la propiedad — produce un @given con un invariante (u otro patrón del M4) que la función debe cumplir para toda entrada.
  3. Verla fallar — produce un ejemplo falsificador: la evidencia de que el bug existe (assert 11 <= 10).
  4. Encoger al mínimo — produce el reproductor más pequeño y legible (price_paid_cents=10), listo para diagnosticar.
  5. Diagnosticar y arreglar — produce la causa raíz identificada y un fix quirúrgico que respeta el invariante.
  6. Clavar la regresión — produce un @example que prueba el caso mínimo de forma determinista para siempre, y el verde final que confirma el fix.

Fíjate en la cadena de dependencias: cada etapa consume el producto de la anterior. No puedes diagnosticar sin el caso mínimo, no tienes el caso mínimo sin la falla, no tienes la falla sin la propiedad, y no escribes la propiedad correcta sin haber elegido bien la función. El orden no es decorativo: es causal.

Ejercicio 2

Vuelve al refund_cents con el bono de lealtad de esta lección y responde, razonando y sin correr: para price_paid_cents = 5 y un now de hace tres días (tramo de reembolso total), ¿la función viola el invariante refund <= pagado? ¿Y para price_paid_cents = 10? Esto anticipa por qué el caso mínimo que Hypothesis encuentra es exactamente 10 y no un número menor.

Ver solución

En el tramo de reembolso total (hours_until >= 48), base = price_paid_cents y loyalty_bonus = base * 10 // 100.

Para price_paid_cents = 5: base = 5, loyalty_bonus = 5 * 10 // 100 = 50 // 100 = 0 (la división entera trunca a cero). El reembolso es 5 + 0 = 5, que cumple 5 <= 5. No viola el invariante.

Para price_paid_cents = 10: base = 10, loyalty_bonus = 10 * 10 // 100 = 100 // 100 = 1. El reembolso es 10 + 1 = 11, que no cumple 11 <= 10. Viola el invariante.

Ese es exactamente el motivo por el que el caso mínimo es 10: es el valor más pequeño de price_paid_cents para el que el bono del 10%, con división entera, llega a valer al menos 1 centavo y empuja el reembolso por encima de lo pagado. Debajo de 10, el bono trunca a 0 y no hay violación. Hypothesis, al encoger, aterriza justo en ese borde —el número más pequeño que todavía rompe la propiedad—. Verás la mecánica completa del encogido en la lección 5, pero ya intuyes que el caso mínimo no es un número cualquiera: es la frontera del bug.

Ejercicio 3

Reflexiona por escrito (unas líneas): de los dos alumnos de la sección de profundización —el que arregló el bug por corazonada y el que siguió el método—, ¿cuál crees que arreglaría más rápido un bug nuevo en una función que nunca ha visto, y por qué? Conecta tu respuesta con la idea de que un método se generaliza y un resultado no.

Ver solución

No hay una única redacción correcta, pero la respuesta fundamentada apunta al segundo alumno —el del método— y por una razón precisa: lo que aprendió es transferible. El primer alumno sabe que refund_cents daba 11 con un pago de 10; ese conocimiento no le sirve para nada frente a overlaps, price_cents o cualquier función que no haya visto. Su "resultado" está atado a una función concreta. El segundo alumno aprendió un procedimiento —formular el invariante, dejar que la máquina busque el contraejemplo, leer el mínimo, diagnosticar la causa, topar, clavar la regresión— que aplica idéntico a cualquier función. Frente a código nuevo, el primero empieza de cero (a mirar líneas, a adivinar); el segundo ya sabe qué hacer en el minuto uno: "¿qué propiedad debe cumplir esto siempre?".

Esa es la moraleja de fondo del módulo, y la razón de que se evalúe el método: en la vida real casi nunca vuelves a la misma función con el mismo bug. Vuelves a la misma pregunta —"¿está bien?"— sobre funciones distintas. Un resultado responde una vez; un método responde siempre. Por eso vale la pena, aunque sea más lento al principio, hacer el ciclo completo aunque "ya se te ocurra" el fix: no estás arreglando este bug, estás afilando el procedimiento que cazará todos los demás.

Resumen y siguiente paso

En esta lección de apertura extendiste el mapa completo del capstone antes de recorrerlo. Viste que este módulo no enseña una técnica nueva, sino que teje las ocho de la guía en un solo proceso: el ciclo de cazar un bug, con sus seis etapas —elegir la función sospechosa, escribir la propiedad, verla fallar, encoger al mínimo, diagnosticar y arreglar, clavar la regresión—. Recorriste ese ciclo entero en miniatura sobre refund_cents con su bono de lealtad, y viste con salida real cómo la propiedad 0 <= refund <= pagado caza un caso donde el reembolso (11) supera lo pagado (10), cómo el fix lo topa, y cómo el verde final lo confirma.

Y entendiste la regla que gobierna todo el módulo: se evalúa el método, no el resultado. Dos alumnos pueden entregar el mismo verde por caminos opuestos, y solo uno demuestra la capacidad que se puede llevar a la siguiente función. El resultado se agota en un bug; el método caza todos. Por eso el ciclo completo importa aunque "ya se te ocurra" el fix.

Con el mapa claro, empezamos a caminarlo. La lección 2 baja a la primera etapa: elegir la función sospechosa. Antes de escribir una sola propiedad, hay que decidir a dónde apuntar, y esa decisión tiene método —dónde se esconden los bugs, qué señales delatan a una función de riesgo, por qué una función recién tocada que maneja dinero y tiene condiciones es el primer lugar donde mirar—. Vas a conocer a refund_cents con su bono de lealtad recién estrenado y a entender por qué es la candidata perfecta para el capstone. El mapa está sobre la mesa; demos el primer paso.

Recursos

  • Documentación oficial de Hypothesis — la referencia completa que ya consultaste toda la guía. En el capstone la usarás como fuente única para cualquier detalle de @given, estrategias, @example o settings que necesites recordar.
  • Quickstart de Hypothesis — el recorrido mínimo de instalar, escribir un @given y correrlo. Un buen repaso exprés del ciclo básico antes de aplicarlo en serio a un bug real.
  • Documentación de pytest — cómo correr tests — el runner que ejecuta las propiedades. Repasa las banderas (-v, --hypothesis-show-statistics) que usaremos para leer cada etapa del ciclo con claridad.