Tus Unit Tests Solo Cubren los Casos que Recordaste
Escribiste una función reverse. La probaste con [1, 2, 3] y [4, 5, 6, 7]. Pasa. La envías a producción.
Un usuario le pasa un slice de un solo elemento. Tu función lo descarta. Abren un issue. Miras el archivo de tests y te preguntas cómo se te pasó algo tan obvio.
Se te pasó porque el example-based testing solo atrapa bugs que anticipas. Cada assert_eq! en tu suite de tests es una suposición sobre dónde podría ocultarse el fallo. El property-based testing reemplaza esas suposiciones con datos aleatorios e invariantes matemáticas. Encontrará el bug del elemento único. También encontrará el bug del vector vacío, el caso límite de desbordamiento de enteros y el caso esquina de Unicode que no sabías que existía.
Inputs Aleatorios e Invariantes, No Ejemplos y Expectativas
El property-based testing cambia las reglas. En lugar de elegir inputs y afirmar outputs exactos, defines una propiedad que debe cumplirse para todos los inputs, y el framework genera cientos de inputs aleatorios para intentar romperla.
El ejemplo clásico es invertir una lista. La propiedad no es “el reverso de [1, 2, 3] es [3, 2, 1]”. La propiedad es “invertir una lista dos veces devuelve la lista original”. Esa invariante se cumple para cualquier lista posible, así que el framework lanza listas aleatorias hasta que algo falla o se alcanza el límite de iteraciones.
Rust tiene dos librerías maduras para esto. quickcheck fue la primera opción popular, portada desde Haskell. proptest es el enfoque más nuevo con una característica decisiva: el shrinking automático. Cuando proptest encuentra un input que falla, no solo imprime un vector de cien elementos y te deja debuguear. Simplifica el input hasta encontrar el contraejemplo mínimo posible. Esa es la diferencia entre leer un output de test fallido y realmente entender el bug.
Cómo el Shrinking Encuentra el Bug Real
El shrinking es el ingrediente secreto. Digamos que proptest genera un vector aleatorio de 47 enteros y tu propiedad falla. El bug no es el elemento 47. El bug probablemente tiene que ver con vectors vacíos, cambios de signo o valores duplicados.
proptest lo sabe. Intenta eliminar elementos. Intenta poner valores a cero. Intenta hacer el vector cada vez más pequeño. Si el test sigue fallando con un input más simple, sigue reduciendo. Se detiene cuando toda variación más simple pasa. El resultado es el contraejemplo mínimo.
Esto no es un nice-to-have. Es la razón por la que el property-based testing es usable en la práctica. Sin shrinking, vuelves a debuguear un pajar. Con él, obtienes la aguja.
Un Bug Real, un Test Real
Aquí hay una función reverse con un bug sutil. A ver si lo detectas antes que el test.
fn reverse<T: Clone>(xs: &[T]) -> Vec<T> {
let mut rev = Vec::with_capacity(xs.len());
for i in 1..xs.len() {
rev.push(xs[xs.len() - i].clone());
}
rev
}
El rango empieza en 1 en lugar de 0. Para cualquier lista no vacía, el primer elemento se descarta silenciosamente. Un unit test con [1, 2, 3] podría pasar si el autor solo verificó que el output pareciera invertido, no que tuviera la longitud correcta. Pero la propiedad lo atrapa al instante.
Añade proptest a tu Cargo.toml:
[dev-dependencies]
proptest = "1.6"
Luego escribe la propiedad:
use proptest::prelude::*;
proptest! {
#[test]
fn reverse_is_its_own_inverse(xs in any::<Vec<i32>>()) {
let rev = reverse(&xs);
let rev_rev = reverse(&rev);
prop_assert_eq!(xs, rev_rev);
}
}
Ejecuta cargo test y el output cuenta la historia:
thread 'reverse_is_its_own_inverse' panicked at 'assertion failed: `(left == right)`
left: `[0]`,
right: `[]`'
proptest redujo un vector aleatorio que fallaba hasta un solo elemento, [0]. La primera llamada devuelve []. La segunda llamada devuelve []. La propiedad exige [] == [0], lo cual falla. El bug es obvio una vez que el input es mínimo.
También puedes probar propiedades estructurales directamente:
proptest! {
#[test]
fn reverse_preserves_length(xs in any::<Vec<i32>>()) {
prop_assert_eq!(xs.len(), reverse(&xs).len());
}
}
Esto falla con el mismo input mínimo pero da un error aún más claro: la longitud del output es 0, no 1.
Por Qué Sigues Necesitando Unit Tests Regulares
El property-based testing no es un reemplazo de los tests basados en ejemplos. Es un plugin.
Los unit tests documentan la intención. Cuando un desarrollador nuevo lee que reverse(&[1, 2, 3]) debe ser igual a [3, 2, 1], aprende lo que la función debe hacer. Un test de propiedades dice “esta invariante se cumple”, pero no te dice cómo se ve el output para un caso normal.
Las propiedades también son más difíciles de escribir que los ejemplos. Las state machines, los side effects y el I/O no se mapean limpiamente a funciones puras. proptest tiene estrategias para tipos complejos, pero conectarlas requiere más reflexión que unas pocas llamadas a assert_eq!.
También está el costo de tiempo. Un test de propiedades puede ejecutar mil iteraciones. En una codebase grande, eso se acumula. Ejecutas los property tests en CI, no en cada pulsación de tecla durante el desarrollo.
Los Tests Aleatorios Pueden Seguir Siendo Reproducibles
La aleatoriedad pone nervioso a la gente. Si un test falla solo a veces, ¿es el código o el test?
proptest maneja esto persistiendo las seeds que fallan. Cuando una propiedad falla, guarda la seed y el input reducido en un archivo en proptest-regressions/. La siguiente ejecución reproduce ese caso exacto antes de generar nuevos datos aleatorios. Un property test flaky es casi siempre una propiedad buggy, no un framework flaky.
Si necesitas determinismo estricto, puedes fijar la seed en la configuración de tu test. La mayoría de equipos no se molestan. El archivo de regresión es suficiente.
Dónde los Property Tests Dan Frutos
El property-based testing vale la pena cuando tu código tiene invariantes matemáticas claras y un espacio de input grande.
El ordenamiento es el ejemplo de libro de texto. Las propiedades son simples: el output está ordenado, es una permutación del input y tiene la misma longitud. Un sort buggy podría pasar ejemplos escritos a mano pero fallar en datos aleatorios con elementos duplicados.
Los parsers y serializadores son otro sweet spot. Las propiedades de round-trip como parse(serialize(x)) == x atrapan bugs de codificación que solo aparecen con secuencias de bytes específicas.
Cualquier cosa con aritmética, colecciones o transiciones de estado se beneficia. Cualquier cosa con APIs externas, timing estricto o juicio humano, no.
Empieza con Una Propiedad
No necesitas reescribir tu suite de tests. Elige una función pura con una invariante clara. Añade proptest a dev-dependencies. Escribe una sola propiedad. Ejecútala, obsérvala pasar, luego introduce un bug sutil y mira cómo reduce el fallo a algo que puedes debuguear en segundos.
Una vez que veas un test aleatorio encontrar un bug para el que nunca habrías escrito un ejemplo, empezarás a recurrir a las propiedades con más frecuencia. No para cada test. Solo para los que importan.