Módulo 4: Encontrar propiedades — los patrones
6. El patrón metamórfico
Descripción
Los tres patrones anteriores necesitaban algo conocido con lo que comparar: el invariante compara la salida contra una franja fija, el round-trip contra el punto de partida, el oráculo contra otra implementación. Pero, ¿qué haces cuando no tienes ninguno de los tres? Cuando el valor exacto es difícil de calcular a mano, no hay inversa y no tienes una segunda implementación confiable. Ese es el terreno del patrón metamórfico, y es a la vez el más sutil y el que más bugs de lógica encuentra.
La idea es esta: aunque no sepas cuánto vale la salida para una entrada dada, muchas veces sabes con certeza cómo debe cambiar la salida si mueves la entrada de una forma controlada. No sabes cuánto cuesta exactamente una reserva de 17 horas con descuento pro en una sala de 3300 centavos —tendrías que hacer la cuenta—, pero sabes, sin dudarlo, que cuesta más que una de 16 horas y menos que lo que pagaría un socio basic por lo mismo. Esas certezas sobre la dirección del cambio son propiedades, y no necesitan un solo valor exacto. El patrón dispara la pregunta 5 del cuestionario: "si muevo la entrada así, ¿cómo debe moverse la salida?". Al terminar esta lección vas a saber formular relaciones metamórficas, escribirlas con Hypothesis sobre price_cents y refund_cents, y entender por qué encuentran bugs que los otros patrones dejan pasar.
Conexión con el módulo: esta es la cuarta lección de patrón, y cierra el grupo de los que "comparan la salida contra algo". La metamórfica ya asomó dos veces: en la lección 1, cuando viste pro <= basic entre los cinco patrones, y en la lección 2, cuando la pregunta 5 destapó la monotonía de refund_cents (cancelar antes nunca reembolsa menos). Aquí la desarrollamos a fondo. Es también la propiedad canónica de price_cents según el diseño de la guía, así que es el hogar natural de esta función. La siguiente lección, la idempotencia, cierra el catálogo con el patrón más específico.
Una analogía: la báscula que no conoces pero en la que confías
Imagina que te subes a una báscula digital vieja, de esas que no sabes si están bien calibradas. Marca 71.4 kg. ¿Es correcto? No tienes idea —quizás está descalibrada y en realidad pesas 70 u 73—. No puedes verificar el valor absoluto sin un peso patrón.
Pero hay algo que sí puedes verificar sin conocer tu peso real. Si te subes con una mochila de libros en la mano, la báscula debe marcar más que sin la mochila. No sabes cuánto más exactamente (depende de los libros), pero sabes la dirección: agregar peso nunca puede hacer que la báscula marque menos. Si te subes con la mochila y marca menos que sin ella, la báscula está rota, y lo sabes con certeza absoluta, aunque nunca hayas conocido tu peso verdadero. Del mismo modo: si dos personas se pesan juntas, la báscula debe marcar la suma de sus pesos individuales, más o menos. Otra relación que puedes comprobar sin un peso patrón.
Eso es el testing metamórfico. No verificas el valor de salida (tu peso, que no conoces); verificas la relación entre salidas cuando cambias la entrada de forma conocida (agregar la mochila debe subir la lectura). La palabra "metamórfico" viene de metamorfosis: transformas la entrada de una manera controlada y afirmas cómo se transforma la salida. Es la herramienta para probar funciones cuyo valor exacto no puedes predecir pero cuyo comportamiento ante cambios sí conoces. Y resulta que casi toda función de negocio —precios, reembolsos, rankings, recomendaciones— tiene relaciones metamórficas obvias, aunque su valor exacto sea un enredo.
Ejemplo trabajado: price_cents y sus dos metamórficas
price_cents(room, member, hours) calcula el precio de una reserva: hourly_cents * hours, menos 20% si el socio es pro. Su valor exacto depende de tres cosas y, para una sala de precio raro y muchas horas con descuento, no lo sabes de memoria. Pero tiene dos relaciones metamórficas que conoces con total certeza.
Primera: cambiar de basic a pro nunca sube el precio. Para el mismo room y las mismas horas, un socio pro paga menos o igual que un basic —el descuento nunca encarece—. No importa cuál sea el precio exacto; pro <= basic siempre.
Segunda: más horas nunca baja el precio. Para el mismo room y el mismo socio, aumentar las horas no puede reducir el precio —cada hora extra cuesta cero o más, nunca negativo—. Otra vez, sin conocer el valor: más horas ⇒ precio ≥.
# test_metamorphic.py — las dos metamorficas de price_cents
from hypothesis import given, strategies as st
from reservo import Room, Member, price_cents
rooms = st.builds(
Room,
id=st.just("r-focus"), name=st.just("Focus"),
capacity=st.integers(min_value=1, max_value=20),
hourly_cents=st.integers(min_value=0, max_value=1_000_000),
)
BASIC = Member(id="m-1", name="Ana", tier="basic")
PRO = Member(id="m-2", name="Beto", tier="pro")
@given(room=rooms, hours=st.integers(min_value=0, max_value=24))
def test_pro_never_pays_more_than_basic(room, hours):
# Relacion metamorfica: cambiar basic -> pro nunca SUBE el precio.
assert price_cents(room, PRO, hours) <= price_cents(room, BASIC, hours)
@given(
room=rooms,
hours=st.integers(min_value=0, max_value=24),
extra=st.integers(min_value=0, max_value=24),
member=st.sampled_from([BASIC, PRO]),
)
def test_more_hours_never_lowers_the_price(room, hours, extra, member):
# Relacion metamorfica: agregar horas nunca BAJA el precio.
assert price_cents(room, member, hours + extra) >= price_cents(room, member, hours)
Detente en la estructura, porque es distinta de los patrones anteriores. En una metamórfica, llamas a la función dos veces —una con la entrada original, otra con la entrada transformada— y comparas las dos salidas. En la primera propiedad, las dos llamadas comparten room y hours pero cambian el socio (PRO vs BASIC); afirmamos pro <= basic. En la segunda, las dos llamadas comparten room y member pero una tiene hours y la otra hours + extra (con extra >= 0); afirmamos que la de más horas es mayor o igual. Ninguna de las dos propiedades menciona un valor de salida: solo la relación entre dos salidas.
Fíjate también en un truco de la segunda: en vez de generar dos cantidades de horas independientes y compararlas, generamos hours y un extra >= 0, y comparamos hours + extra contra hours. Así garantizamos por construcción que la segunda entrada tiene más o iguales horas que la primera, sin tener que filtrar los casos donde una es mayor. Es la misma idea que usamos en la lección 2 para la monotonía de refund_cents (construir now_early restando a now_late): fabricar la relación en la entrada en vez de descartar los casos que no la cumplen.
Qué esperar. Guardas el archivo, corres python3 -m pytest test_metamorphic.py -v, y ves dos puntos verdes —doscientos casos en total, y en ninguno el pro pagó de más ni las horas extra bajaron el precio:
$ python3 -m pytest test_metamorphic.py -v
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0 -- /private/tmp/m4work/.venv/bin/python
cachedir: .pytest_cache
hypothesis profile 'default'
rootdir: /private/tmp/m4work
plugins: hypothesis-6.161.2
collecting ... collected 2 items
test_metamorphic.py::test_pro_never_pays_more_than_basic PASSED [ 50%]
test_metamorphic.py::test_more_hours_never_lowers_the_price PASSED [100%]
============================== 2 passed in 0.17s ===============================
Dos metamórficas en verde. Y lo notable: no escribimos ni un solo == 6000 ni == 7500. Probamos que el descuento se comporta bien —nunca encarece— y que el precio escala con las horas —nunca decrece—, todo sin conocer un solo precio exacto. Esa es la magia del patrón: prueba la lógica de la función (cómo responde a los cambios) en vez de sus valores (cuánto da para cada entrada).
La metamórfica cazando un bug: el recargo premium
La prueba de fuego. Imagina que alguien, por error, convierte el "descuento pro" en un "recargo premium": en vez de restarle al pro, le suma 100 centavos como si el tier pro fuera un servicio de lujo que se paga aparte. Es un bug de signo plausible —confundir descuento con recargo—.
def price_cents_buggy(room, member, hours):
subtotal = room.hourly_cents * hours
if member.tier == "pro":
subtotal += 100 # BUG: 'cargo premium' para pro, en vez de descuento
return subtotal
Corremos la primera metamórfica (pro <= basic) contra esta versión. Ahora el pro paga más que el basic, así que la relación debería romperse.
Qué esperar. Rojo, con el ejemplo falsificador reducido al caso más simple imaginable:
$ python3 -m pytest test_metamorphic_buggy.py
=================================== FAILURES ===================================
_____________________ test_pro_never_pays_more_than_basic ______________________
room = Room(id='r-focus', name='Focus', capacity=1, hourly_cents=0), hours = 0
@given(room=rooms, hours=st.integers(min_value=0, max_value=24))
def test_pro_never_pays_more_than_basic(room, hours):
> assert price_cents_buggy(room, PRO, hours) <= price_cents_buggy(room, BASIC, hours)
E AssertionError: assert 100 <= 0
E + where 100 = price_cents_buggy(Room(id='r-focus', name='Focus', capacity=1, hourly_cents=0), Member(id='m-2', name='Beto', tier='pro'), 0)
E + and 0 = price_cents_buggy(Room(id='r-focus', name='Focus', capacity=1, hourly_cents=0), Member(id='m-1', name='Ana', tier='basic'), 0)
E Failing test case: test_pro_never_pays_more_than_basic(
E room=Room(id='r-focus', name='Focus', capacity=1, hourly_cents=0),
E hours=0,
E )
Léelo. Hypothesis redujo el fallo al caso más limpio posible: una sala de precio por hora cero y cero horas. Con esa entrada, un basic paga 0 (nada por hora, ninguna hora) y un pro paga 100 (los 0 del subtotal más el recargo premium erróneo). La relación pro <= basic se convierte en 100 <= 0, que es falsa. El caso mínimo desnuda el bug con crueldad: incluso cuando no hay nada que cobrar (sala gratis, cero horas), el pro paga 100 de más. Eso hace evidente que el problema no está en el cálculo del subtotal ni en las horas, sino en ese += 100 que nunca debió existir.
Fíjate en algo importante: el invariante de signo (price >= 0) que vimos en la lección 3 no habría cazado este bug. price_cents_buggy nunca devuelve un negativo —sumar 100 a algo no-negativo da algo no-negativo—, así que el invariante >= 0 pasaría en verde con la versión rota. La metamórfica sí lo caza, porque no mira el signo de una salida aislada, mira la relación entre la salida del pro y la del basic. Este es el argumento central del patrón: la metamórfica encuentra bugs de lógica de negocio que los invariantes de rango dejan pasar, porque prueba cómo se relacionan las salidas entre sí, no solo dónde cae cada una.
Profundización: por qué la metamórfica es tan potente (y cómo se te ocurren)
El testing metamórfico tiene una historia interesante y una razón de fondo para su potencia. Nació precisamente para probar programas donde no existe un oráculo —programas científicos, motores de búsqueda, compiladores— porque nadie sabe la salida correcta exacta. ¿Cómo pruebas un buscador si no sabes cuáles "deberían" ser los diez mejores resultados para una consulta? No puedes verificar el valor, pero sí una relación: si agregas una palabra más específica a la consulta, el número de resultados no debería aumentar. Esa es una relación metamórfica, y con ella pruebas el buscador sin conocer la respuesta "correcta". El nombre técnico de esas transformaciones —cambiar la entrada de forma controlada y afirmar cómo cambia la salida— es relaciones metamórficas.
La razón de su potencia es que ataca la lógica directamente. Un invariante de rango dice "la salida cae en [0, X]", que es una restricción débil: muchas funciones rotas la cumplen (recuerda la que devuelve siempre 0). Una relación metamórfica dice "si el pro debería costar menos, entonces pro <= basic", que codifica una regla de negocio específica. Romper esa regla requiere un bug en esa lógica exacta, no un desbordamiento genérico. Por eso la metamórfica es tan buena cazando errores de lógica de dominio: cada relación metamórfica es una regla de negocio traducida a una comparación entre dos llamadas.
¿Cómo se te ocurren las relaciones metamórficas? Hay unos cuantos "moldes de transformación" que aparecen una y otra vez, y conviene tenerlos a mano:
- Monotonía: si aumento una entrada, la salida sube (o baja, o no cambia de dirección). Más horas ⇒ precio ≥; cancelar antes ⇒ reembolso ≥; consulta más específica ⇒ menos resultados.
- Orden entre variantes: una versión de la entrada siempre produce una salida ordenada respecto a otra. Pro ≤ basic; premium ≥ estándar; con cupón ≤ sin cupón.
- Escala: si multiplico la entrada por algo, la salida se multiplica de forma predecible. Doble de horas ⇒ (con precio lineal) doble de precio, o al menos ≥.
- Permutación / simetría: reordenar la entrada no cambia la salida, o la cambia de forma conocida.
overlaps(a, b) == overlaps(b, a)(el orden de los dos rangos no importa); ordenar una lista dos veces da lo mismo. - Combinación: la salida de las partes se relaciona con la salida del todo. El precio de A más el de B se relaciona con el precio de A y B juntos.
Cuando busques una metamórfica, recorre estos moldes preguntándote: ¿esta función es monótona en alguna entrada? ¿hay variantes ordenadas (tiers, planes)? ¿escala? ¿es simétrica en algún argumento? Casi siempre, al menos uno de los moldes aplica y te entrega una propiedad sin que tengas que conocer un solo valor exacto.
Una nota sobre overlaps y la simetría, que es una metamórfica que no vimos en el oráculo. overlaps(a_start, a_end, b_start, b_end) == overlaps(b_start, b_end, a_start, a_end): intercambiar los dos rangos no cambia si se solapan (solaparse es una relación simétrica). Esa es una metamórfica de permutación, y complementa el oráculo de la lección 5: el oráculo prueba que overlaps da el valor correcto; la simetría prueba que trata a los dos rangos por igual. Dos patrones, dos ángulos, la misma función mejor cubierta.
Errores comunes
Meter un valor exacto en una metamórfica. Qué pasa: alguien intenta afirmar price_cents(room, PRO, hours) == price_cents(room, BASIC, hours) * 80 // 100 en vez de <=. Por qué pasa: la costumbre de calcular el valor exacto. Cómo detectarlo: si tu metamórfica reproduce la fórmula interna de la función (el * 80 // 100), ya no es una metamórfica, es un oráculo frágil que se rompe si cambias la implementación. Cómo corregirlo: una metamórfica afirma la dirección o el orden (<=, >=), no el valor exacto de la relación. pro <= basic sobrevive a cualquier cambio del porcentaje de descuento; pro == basic * 80 // 100 no.
Comparar salidas de entradas que no están relacionadas de forma controlada. Qué pasa: alguien genera dos cantidades de horas independientes y afirma que el precio de una es menor que el de la otra, y la propiedad falla porque a veces la primera es mayor. Por qué pasa: se olvida de construir la relación en la entrada. Cómo detectarlo: si tu metamórfica falla de forma "aleatoria" según qué entrada salió mayor, no controlaste la transformación. Cómo corregirlo: fabrica la relación en la entrada —genera hours y extra >= 0, compara hours + extra contra hours— en vez de generar dos valores sueltos y esperar que salgan en el orden correcto.
Creer que una metamórfica reemplaza a los invariantes y los ejemplos. Qué pasa: alguien encuentra la metamórfica pro <= basic, la escribe, y da price_cents por completamente probada. Por qué pasa: la metamórfica se siente muy potente. Cómo detectarlo: la metamórfica pro <= basic la cumple una función que devuelve siempre 0 para todos (0 <= 0), aunque esté rota. Cómo corregirlo: combina. La metamórfica prueba la lógica relacional; el invariante de signo prueba el rango; un ejemplo-ancla (price_cents(Focus, pro, 3) == 6000) fija un valor conocido. Los tres juntos acorralan price_cents; ninguno solo basta. Es, otra vez, la lección de fondo del módulo.
Ejercicios
Ejercicio 1
Reproduce el test_metamorphic.py de esta lección y confirma los dos verdes. Luego introduce el price_cents_buggy (el del recargo premium += 100) y corre solo la primera metamórfica (pro <= basic) contra él. Sin ejecutar: ¿cuál esperas que sea el caso mínimo? Después ejecútalo y confirma que Hypothesis reduce a hourly_cents=0, hours=0 con 100 <= 0.
Ver solución
Contra el price_cents correcto, dos verdes: pro nunca paga más que basic, y más horas nunca baja el precio.
Contra price_cents_buggy, rojo. El caso mínimo es hourly_cents=0, hours=0: con sala gratis y cero horas, el basic paga 0 y el pro paga 100 (el subtotal de 0 más el recargo premium erróneo), y 100 <= 0 es falso. Hypothesis reduce a esos valores porque son los más simples que exponen el problema: el bug del += 100 no depende del precio por hora ni de las horas, así que el caso mínimo los pone en cero. La observación clave del ejercicio: el invariante de signo (price >= 0) no habría cazado este bug —100 es no-negativo—; solo la metamórfica, que compara pro contra basic, lo detecta.
Ejercicio 2
refund_cents también tiene una metamórfica, que ya viste en la lección 2: cancelar antes nunca reembolsa menos. Enuncia otra metamórfica distinta de refund_cents, esta vez sobre el monto pagado en vez del tiempo. Pista: si dos socios cancelan con la misma anticipación pero uno pagó más que el otro, ¿qué relación tienen sus reembolsos?
Ver solución
La metamórfica sobre el monto: para el mismo booking y el mismo now (misma anticipación), si un socio pagó más que otro, su reembolso es mayor o igual. Formalmente, si paid_a >= paid_b, entonces refund_cents(booking, paid_a, now) >= refund_cents(booking, paid_b, now). Tiene sentido: el reembolso es un porcentaje del pagado (100%, 50% o 0%), y un porcentaje fijo de un monto mayor es mayor o igual que el mismo porcentaje de un monto menor.
Para escribirla con Hypothesis, fabricas la relación en la entrada como siempre: generas paid_b y un extra >= 0, y comparas refund_cents(booking, paid_b + extra, now) contra refund_cents(booking, paid_b, now), afirmando >=. Es la metamórfica de monotonía en el monto, hermana de la de monotonía en el tiempo de la lección 2. Que refund_cents tenga dos metamórficas (una en el tiempo, otra en el dinero) además de su invariante de rango es, una vez más, la prueba de que una función esconde varios patrones.
Ejercicio 3
Elige una función que uses fuera de Reservo y enuncia dos relaciones metamórficas usando los moldes de la profundización (monotonía, orden entre variantes, escala, permutación/simetría, combinación). Sugerencias: len(lista), max(lista), una función de búsqueda que devuelve resultados, sorted.
Ver solución
Un ejemplo con max(lista) (el máximo de una lista no vacía):
- Monotonía (agregar): agregar un elemento a la lista nunca baja el máximo.
max(lista + [x]) >= max(lista). Molde de monotonía: crecer la entrada no reduce la salida. - Permutación: reordenar la lista no cambia el máximo.
max(lista) == max(lista_barajada). Molde de simetría: el orden de la entrada no importa. - Combinación: el máximo de dos listas juntas es el mayor de los dos máximos.
max(a + b) == max(max(a), max(b)). Molde de combinación: la salida del todo se relaciona con la de las partes.
Un ejemplo con una búsqueda (una función search(query) que devuelve una lista de resultados):
- Monotonía (especificidad): hacer la consulta más específica (agregar una palabra) nunca aumenta el número de resultados.
len(search(query + " palabra")) <= len(search(query)). Es la metamórfica clásica de los buscadores, y el ejemplo histórico que motivó el testing metamórfico —probar un buscador sin conocer los resultados "correctos"—.
Fíjate en que ninguna de estas propiedades necesita conocer el valor exacto de la salida. Ese es el poder del patrón: prueba la lógica de funciones cuyo resultado exacto no sabrías predecir a mano.
Resumen y siguiente paso
En esta lección estrenaste el patrón metamórfico: cuando no conoces el valor exacto de la salida pero sí sabes cómo debe cambiar al mover la entrada de forma controlada. Lo disparaste con la pregunta "si muevo la entrada así, ¿cómo se mueve la salida?", lo aplicaste a las dos metamórficas de price_cents (pro <= basic; más horas ⇒ precio ≥) y las viste en verde sin escribir un solo valor exacto. Sobre todo, lo viste cazar el recargo premium erróneo —un bug de lógica que el invariante de signo dejaba pasar— porque la metamórfica compara la salida del pro contra la del basic en vez de mirar cada una aislada.
Aprendiste por qué el patrón es tan potente: ataca la lógica de negocio directamente, traduciendo cada regla ("el pro paga menos") a una comparación entre dos llamadas. Y conociste los moldes que hacen que se te ocurran —monotonía, orden entre variantes, escala, permutación/simetría, combinación— para no volver a quedarte sin ideas frente a una función cuyo valor exacto no puedas predecir. Con la simetría de overlaps viste que una metamórfica puede complementar a un oráculo sobre la misma función.
Antes de avanzar deberías poder: formular una relación metamórfica sin usar valores exactos; fabricar la relación en la entrada (generar hours y extra >= 0); explicar por qué la metamórfica caza bugs que el invariante de rango no; y recorrer los moldes de transformación para inventar propiedades.
Queda un patrón para cerrar el catálogo, el más específico de los cinco: la idempotencia. En la lección 7 verás qué significa que aplicar una operación dos veces dé lo mismo que aplicarla una —cancel idempotente, un clamp que recorta al rango— y el diseño alternativo del cancel que lanza a la segunda, con su propia propiedad de guarda. Sigamos.
Recursos
- Metamorphic testing — Wikipedia — el fundamento teórico del patrón: cómo probar programas sin oráculo (buscadores, compiladores, software científico) mediante relaciones metamórficas. El contexto histórico de lo que viste aquí.
- Stateless Properties — PropEr Testing (Fred Hébert) — trata la "generalización" de tests y las relaciones entre entradas y salidas, que es el corazón del patrón metamórfico.
hypothesis.strategies— referencia oficial —st.sampled_from(para elegir entreBASICyPRO) yst.integers(para elextra >= 0que fabrica la relación monótona) son las piezas con las que se escribe una metamórfica.