Módulo 7: Otras técnicas avanzadas

1. Presentación del módulo: la caja de herramientas más allá de Hypothesis

Descripción

Durante seis módulos tuviste una sola protagonista: Hypothesis, y la idea de que una propiedad vale para toda entrada. Es una idea poderosa y, si sales de esta guía sabiendo encontrar propiedades y leer un contraejemplo mínimo, ya te llevas lo esencial. Pero sería un error creer que "testing avanzado" empieza y termina en el property-based. Alrededor de esa técnica hay un ramo de herramientas que un tester con oficio usa a diario, y que no dependen de Hypothesis para nada: parametrización sofisticada, fixtures que fabrican datos a pedido, una forma de medir si tu propia suite sirve, y una familia entera —el fuzzing— que es prima hermana del property-based. Este módulo es esa caja de herramientas.

Al terminar vas a manejar seis técnicas nuevas, cada una útil por su cuenta y cada una ejecutada de verdad sobre Reservo. Vas a llevar parametrize más allá del uso simple que ya conocías —parametrización indirecta, casos etiquetados y marcados con pytest.param, apilar decoradores para cubrir todas las combinaciones—. Vas a escribir fixtures avanzadas —una fábrica que construye reservas a pedido, y una fixture parametrizada que multiplica tus tests por cada variante—. Vas a probar el mutation testing, la técnica que le mete bugs a tu código para preguntar si tu suite los caza. Y vas a entender el fuzzing y por qué el property-based es, en el fondo, "fuzzing con propiedades".

Conexión con el módulo: esta lección es el mapa. No enseña ninguna técnica a fondo —cada una tiene su lección propia—; te da la vista de pájaro de las seis, agrupadas en cuatro familias, con una probada ejecutada de cada una para que veas el sabor. Es también la bisagra entre lo que fue la guía (Hypothesis, del M2 al M6, con el M6 modelando el Calendar como máquina de estados) y lo que viene (el M8, el capstone, donde usarás todo para cazar un bug real). Este módulo se sitúa en medio a propósito: cierra el aprendizaje de técnicas antes de que las pongas todas juntas a trabajar.

Una analogía: el cinturón del carpintero, no un martillo mejor

Piensa en un carpintero que durante meses aprendió a usar una sierra circular. Se volvió buenísimo con ella: corta rápido, recto, seguro. Pero un carpintero de oficio no lleva al taller solo una sierra excelente; lleva un cinturón con formón, escuadra, nivel, prensa, lija. No porque la sierra sea mala, sino porque hay trabajos que la sierra no hace: una junta a inglete pide escuadra, una superficie plana pide nivel, sujetar dos piezas mientras pega el cola pide prensa. Cada herramienta resuelve un problema que las otras no tocan.

Hypothesis fue tu sierra circular: la aprendiste a fondo y resuelve una clase enorme de problemas. Este módulo es el resto del cinturón. parametrize avanzado es la escuadra para cortar una tabla de casos limpia. Las fixture factories son la prensa que sujeta los datos que cada test necesita. El mutation testing es el nivel que verifica que tu trabajo esté derecho —no la madera, sino tu propia medición—. El fuzzing es una sierra distinta, de otra marca, que corta parecido pero por otro principio. Ninguna reemplaza a Hypothesis; cada una cubre un hueco que Hypothesis deja. La marca del oficio no es dominar una herramienta, sino mirar el problema y saber cuál del cinturón sacar.

Las cuatro familias del módulo

Antes de tocar código, quédate con el mapa. Las seis técnicas se agrupan en cuatro familias, y las lecciones siguen ese orden.

  1. Parametrize sofisticado (lecciones 2 y 3). Ya sabes lo básico: @pytest.mark.parametrize corre el mismo test con varios valores. Aquí subimos: la parametrización indirecta hace que el valor pase por una fixture antes de entrar al test (para construir un objeto a partir de un dato crudo); pytest.param le pone un nombre legible a cada caso y le cuelga marcas (xfail para un fallo esperado, skip para saltarlo); y apilar varios @parametrize genera el producto cartesiano de todos los valores, ideal para cubrir todas las salas × todos los socios × todas las duraciones de Reservo.

  2. Fixtures avanzadas (lecciones 4 y 5). Una fixture normal devuelve un valor. Aquí van dos saltos: la fixture factory devuelve una función, una fábrica que construye datos a pedido (varios Bookings distintos dentro de un mismo test, cada uno con los valores que ese test necesita); y la fixture parametrizada (params=...) corre cada test que la usa una vez por variante, sin tocar el test. De paso, el scope —cuántas veces se construye una fixture: por test, por módulo, por sesión—.

  3. Mutation testing (lección 6). Un giro de perspectiva: en vez de medir si tu código es correcto, mides si tu suite sirve. Le inyectas un bug al código —un mutante— y corres la suite. Si algún test falla, la suite "mató" al mutante: bien. Si todos pasan, el mutante sobrevivió: tu suite tiene un hueco justo ahí. Lo demostramos a mano mutando refund_cents y viendo una suite débil dejarlo pasar y una fuerte cazarlo. Es la técnica que mide la calidad de tus tests, no la de tu código.

  4. Fuzzing vs property-based (lección 7). El fuzzing lanza entradas al azar —incluso basura— para provocar caídas. Se parece al property-based (los dos generan entradas) pero difiere en qué buscan: el fuzzing clásico busca crashes, el property-based busca violaciones de una propiedad. Verás que Hypothesis es, técnicamente, un fuzzer que además chequea propiedades y encoge el contraejemplo al mínimo. "Fuzzing con propiedades" es una buena definición de una línea del property-based.

Con ese mapa en la cabeza, probemos el sabor de cada familia ejecutando de verdad.

Una probada ejecutada de cada familia

No vamos a explicar nada a fondo aquí —cada técnica tiene su lección—. La idea es solo que veas, con salida real, que las cuatro familias funcionan sobre Reservo. Reutilizamos el reservo.py de siempre (las funciones puras: Room, Member, Booking, price_cents, refund_cents, overlaps, y el Calendar con book/cancel).

Aquí hay cuatro archivos, uno por familia de las dos primeras (parametrize y fixtures), más un adelanto de las otras dos. Primero, un parametrize indirecto que construye una sala a partir de su tarifa, y una tabla de precios con casos etiquetados:

# test_indirect.py — parametrizacion indirecta y pytest.param
import pytest
from reservo import Room, Member, price_cents


@pytest.fixture
def room(request):
    """Construye una Room a partir del param: la tarifa por hora en centavos."""
    hourly = request.param
    return Room(id="r-focus", name="Focus", capacity=4, hourly_cents=hourly)


@pytest.mark.parametrize("room", [2500, 3000, 5000], indirect=True)
def test_basic_price_is_rate_times_hours(room):
    basic = Member(id="m-1", name="Ann", tier="basic")
    assert price_cents(room, basic, 2) == room.hourly_cents * 2

Segundo, dos fixtures avanzadas: una factory que fabrica reservas a pedido y una parametrizada que corre cada test por tier:

# test_factory.py (extracto) — una fixture que devuelve una FUNCION
@pytest.fixture
def make_booking():
    def _make(hours=2, price_cents=6000, status="confirmed", start=START):
        return Booking(id="bk", room_id="r-focus", member_id="m-1",
                       start=start, end=start + timedelta(hours=hours),
                       status=status, price_cents=price_cents)
    return _make


# test_param_fixture.py (extracto) — una fixture que corre una vez POR variante
@pytest.fixture(params=["basic", "pro"])
def member(request):
    tier = request.param
    return Member(id=f"m-{tier}", name=tier.title(), tier=tier)

Corremos los cuatro archivos de golpe. No mires todavía el detalle de cada técnica; mira solo que el conjunto corre y da verde:

Qué esperar. Guardas los cuatro archivos, corres python3 -m pytest -q, y ves una fila de puntos verdes con un solo caso marcado como fallo esperado (la x):

$ python3 -m pytest test_indirect.py test_stacking.py test_factory.py test_param_fixture.py -q
......x.........................                                          [100%]
31 passed, 1 xfailed in 0.08s

Detente un segundo en ese resumen. 31 passed salieron de solo un puñado de funciones de test: la parametrización y las fixtures parametrizadas multiplican una función en muchos casos. La x1 xfailed— es un caso que marcamos como "esperamos que falle" con pytest.param(..., marks=pytest.mark.xfail); pytest lo corre, ve que efectivamente falla, y lo cuenta como éxito del plan, no como error. Ese solo resumen ya te muestra dos ideas del módulo: una función de test puede volverse decenas de casos, y un caso puede declararse "fallo esperado" sin romper la suite.

Ahora la tercera familia, el mutation testing, con la probada más reveladora del módulo. Tomamos la suite de refund_cents (una tabla de casos por ejemplo) y le metemos un mutante al código: cambiamos la guarda >= 48 por > 48. Un cambio de un carácter. La pregunta del mutation testing no es "¿el código con esto está bien?" —claramente no—, sino "¿mi suite se da cuenta?".

$ python3 -m pytest test_suite_weak_mutant.py -v
test_suite_weak_mutant.py::test_refund_table[72-6000] PASSED             [ 33%]
test_suite_weak_mutant.py::test_refund_table[36-3000] PASSED             [ 66%]
test_suite_weak_mutant.py::test_refund_table[12-0] PASSED                [100%]

3 passed

Todo verde... contra el código con el bug. La suite no se dio cuenta: el mutante sobrevivió. Eso no habla mal del código (el código está roto, ya lo sabíamos); habla mal de la suite, que tiene un hueco —no prueba el borde exacto de las 48 horas, justo donde >= y > difieren—. Esa es toda la idea del mutation testing, y la lección 6 la desarrolla: un mutante que sobrevive es un test que te falta.

Y la cuarta familia, el fuzzing, con su contraste estrella. Lanzamos miles de entradas al azar a overlaps, primero solo mirando si se cae (fuzzing "tonto"), y luego chequeando además una propiedad (la simetría). Contra una versión con un bug de asimetría:

$ python3 fuzz_demo.py buggy
objetivo: buggy
  fuzzing tonto     -> caidas (crashes):        0
  fuzzing + propiedad -> violaciones de simetria: 144
  primer contraejemplo: a=[12,13) b=[1,8)  overlaps(a,b)=False  overlaps(b,a)=True

Míralo con calma, porque es el corazón de la lección 7. El fuzzer "tonto" —el que solo pregunta "¿se cayó?"— le da al código roto un certificado de buena salud: cero caídas. Nunca se cae, así que para un cazador de crashes está impecable. Pero el mismo fuzzer, cuando además chequea la propiedad de simetría, encuentra 144 violaciones. La entrada al azar era la misma; lo que cambió fue tener una propiedad que verificar. Eso es, exactamente, lo que Hypothesis hace por ti (y con más inteligencia, encogiendo el contraejemplo al mínimo). El property-based es fuzzing con propiedades.

Profundización: cuándo sacar cada herramienta del cinturón

El riesgo de un módulo de caja de herramientas es salir con seis martillos y ver clavos por todos lados. Conviene, desde el principio, tener un mapa de cuándo aplica cada una, para no forzarlas.

  • parametrize (simple, indirecto, apilado) es para cuando tienes una tabla de casos concretos que quieres correr con el mismo cuerpo de test. Casos-ancla de Reservo (72 h → 6000, 36 h → 3000), combinaciones de catálogo (todas las salas × todos los tiers). No genera entradas al azar como Hypothesis: enumera casos que eliges. Los dos conviven: parametrize para tus casos-ancla conocidos, Hypothesis para el espacio que no listaste.

  • Fixtures avanzadas (factory, parametrizada, scope) son para el montaje de datos y colaboradores: cuando construir lo que el test necesita es engorroso, se repite, o tiene variantes. La factory cuando un test necesita varios objetos distintos; la parametrizada cuando muchos tests necesitan correr sobre las mismas variantes; el scope para no reconstruir de más lo que es caro.

  • Mutation testing es para auditar tu suite, no para el día a día. Lo sacas cuando quieres saber si tus tests de verdad protegen el código o solo dan una sensación verde. Es una medición de segundo orden —mides la calidad de tu medición— y por eso se corre de vez en cuando (es lento), no en cada commit.

  • Fuzzing es para robustez ante entradas hostiles: parsers, deserializadores, cualquier cosa que reciba bytes del mundo. En lógica pura como Reservo, el fuzzing puro (cazar crashes) dice poco porque las funciones no se caen; ahí el property-based —fuzzing con propiedades— es la forma útil de la idea. Conocer los dos te deja ubicar Hypothesis en su linaje.

Una honestidad de encuadre: este es un módulo panorámico. Mutation y fuzzing los tratamos a nivel concepto y demo a mano —vas a entenderlos y verlos funcionar, pero no instalamos mutmut ni un fuzzer con instrumentación de cobertura; eso es material de tooling, de otra guía—. El parametrize y las fixtures, en cambio, sí los vas a usar completos y de verdad, porque son pytest puro y los vas a necesitar mañana mismo. El criterio del módulo: profundidad total en lo que usarás a diario, panorama honesto en lo que conviene conocer aunque lo configures otro día.

Errores comunes

Creer que "avanzado" siempre es "mejor". Qué pasa: alguien vuelve de este módulo y mete parametrize indirecto, factories y fixtures parametrizadas en un test que se resolvía con tres líneas planas. Por qué pasa: herramienta nueva, ganas de usarla. Cómo detectarlo: si tu andamiaje de test es más largo y difícil de leer que el test que reemplazó, te pasaste. Cómo corregirlo: la técnica avanzada se justifica cuando quita repetición o habilita algo imposible sin ella (varios objetos por test, todas las combinaciones), no cuando adorna. Un assert simple sigue siendo la mejor herramienta para un caso simple.

Confundir mutation testing con testing del código. Qué pasa: alguien ve el mutante sobrevivir y concluye "hay un bug en el código". Por qué pasa: el mutante es un bug, así que es natural mirar al código. Cómo detectarlo: recuerda que el mutante lo inyectaste tú a propósito; el código real está bien. Cómo corregirlo: el veredicto del mutation testing es sobre la suite, no sobre el código. Mutante que sobrevive = test que te falta. La acción correcta no es tocar el código, sino agregar el test que lo habría cazado.

Pensar que fuzzing y property-based son la misma cosa (o cosas sin relación). Qué pasa: dos errores opuestos —"es lo mismo" o "no tienen nada que ver"—. Por qué pasa: se parecen (generan entradas) pero suenan a mundos distintos (seguridad vs corrección). Cómo detectarlo: pregúntate qué busca cada uno. Cómo corregirlo: el fuzzing clásico busca crashes (sin oráculo: cualquier caída es un hallazgo); el property-based busca violaciones de una propiedad (con oráculo: la propiedad dice qué está bien). Hypothesis los une: es un fuzzer que además chequea propiedades. Ni idénticos ni ajenos: parientes.

Ejercicios

Ejercicio 1

Sin escribir código. Para cada situación de testing de abajo, di cuál de las cuatro familias del módulo —parametrize sofisticado, fixtures avanzadas, mutation testing, fuzzing/property-based— es la herramienta más natural, y por qué.

  1. Quieres correr el mismo test de precio sobre las 2 salas × los 2 tiers × las 3 duraciones del catálogo, sin escribir 12 funciones.
  2. Sospechas que tu suite de refund_cents da verde pero no probaría nada si el código se rompiera; quieres comprobarlo.
  3. Un test necesita crear tres reservas distintas —una barata, una cara, una gratis— y una fixture normal solo te da un objeto.
  4. Recibes un archivo de un usuario y quieres asegurarte de que tu parser no se cae ante bytes arbitrarios.
Ver solución
  1. Parametrize sofisticado (apilar @parametrize). El producto cartesiano de tres listas es exactamente para esto: tres decoradores apilados generan las 12 combinaciones desde una sola función (lección 3).
  2. Mutation testing. La pregunta "¿mi suite se daría cuenta si el código se rompiera?" es la definición del mutation testing: inyectas un bug y ves si algún test falla (lección 6).
  3. Fixtures avanzadas (una fixture factory). Cuando un test necesita varios objetos distintos, la fixture normal (un valor) no alcanza; la factory —una función que fabrica a pedido— sí (lección 4).
  4. Fuzzing. Entradas arbitrarias/basura para provocar caídas es fuzzing clásico. Como buscas robustez (que no se caiga), el fuzzing de crashes es la herramienta; si además tuvieras una propiedad ("parsear y volver a serializar da lo mismo"), sería property-based (lección 7).

Ejercicio 2

En la probada del mutation testing, la suite débil dio "3 passed" contra el código con el mutante >= 48> 48. Antes de leer la lección 6: ¿qué caso concreto le falta a esa suite para cazar ese mutante? Pista: ¿en qué valor exacto de horas de anticipación difieren >= 48 y > 48?

Ver solución

Le falta el caso del borde exacto de las 48 horas. >= 48 y > 48 se comportan igual en todos lados menos en un punto: cuando hours_until es exactamente 48. Con >=, 48 horas da reembolso completo (entra en la primera guarda); con >, 48 horas no entra en la primera guarda y cae a la segunda (>= 24), dando 50%. La suite débil probaba 72 h, 36 h y 12 h —ninguna es 48—, así que nunca toca el punto donde el mutante difiere del original, y por eso el mutante sobrevive. Agregar un caso (48, 6000) —48 horas debe dar reembolso completo— caza el mutante: contra el original pasa (da 6000), contra el mutante falla (da 3000). Ese es el hueco que el mutation testing te señaló. Lo verás ejecutado en la lección 6.

Ejercicio 3

En la probada del fuzzing, contra el código con bug el fuzzer "tonto" reportó 0 caídas pero el fuzzer con propiedad reportó 144 violaciones. Explica con tus palabras por qué el fuzzer de crashes le dio "buena salud" a un código que está roto, y qué le habría faltado para detectar el problema.

Ver solución

El fuzzer "tonto" solo pregunta una cosa: ¿la función lanzó una excepción? La versión con bug de overlaps está lógicamente rota (no es simétrica: dice que A pisa a B pero B no pisa a A), pero nunca se cae —siempre devuelve un booleano, sea el correcto o no—. Como el único criterio del fuzzer de crashes es "no se cayó", le da el visto bueno: cero caídas en 2000 intentos. El error no es una caída, es una respuesta incorrecta, y un cazador de crashes no mira las respuestas, solo mira si el programa sigue en pie.

Lo que le faltaba es un oráculo: una regla que diga qué respuesta es correcta. La propiedad de simetría (overlaps(a,b) == overlaps(b,a)) es ese oráculo: no necesita saber el valor bueno, solo que las dos llamadas coincidan. En cuanto el fuzzer chequea esa propiedad, las 144 violaciones aparecen. Moraleja: el fuzzing sin propiedad caza caídas; el fuzzing con propiedad —el property-based— caza además errores de lógica. Es justo lo que Hypothesis te da, y lo verás a fondo en la lección 7.

Resumen y siguiente paso

En esta lección desplegaste el mapa del módulo: "testing avanzado" no es solo property-based, sino un cinturón de herramientas que conviven con Hypothesis. Las agrupamos en cuatro familias —parametrize sofisticado (indirecto, pytest.param, apilado), fixtures avanzadas (factories, parametrizadas, scope), mutation testing (medir la suite inyectando bugs) y fuzzing vs property-based (crashes vs propiedades)— y probaste el sabor de cada una ejecutando de verdad: 31 casos verdes de parametrize y fixtures, un mutante que sobrevive a una suite débil, y un fuzzer que solo con una propiedad encuentra 144 violaciones que el de crashes no ve.

Sobre todo te llevas el criterio de cuándo sacar cada herramienta: parametrize para tablas de casos que eliges, fixtures para montar datos, mutation para auditar tu suite, fuzzing para robustez ante entradas hostiles. Y la honestidad del encuadre: parametrize y fixtures a fondo porque los usarás mañana; mutation y fuzzing a nivel concepto y demo, porque conviene conocerlos aunque configures el tooling otro día.

Antes de avanzar deberías poder: nombrar las cuatro familias y decir para qué sirve cada una; explicar por qué un mutante que sobrevive habla de tu suite y no de tu código; y por qué el property-based es "fuzzing con propiedades".

Empezamos por la herramienta que más vas a usar y la más cercana a lo que ya sabes. En la lección 2 llevamos parametrize más allá del uso simple: la parametrización indirecta, que hace pasar cada valor por una fixture antes de llegar al test, y pytest.param, que le pone nombre legible a cada caso y le cuelga marcas como xfail. Vamos a construir la tabla de precios de Reservo con casos etiquetados y un fallo esperado declarado. Sigamos.

Recursos