Módulo 1: Del ejemplo a la propiedad — por qué existe el property-based testing

3. Qué es una propiedad (un invariante)

Descripción

Ya sabes que un ejemplo cubre un punto y que eso deja el espacio de entradas casi entero sin probar. Toca definir con precisión la herramienta que barre ese espacio: la propiedad. La palabra se usa mucho y a la ligera, así que vamos a fijarla con rigor, porque la mitad de los errores al empezar con property-based vienen de una definición borrosa.

Al terminar esta lección vas a poder decir, sin dudar, qué es una propiedad y en qué se diferencia de un ejemplo: una propiedad es una afirmación con un cuantificador universal —"para TODA entrada válida se cumple X"— donde la X es una regla verificable, no un valor concreto. Vas a conocer la palabra que está en el corazón del asunto, invariante (algo que no cambia por más que cambien las entradas), y vas a aprender la anatomía de todo chequeo de propiedad, que tiene siempre las mismas tres piezas: un espacio de entradas, un generador que lo muestrea y una aserción del invariante. Con esas tres piezas nombradas, vas a poder leer y escribir propiedades sin confundirte, aquí a mano y —desde el M2— con Hypothesis.

Conexión con el módulo: esta es la lección de las definiciones. La L2 te dejó con el hueco bien visto; esta te da el concepto que lo llena. Todavía no vamos a buscar propiedades en Reservo (eso es la L4) ni a ponerlas frente a los ejemplos (L5); aquí construimos el andamiaje conceptual y de código que esas lecciones dan por sabido. Si esta lección te queda clara, las tres siguientes se sienten como aplicar una plantilla.

Una analogía: la regla del cajero contra el recibo

Piensa en dos formas de controlar la caja de una tienda. La primera es revisar recibos concretos: "el recibo de las 3:47 dice que Ana pagó 250 pesos por dos cafés; ¿está bien la suma?". Verificas un recibo, un caso, un punto. Es un ejemplo.

La segunda es imponer una regla que todo recibo debe cumplir, sin importar quién compre ni qué: "el total de cualquier recibo es igual a la suma de sus líneas, y nunca es negativo". Esa regla no habla de Ana ni de las 3:47; habla de todos los recibos posibles, los de ayer, los de hoy y los que aún no existen. Si un día aparece un recibo que la viola —un total negativo, o un total que no cuadra con sus líneas—, sabes que algo se rompió, aunque ese recibo en particular jamás lo hubieras imaginado.

Una propiedad es esa regla del cajero. No verifica un recibo; enuncia una verdad que todo recibo debe respetar, y luego busca alguno que no la respete. El ejemplo mira hacia atrás, a un caso que ya ocurrió; la propiedad mira hacia todos los casos, incluidos los que no se te ocurren. Esa es la diferencia de altura entre las dos, y por eso una sola propiedad bien puesta vale por infinitos recibos revisados a mano.

La definición, pieza por pieza

Vamos a la definición formal, y luego la desarmamos.

Una propiedad es una afirmación de la forma "para toda entrada válida x, se cumple P(x)", donde P es una condición que se puede evaluar como verdadera o falsa.

Tres palabras cargan todo el peso. Veámoslas.

"Para toda" — el cuantificador universal. Esto es lo que separa una propiedad de un ejemplo. Un ejemplo dice "para esta entrada". Una propiedad dice "para toda entrada". No es un matiz: es un salto de un punto a un espacio completo. Cuando escribas o leas una propiedad, busca ese "para todo" al frente, explícito o implícito. Si no está, no es una propiedad; es un ejemplo disfrazado.

"Entrada válida" — el dominio. El "para toda" no abarca literalmente cualquier cosa, sino toda entrada dentro del dominio de la función. refund_cents espera un precio pagado que es un entero no negativo (no existe pagar −500 centavos) y un instante now. El precio negativo no es una entrada válida; está fuera del contrato de la función. Definir bien el dominio —qué entradas son legítimas— es la mitad del trabajo de una propiedad, y es un tema que el M3 desarrolla a fondo con assume() y estrategias que describen exactamente el espacio válido. Por ahora quédate con que "para toda entrada válida" no es "para cualquier basura": es "para todo lo que la función promete manejar".

"P(x) verdadera o falsa" — el invariante. La condición tiene que ser algo que puedas evaluar y que dé un booleano: 0 <= refund <= price_paid da True o False. No vale una condición vaga ("el reembolso es razonable") ni un valor concreto ("el reembolso es 6000"). Tiene que ser una regla comprobable que valga para todos los x. A esa regla que se mantiene constante mientras las entradas cambian se le llama invariante, y es la palabra que conviene tatuarse.

Invariante: la palabra clave

Un invariante es una propiedad del sistema que permanece verdadera pase lo que pase con las entradas. "In-variante": que no varía. El reembolso puede ser 0, 3000, 6000 o cualquier cosa según la hora y el precio —eso varía—, pero la afirmación "el reembolso está entre 0 y lo pagado" no varía nunca: se cumple para toda entrada válida. Ese es el invariante.

La costumbre de pensar en invariantes es, quizás, la habilidad más transferible de todo el property-based testing. Cuando mires una función, en vez de preguntar "¿qué devuelve para esta entrada?", vas a preguntar "¿qué es cierto de su salida pase lo que pase con la entrada?". Las respuestas a esa segunda pregunta son tus propiedades. Y son sorprendentemente ricas: casi cualquier función tiene varios invariantes escondidos que nunca formulaste porque el modo confirmación no te empujaba a buscarlos.

La anatomía de un chequeo de propiedad

Toda verificación de una propiedad —a mano en este módulo, con Hypothesis desde el M2— tiene exactamente tres piezas. Nómbralas una vez y las reconocerás para siempre.

  1. El espacio de entradas. El conjunto de todas las entradas válidas. Para refund_cents: todos los pares (precio pagado ≥ 0, instante de cancelación). Es enorme, casi siempre infinito.
  2. El generador. El mecanismo que produce entradas concretas del espacio para probarlas. A mano usamos random: random.randint(0, 50_000) para el precio, random.uniform(0, 200) para las horas. Con Hypothesis, las estrategias (st.integers, st.floats, ...) cumplen este rol, y mucho mejor. El generador es lo que convierte "para toda entrada" (imposible de evaluar literalmente) en "para estas 500 entradas muestreadas" (evaluable).
  3. La aserción del invariante. El assert (o el if not ...) que comprueba P(x) en cada entrada generada. Es la regla del cajero aplicada a cada recibo.

Cuando veas cualquier test property-based, sepáralo mentalmente en estas tres piezas. Si algo no funciona, casi siempre el problema está en una de ellas: el espacio mal definido (dejaste entrar basura, o excluiste casos válidos), el generador angosto (no cubre las zonas interesantes, como viste al final de la L1) o el invariante mal escrito (una regla que siempre pasa, o que no captura lo que crees). Tener las tres piezas nombradas te da un checklist de diagnóstico.

Ejemplo trabajado: un invariante que se cumple, verificado a mano

Hasta ahora vimos propiedades cazando bugs. Es igual de importante ver una propiedad cumpliéndose, para que quede claro que no es una máquina de fallar: es una máquina de verificar una regla, que calla cuando la regla se respeta. Tomamos la implementación correcta de refund_cents y verificamos el invariante de rango sobre cientos de entradas generadas.

Aquí está la implementación correcta, la de fundamentos:

# reservo/refunds.py — la versión correcta
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

Y el chequeo de propiedad, con sus tres piezas anotadas en los comentarios para que las veas:

# check_invariant.py — anatomía de un chequeo de propiedad
import random
from datetime import datetime, timedelta
from reservo.models import Booking
from reservo.refunds import refund_cents   # la versión CORRECTA

START = datetime(2026, 3, 10, 12, 0)
random.seed(101)


def a_booking():
    return Booking(id="bk", room_id="r", member_id="m",
                   start=START, end=START + timedelta(hours=2))


failures = 0
for _ in range(500):
    # PIEZA 1 y 2: el espacio de entradas, muestreado por el generador.
    price_paid = random.randint(0, 50_000)        # precio válido: entero >= 0
    hours_before = random.uniform(-10, 200)        # incluso cancelar tarde (now > start)
    now = START - timedelta(hours=hours_before)

    refund = refund_cents(a_booking(), price_paid, now)

    # PIEZA 3: la aserción del invariante.
    if not (0 <= refund <= price_paid):
        failures += 1
        print(f"CONTRAEJEMPLO: pagado={price_paid}, {hours_before:.2f} h, reembolso={refund}")

print(f"Casos probados: 500 | violaciones del invariante: {failures}")

Fíjate en un detalle deliberado del generador: random.uniform(-10, 200) incluye valores negativos de hours_before, es decir, cancelaciones después de que la reserva empezó (now posterior a start). Es un caso que casi nadie escribiría a mano —"cancelar una reserva que ya arrancó"— y es exactamente la clase de rincón que una propiedad debe cubrir. Queremos saber si el invariante aguanta también ahí.

Qué esperar. Silencio y cero. El invariante se cumple para las 500 entradas, incluidas las cancelaciones tardías: cuando now es posterior a start, hours_until es negativo, cae en el tramo return 0, y 0 está dentro de [0, price_paid]. La regla aguanta.

$ python3 check_invariant.py
Casos probados: 500 | violaciones del invariante: 0

Ni una línea de contraejemplo, y un limpio 0 al final. Esto es lo que hace una propiedad cuando el código es correcto: nada visible. Puede parecer anticlimático —"¿corrí 500 casos para no ver nada?"—, pero es precisamente la señal de confianza que buscas. Probaste el invariante en 500 puntos repartidos por el espacio, incluidos los raros, y ninguno lo rompió. Compáralo con los tres puntos cómodos de la L2: la misma tranquilidad aparente, muchísima más evidencia detrás.

Profundización: una propiedad es más débil y más fuerte que un ejemplo, a la vez

Hay una paradoja útil que conviene entender. Una propiedad es, en un sentido, más débil que un ejemplo, y en otro sentido, más fuerte. Suena contradictorio; no lo es.

Es más débil porque afirma menos sobre cada punto. El ejemplo "72 horas → 6000" te dice el valor exacto: 6000, ni 5999 ni 6001. La propiedad "0 ≤ reembolso ≤ pagado" no te dice el valor; solo te dice un rango. Una función que a 72 horas devolviera 5999 pasaría la propiedad (5999 está en el rango) y fallaría el ejemplo. En ese sentido, la propiedad es menos exigente en cada punto: acepta cualquier valor dentro de la franja.

Y a la vez es más fuerte porque afirma algo sobre todos los puntos, no solo uno. El ejemplo no dice nada sobre las 71 horas, ni sobre las 73, ni sobre un precio de 9886; la propiedad dice algo sobre los tres y sobre infinitos más. Cubre en anchura lo que sacrifica en precisión.

De aquí sale la lección práctica que ya asomó y que conviene grabar: las propiedades y los ejemplos no compiten; se complementan. Los ejemplos aportan precisión puntual (el valor exacto en puntos clave, los números-ancla que documentan la regla). Las propiedades aportan cobertura de todo el espacio (la franja válida en todos lados). Una suite madura usa ejemplos para clavar "72 h → 6000, 36 h → 3000, 12 h → 0" y propiedades para garantizar "y nunca, jamás, en ningún punto, el reembolso se sale de [0, pagado] ni deja de ser monótono". La debilidad de una es la fuerza de la otra.

Esto también explica por qué una propiedad sola puede pasar con una función absurda. "0 ≤ reembolso ≤ pagado" la cumple una función que siempre devuelve 0 —está dentro del rango en todo punto—, aunque esa función esté clarísimamente rota (no reembolsa nunca a quien canceló con tres días de anticipación). La propiedad de rango no la caza porque no habla de valores concretos. Por eso necesitas varias propiedades (rango, monotonía, "pagado 0 ⇒ reembolso 0", que combinan para cerrar el cerco) y algunos ejemplos que fijen los valores. Ninguna herramienta sola basta; la habilidad está en combinarlas, y a encontrar el juego de propiedades adecuado dedicamos la L4 y todo el M4 más adelante.

Errores comunes

Confundir "propiedad" con "test que usa parametrize". Una tabla de parametrize con veinte filas sigue siendo veinte ejemplos: veinte puntos elegidos a mano, sin "para todo". La propiedad no se define por la cantidad de casos ni por la sintaxis, sino por el cuantificador universal y la generación. Un test con un solo assert dentro de un bucle que genera entradas es una propiedad; una tabla de mil filas escritas a mano no lo es.

Escribir un invariante que en realidad es un valor concreto. "El reembolso a 72 horas es 6000" no es un invariante, es un ejemplo, aunque lo metas en un bucle. Un invariante no menciona un valor de salida fijo; menciona una relación que vale para todos (rango, orden, igualdad entre dos formas de calcular). Si tu "propiedad" contiene un número mágico de salida, casi seguro es un ejemplo mal vestido.

Olvidar definir el dominio y dejar entrar entradas inválidas. Si tu generador produce precios negativos y tu función no promete manejarlos, vas a ver "fallos" que no son bugs de la función, sino entradas fuera de su contrato. "Para toda entrada válida" incluye la palabra válida por algo. Definir el dominio —y filtrar lo que queda fuera— es parte de la propiedad, no un detalle. El M3 le dedica herramientas enteras (assume, estrategias acotadas).

Creer que una propiedad verde prueba la corrección. Una sola propiedad casi nunca captura toda la corrección (recuerda la función que devuelve siempre 0). Verde en una propiedad significa "esta regla se respeta en los puntos que probé", no "la función es correcta". La corrección se acorrala con un conjunto de propiedades más algunos ejemplos, nunca con una regla aislada.

Ejercicios

Ejercicio 1

Para cada frase, decide si es un invariante legítimo (una regla comprobable con "para todo") o no lo es (porque es un ejemplo disfrazado, un valor concreto o una condición no comprobable). Justifica.

  1. "price_cents nunca devuelve un número negativo."
  2. "price_cents(Focus, pro, 3) devuelve 6000."
  3. "El precio de price_cents es razonable."
  4. "Para el mismo room y las mismas horas, el precio pro es menor o igual que el basic."
Ver solución
  1. Invariante legítimo. "Para todo room, member y hours, price_cents(...) >= 0". Regla comprobable, cuantificador universal, sin valores mágicos. Es el invariante de no-negatividad.
  2. No es invariante. Es un ejemplo: una entrada concreta con una salida exacta (6000). Útil como test, pero no es una propiedad.
  3. No es invariante. "Razonable" no es comprobable; no se evalúa a True/False de forma objetiva. Para volverlo propiedad habría que precisarlo ("está entre 0 y el precio de lista", por ejemplo).
  4. Invariante legítimo. "Para todo room y hours, price_cents(room, pro, hours) <= price_cents(room, basic, hours)". Es una relación con cuantificador universal; se llama propiedad metamórfica y la verás en la L4. Fíjate que no menciona ningún valor de salida concreto: habla de la relación entre dos salidas.

Ejercicio 2

Reproduce check_invariant.py de esta lección contra la implementación correcta de refund_cents y confirma las cero violaciones. Luego cambia una sola línea —el import— para apuntar a refunds_buggy (el bono sin tope) y vuelve a correr. Sin ejecutar todavía: ¿esperas cero violaciones también, o esperas contraejemplos? ¿Por qué? Después ejecútalo y confirma.

Ver solución

Contra la versión correcta, cero violaciones (silencio y 0), como en la lección: el invariante de rango se respeta en las 500 entradas, incluidas las cancelaciones tardías.

Contra refunds_buggy esperas contraejemplos, y muchos. El generador usa random.uniform(-10, 200), así que produce montones de anticipaciones mayores a 48 horas, justo la región donde el bono sin tope reembolsa de más y rompe refund <= price_paid. Al ejecutarlo verás varias líneas de CONTRAEJEMPLO: y un conteo alto de violaciones. Es el mismo invariante, la misma anatomía, distinto código: la propiedad calla con el correcto y grita con el roto. Ese contraste —no el silencio ni el grito por separado— es lo que la vuelve un buen test.

Ejercicio 3

Elige una función tuya o una conocida (por ejemplo, sorted(lista) de Python, que ordena una lista) y enuncia por escrito tres invariantes de su salida, en la forma "para toda entrada válida, se cumple...". No los programes; solo enúncialos con cuidado, evitando valores concretos.

Ver solución

Para sorted, tres invariantes clásicos y bien formados:

  1. Orden. Para toda lista de entrada, la lista devuelta está ordenada de menor a mayor: cada elemento es menor o igual que el siguiente.
  2. Longitud. Para toda lista de entrada, la lista devuelta tiene la misma cantidad de elementos que la de entrada (ordenar no agrega ni quita).
  3. Permutación (mismos elementos). Para toda lista de entrada, la lista devuelta contiene exactamente los mismos elementos que la de entrada, solo que reacomodados —ninguno aparece de la nada ni desaparece—.

Fíjate en tres cosas. Primera: ninguno menciona una lista concreta ni un resultado exacto; todos son reglas con "para toda". Segunda: los tres juntos acorralan la corrección mucho mejor que uno solo —el invariante de orden por sí mismo lo cumpliría una función que devuelve [] siempre (una lista vacía está "ordenada"), pero esa función viola el de longitud y el de permutación—. Tercera: esto es exactamente el patrón que aplicarás a refund_cents en el mini-proyecto del módulo. Buscar varios invariantes que se cubran los huecos entre sí es el oficio central de la L4.

Resumen y siguiente paso

Lo esencial de esta lección:

  • Una propiedad es una afirmación con cuantificador universal: "para toda entrada válida x, se cumple P(x)", donde P es una regla comprobable, no un valor concreto.
  • Tres palabras la definen: "para toda" (el salto de punto a espacio), "entrada válida" (el dominio de la función) y una P(x) verdadera o falsa (el invariante).
  • Un invariante es lo que no cambia por más que cambien las entradas: el reembolso varía, pero "está entre 0 y lo pagado" no varía. Pensar en invariantes es la habilidad más transferible del property-based.
  • Todo chequeo de propiedad tiene tres piezas: espacio de entradas, generador y aserción del invariante. Nómbralas y tendrás un checklist de diagnóstico.
  • Lo vimos cumplirse: el invariante de rango de la refund_cents correcta pasó limpio sobre 500 entradas, incluidas cancelaciones tardías. Una propiedad no es una máquina de fallar; es una máquina de verificar una regla, que calla cuando se respeta.
  • Una propiedad es más débil (no fija el valor exacto) y más fuerte (cubre todo el espacio) que un ejemplo. No compiten: se complementan. Y una sola propiedad rara vez basta; se combinan varias más algunos ejemplos.

Ya tienes la definición y la anatomía. Ahora falta la parte artesanal: aprender a encontrar las propiedades escondidas en una función que ya conoces, porque enunciarlas bien es más difícil que escribir un ejemplo. Eso es exactamente lo próximo.

En la siguiente lección salimos a cazar propiedades en Reservo. Vas a ver tres patrones que aparecen una y otra vez —el invariante de rango y la monotonía de refund_cents, la simetría de overlaps, la metamórfica de price_cents— y vas a verificarlos sobre cientos de casos aleatorios. Es donde la teoría de esta lección se vuelve un instinto para mirar código. Sigamos.

Recursos