wandres.dev
TESTING · unit, integration, doc, property

Property testing y fuzzing: miles de entradas contra tus invariantes

Un test de ejemplo comprueba un caso que tú elegiste; un test de propiedad comprueba una ley que debe cumplirse para infinitas entradas que la máquina genera por ti. proptest y quickcheck buscan contraejemplos y los reducen al mínimo; cargo-fuzz lanza millones de entradas guiadas por cobertura contra la superficie más frágil de tu código.

⏱ 20 min

Un test unitario clásico afirma un caso concreto: duplica(21) da 42. Es útil, pero comparte una debilidad con toda prueba basada en ejemplos: solo comprueba los casos que a ti se te ocurrieron, y los bugs viven precisamente en los que no se te ocurrieron —la cadena vacía, el i32::MIN, el vector de un millón de elementos, el UTF-8 mal formado—. El testing basado en propiedades invierte el planteamiento. En lugar de escribir entradas, escribes una ley que debe cumplirse para todas las entradas de un tipo, y la máquina genera cientos o miles de ellas intentando refutarte. Cuando lo consigue, hace algo aún más valioso que fallar: reduce el contraejemplo hasta su forma mínima. Y cuando quieres ir más lejos —lanzar millones de entradas guiadas por la cobertura contra tu parser o tu decodificador— entra el fuzzing con cargo-fuzz. Dos herramientas, una misma idea: dejar que la generación mecánica explore el espacio que tu imaginación no alcanza.

🎯 Al terminar esta lección sabrás
  • Distinguir el testing por ejemplos del basado en propiedades e identificar propiedades verificables.
  • Escribir pruebas con proptest, definir estrategias de generación y aprovechar la reducción de contraejemplos.
  • Contrastar proptest con quickcheck y el papel del trait Arbitrary.
  • Comprender el fuzzing guiado por cobertura con cargo-fuzz, libFuzzer y fuzz_target!.

De ejemplos a propiedades

El salto conceptual es dejar de preguntar “¿qué da esta entrada?” y empezar a preguntar “¿qué es siempre cierto?”. Esas verdades universales son las propiedades. Los arquetipos que conviene reconocer:

  • Ida y vuelta (roundtrip): decodificar lo que se codificó devuelve el original. parsear(serializar(x)) == x.
  • Idempotencia: aplicar dos veces es como aplicar una. normaliza(normaliza(x)) == normaliza(x).
  • Oráculo: una implementación rápida coincide con una lenta pero obviamente correcta.
  • Invariante preservada: una operación mantiene una propiedad estructural —ordenar no cambia la longitud ni el multiconjunto de elementos.

Con proptest, una propiedad se expresa dentro de la macro proptest!, declarando el tipo de las entradas que quieres que genere:

use proptest::prelude::*;

proptest! {
    #[test]
    fn ordenar_conserva_longitud(mut v in prop::collection::vec(any::<i32>(), 0..100)) {
        let n = v.len();
        v.sort();
        prop_assert_eq!(v.len(), n);
    }
}

El fragmento v in prop::collection::vec(any::<i32>(), 0..100) es una estrategia: describe cómo fabricar valores —un vector de entre cero y cien enteros cualesquiera—. proptest ejecutará el cuerpo cientos de veces con vectores distintos, y prop_assert_eq! es la aserción que, al romperse, dispara la búsqueda del mínimo.

Reducción: del caos al contraejemplo mínimo

La reducción (shrinking) es lo que separa una herramienta de propiedades de un simple generador aleatorio. Imagina una propiedad falsa que se rompe con un vector de ochenta números enormes. Sin reducción, verías esos ochenta números y no sabrías cuál importa. Con ella, al hallar un fallo, proptest simplifica la entrada sistemáticamente —acortando el vector, acercando los números a cero— y reejecuta, quedándose con la versión más pequeña que sigue fallando:

proptest! {
    #[test]
    fn suma_nunca_desborda(a in any::<i32>(), b in any::<i32>()) {
        let _ = a + b;   // fallara y se reducira hacia a=1, b=i32::MAX o similar
    }
}

El contraejemplo mínimo suele señalar la causa con el dedo: aquí, el desbordamiento aritmético en el límite del i32. Además, proptest guarda la semilla que provocó el fallo en un fichero proptest-regressions/; ese caso se reejecuta en cada corrida futura, de modo que un bug encontrado una vez no vuelve a esconderse.

quickcheck, la alternativa histórica inspirada en Haskell, persigue lo mismo con otra ergonomía: en lugar de estrategias explícitas, se apoya en el trait Arbitrary, que sabe generar y reducir valores de un tipo. Escribes una función que devuelve bool (o TestResult) y el tipo de sus argumentos elige el generador:

#[quickcheck]
fn invertir_dos_veces_es_identidad(xs: Vec<u8>) -> bool {
    let mut doble = xs.clone();
    doble.reverse();
    doble.reverse();
    doble == xs
}
💡
La propiedad no reemplaza al ejemplo: lo complementa

El testing de propiedades no jubila a las pruebas por ejemplo, y quien lo trata como sustituto se equivoca. Un ejemplo concreto documenta intención —este caso importa y esto es lo que debe pasar— y sirve de regresión legible. Una propiedad captura una ley y explora el espacio. Los mejores proyectos usan ambos: ejemplos para los casos canónicos y los bugs históricos, propiedades para barrer los infinitos casos que nadie enumeraría a mano.

🧬

proptest

Estrategias explícitas que describen cómo generar cada entrada. Reducción muy afinada. El estándar de facto hoy en Rust.

🎲

quickcheck

Generación por el trait Arbitrary, inferida del tipo del argumento. Herencia directa de Haskell: menos ceremonia, menos control.

💣

cargo-fuzz

libFuzzer guiado por cobertura sobre bytes crudos. Millones de entradas mutadas. Caza pánicos y comportamiento indefinido.

Fuzzing: millones de entradas guiadas por la cobertura

El testing de propiedades genera entradas estructuradas de un tipo. El fuzzing ataca la frontera más peligrosa —código que consume bytes crudos: parsers, decodificadores, deserializadores— con una estrategia distinta y brutal: generar millones de secuencias de bytes, mutándolas, y —el matiz decisivo— dejándose guiar por la cobertura. El fuzzer observa qué ramas del código activa cada entrada y evoluciona las que exploran territorio nuevo, penetrando así ramas que la aleatoriedad pura jamás alcanzaría.

En Rust, cargo-fuzz integra libFuzzer. Se instala y se define un objetivo:

cargo install cargo-fuzz
cargo fuzz init
cargo fuzz run mi_parser
// fuzz/fuzz_targets/mi_parser.rs
#![no_main]
use libfuzzer_sys::fuzz_target;

fuzz_target!(|data: &[u8]| {
    let _ = micrate::parsear(data);   // no debe entrar en panico ni provocar UB
});

Dos matices elevan el fuzzing de truco a disciplina. El primero es el corpus: libFuzzer mantiene un conjunto de entradas interesantes —las que descubrieron ramas nuevas— y lo reutiliza entre ejecuciones, de modo que cada sesión parte del conocimiento acumulado por las anteriores en vez de empezar de cero. El segundo es la continuidad: un fuzzer no es una prueba que pasa o falla una vez, sino un proceso que cuanto más corre, más profundo cava. Proyectos críticos lo integran en servicios como OSS-Fuzz, que lo ejecutan sin descanso durante días y reportan cada hallazgo con su entrada mínima reproducible.

La macro fuzz_target! recibe un &[u8] que libFuzzer muta sin descanso. El fuzzer declara victoria cuando encuentra una entrada que provoca un pánico, un desbordamiento detectado, un fallo de aserción o —crucial en código unsafe— comportamiento indefinido cazado por los sanitizers. Si tu objetivo, en vez de bytes crudos, necesita datos con forma, el crate arbitrary traduce el flujo de bytes en tipos estructurados vía el trait Arbitrary, tendiendo un puente entre el fuzzing y las entradas tipadas del testing de propiedades.

flowchart TD
L[Escribes una ley que vale para toda entrada] --> G[proptest genera cientos de entradas tipadas]
G --> H{Alguna refuta la ley}
H -->|no| OK[Propiedad se sostiene esta corrida]
H -->|si| S[Reduce el contraejemplo al minimo]
S --> R[Guarda la semilla en proptest-regressions]
B[Superficie que consume bytes crudos] --> F[cargo-fuzz con libFuzzer muta millones de entradas]
F --> COV[Guiado por cobertura penetra ramas nuevas]
COV --> UB[Caza panicos desbordamientos y comportamiento indefinido]
style L fill:#cba6f7,color:#11111b
style S fill:#a6e3a1,color:#11111b
style F fill:#89b4fa,color:#11111b
style UB fill:#f38ba8,color:#11111b
Delegar la imaginación de los casos límite a la máquina

Toda prueba basada en ejemplos comparte un techo invisible: no puede encontrar el bug que su autor no imaginó, porque el autor tuvo que escribir la entrada, y para escribirla tuvo antes que concebirla. Los casos límite que de verdad hunden un programa —la cadena vacía, el entero en su valor mínimo, la anidación de diez mil niveles, el byte que rompe la suposición tácita del parser— son justo los que la mente descarta por improbables o ni siquiera visita. El testing de propiedades y el fuzzing son, en el fondo, la misma renuncia liberadora: dejar de fingir que puedes enumerar el espacio de entradas y delegar esa enumeración a un proceso mecánico incansable. Pero el giro genial no es generar mucho, sino lo que se hace con el fallo. Un generador aleatorio que solo dijera falla sería casi inútil: te dejaría con una entrada monstruosa y ninguna pista. La reducción convierte el ruido en señal, destilando el contraejemplo hasta que su forma mínima es el diagnóstico, y la guía por cobertura del fuzzer convierte la fuerza bruta en una búsqueda dirigida que aprende del propio código que ataca. Hay aquí, además, una afinidad honda con el alma de Rust. La disciplina de pensar en propiedades —invariantes que deben sostenerse siempre— es la misma que anima al sistema de tipos, que no comprueba casos sino que demuestra leyes universales sobre todos los valores posibles. El fuzzing, por su parte, es la red de seguridad natural del código unsafe: donde el compilador deja de garantizar, un sanitizer bajo un millón de entradas vuelve a vigilar. La lección madura del testing es esta: hay un punto en que tu imaginación deja de ser el mejor generador de casos, y reconocerlo —ceder ese trabajo a la máquina y quedarte tú con la tarea más noble de enunciar las leyes— es señal de que has dejado de probar ejemplos para empezar a probar verdades.

⚔️ Deja que la máquina invente los casos que tú no verías
  1. Enuncia y prueba con proptest una propiedad de ida y vuelta sobre una función de serialización tuya: deserializar(serializar(x)) == x.
  2. Escribe una propiedad falsa a propósito (por ejemplo, que una suma nunca desborda) y observa el contraejemplo reducido; localiza el fichero proptest-regressions/ que guarda la semilla.
  3. Verifica una invariante de sort: que ordenar conserva la longitud y el multiconjunto de elementos. Usa una estrategia que genere vectores de tamaño variable.
  4. Reescribe una de esas propiedades con quickcheck usando Arbitrary; compara la ergonomía de las estrategias explícitas frente a la inferencia por tipo.
  5. Inicializa cargo fuzz sobre una función que parsee bytes, defínele un fuzz_target! que solo exija ausencia de pánico, y déjalo correr; si tienes datos con forma, introdúcelos con el crate arbitrary.