wandres.dev
TESTING EN ELM · puro es testeable

Fuzz testing: probar propiedades en vez de ejemplos

Una prueba basada en ejemplos solo puede fallar en los casos que a su autor se le ocurrieron, y esa es exactamente la población de entradas que el autor ya tuvo en mente al escribir la implementación, de modo que el conjunto examinado está sesgado por la misma imaginación que produjo el fallo. El fuzz testing invierte la carga: en lugar de enunciar pares concretos de entrada y salida, se enuncia una propiedad que debe cumplirse para toda entrada de un tipo y se delega en un generador la tarea de buscar contraejemplos. Esta lección explica qué es un Fuzzer y por qué también es un valor componible, cómo se construyen generadores del dominio a partir de primitivos, qué hace la reducción automática cuando encuentra un fallo y por qué es la mitad del valor de la técnica, cuáles son los patrones que producen propiedades útiles y por qué la semilla y el número de ejecuciones convierten un fallo aleatorio en un fallo reproducible.

⏱ 19 min

Hay una asimetría incómoda en las pruebas por ejemplos que rara vez se enuncia en voz alta: los casos que escribes son los que se te ocurren, y los que se te ocurren son los que ya tuviste en la cabeza mientras implementabas. La lista vacía, el cero, el uno, el nombre con acento si eres cuidadoso. La entrada que rompe el código es, por definición, la que no imaginaste, así que la técnica que consiste en imaginar entradas tiene un punto ciego que coincide exactamente con la región donde viven los fallos. El fuzz testing no mejora tu imaginación, la sustituye por una fuente que no tiene prejuicios: un generador que produce valores del tipo que le pidas, incluidos los feos, los enormes, los vacíos y los que contienen caracteres que no sabías que existían. A cambio te pide algo más difícil que escribir un ejemplo: enunciar qué debería ser cierto para todas las entradas. Ese cambio de registro, de la tabla de casos a la propiedad universal, es el verdadero contenido intelectual de la técnica, y su efecto secundario más valioso es que obliga a entender la función mejor de lo que hacía falta para escribirla.

🎯 Al terminar esta lección sabrás
  • Distinguir una prueba por ejemplos de una prueba por propiedades y explicar el sesgo estructural de la primera.
  • Componer generadores del dominio a partir de los primitivos de Fuzz con map, andThen y las variantes de frecuencia.
  • Explicar qué es la reducción de un contraejemplo y por qué convierte un fallo aleatorio en un diagnóstico legible.
  • Aplicar los patrones clásicos de propiedad: ida y vuelta, invariante, oráculo alternativo, idempotencia y relación entre casos.

De la tabla de ejemplos a la propiedad universal

Escribir una propiedad exige responder a una pregunta distinta de la habitual. No es qué devuelve la función para esta entrada, sino qué relación se mantiene entre cualquier entrada y su salida. Esa relación suele ser más débil que un valor concreto, y ahí está su fuerza: precisamente por ser débil, puede afirmarse de infinitas entradas. Que ordenar una lista devuelve otra de la misma longitud es una afirmación modesta, pero es cierta siempre, y basta para descubrir una implementación que pierde duplicados. Que invertir dos veces devuelve el original es igual de modesta, y basta para descubrir media docena de errores de índices.

module OrdenTest exposing (suite)

import Expect
import Fuzz exposing (int, list, string)
import Orden exposing (ordenar)
import Test exposing (Test, describe, fuzz, fuzz2)


suite : Test
suite =
    describe "ordenar"
        [ fuzz (list int) "conserva la longitud" <|
            \xs ->
                List.length (ordenar xs)
                    |> Expect.equal (List.length xs)
        , fuzz (list int) "el resultado esta ordenado" <|
            \xs ->
                let
                    ys =
                        ordenar xs
                in
                List.map2 (<=) ys (List.drop 1 ys)
                    |> List.all identity
                    |> Expect.equal True
        , fuzz (list int) "es idempotente" <|
            \xs ->
                ordenar (ordenar xs)
                    |> Expect.equal (ordenar xs)
        , fuzz (list int) "coincide con la implementacion de referencia" <|
            \xs ->
                ordenar xs
                    |> Expect.equal (List.sort xs)
        ]
🔁

Ida y vuelta

Codificar y decodificar, serializar y leer, comprimir y expandir. Si la composición no devuelve el original, hay un fallo.

⚖️

Invariante

Algo que no cambia: la longitud, la suma, el conjunto de elementos, el saldo total antes y después de una transferencia.

🧭

Oráculo alternativo

Una implementación lenta pero obviamente correcta contra la que comparar la rápida que de verdad se usa.

🧊

Idempotencia

Aplicar dos veces equivale a aplicar una. Normalizar, ordenar, deduplicar y sanear suelen cumplirla.

Un generador también es un valor componible

Fuzz sigue el mismo patrón que el resto de las bibliotecas del ecosistema: unos pocos primitivos y unas operaciones que toman generadores y devuelven generadores. Hay enteros, enteros en rango, flotantes, booleanos, cadenas, listas, arrays, maybes y results, y con eso ya se cubren los tipos de la biblioteca estándar. Lo interesante empieza cuando el tipo es tuyo: entonces se construye el generador con las mismas herramientas que cualquier otra transformación, aplicando un constructor sobre generadores más simples o encadenando cuando la forma de un valor depende de otro.

Este punto merece énfasis porque decide si la técnica se usa de verdad o se abandona a la segunda semana. Un generador restringido a los tipos primitivos solo sirve para probar utilidades genéricas; un generador de tu tipo de dominio sirve para probar tu dominio, que es donde están las reglas que importan. Y como el generador es un valor con nombre, se escribe una vez en un módulo auxiliar de pruebas y se reutiliza en cada suite que lo necesite.

module Generadores exposing (usuario)

import Fuzz exposing (Fuzzer)
import Usuario exposing (Plan(..), Usuario)


plan : Fuzzer Plan
plan =
    Fuzz.oneOf
        [ Fuzz.constant Gratuito
        , Fuzz.map Pago (Fuzz.intRange 1 120)
        , Fuzz.constant Suspendido
        ]


usuario : Fuzzer Usuario
usuario =
    Fuzz.map3 Usuario
        (Fuzz.stringOfLengthBetween 1 40)
        (Fuzz.intRange 18 120)
        plan
💡
Genera el tipo, no la representación

Cuando necesites un valor que cumple una condición, resístete a generar cualquier cosa y descartar lo que no valga. Filtrar es lento, puede agotar los intentos y, sobre todo, oculta un diagnóstico: si te cuesta generar valores válidos es que tu tipo admite estados inválidos, y ese es un problema de modelado antes que de pruebas. La alternativa correcta es construir el valor válido por definición, aplicando el constructor sobre generadores ya restringidos, con rangos en los enteros y longitudes acotadas en las cadenas. El generador acaba siendo, así, una segunda especificación del tipo, y su dificultad de escritura mide con bastante fidelidad la calidad del modelado.

La reducción es la mitad del valor

Un generador que encuentra un fallo con una lista de ochenta enteros de siete cifras te ha dado una mala noticia envuelta en ruido inutilizable. Por eso la biblioteca no se limita a informar del contraejemplo que encontró: cuando una propiedad falla, empieza a probar entradas progresivamente más pequeñas y simples que sigan fallando, y reporta la mínima que conservó el fallo. Aquella lista de ochenta elementos se convierte casi siempre en dos, y los enteros de siete cifras en un cero y un uno. El informe deja entonces de ser una anécdota estadística y pasa a ser un caso mínimo reproducible que suele señalar la línea culpable sin depurar.

El corredor imprime además la semilla de la ejecución, y ese detalle es lo que reconcilia la aleatoriedad con la disciplina de la integración continua. Un fallo que aparece una vez de cada quinientas ejecuciones sería insoportable si no se pudiera reproducir; con la semilla se reproduce exactamente. La costumbre sana es fijar la semilla del fallo mientras se investiga, y convertir después el contraejemplo mínimo en una prueba por ejemplos permanente, que documenta el caso concreto y protege contra la regresión aunque el generador no vuelva a visitar esa región.

flowchart TD
P[Propiedad universal] --> G[Generador produce entradas]
G --> V[Se verifica cien veces]
V -->|todas pasan| OK[Propiedad sostenida]
V -->|una falla| S[Reduccion del contraejemplo]
S --> M[Caso minimo con semilla]
M --> T[Prueba por ejemplo permanente]
style G fill:#89b4fa,color:#11111b
style S fill:#f9e2af,color:#11111b
style T fill:#a6e3a1,color:#11111b
ℹ️
Cuidado con la propiedad que reimplementa la función

Existe una trampa que todo el mundo pisa una vez y conviene reconocerla antes: escribir como expectativa una copia de la lógica que se está probando. Si la propiedad calcula el resultado esperado con el mismo algoritmo que la implementación, la prueba pasará siempre, incluso cuando ambas estén igual de equivocadas, y habrás gastado esfuerzo en verificar una tautología. La señal de alarma es que la propiedad se parezca al cuerpo de la función. Las buenas propiedades suelen ser más débiles y más estructurales: hablan de longitudes, de pertenencia, de orden, de sumas conservadas o de la relación entre dos llamadas, no del valor exacto que se obtiene.

Los ejemplos prueban lo que pensaste; las propiedades prueban lo que decidiste

Detrás de esta técnica hay un cambio de estatus epistemológico que merece enunciarse con precisión, porque explica por qué el esfuerzo de escribir propiedades se paga en un plazo distinto del que uno espera. Una prueba por ejemplos es un enunciado existencial disfrazado: afirma que para estas entradas concretas el resultado es este, y de ahí no se sigue absolutamente nada sobre las demás. Su valor no reside en la generalidad sino en la protección contra la regresión de un caso que alguna vez importó. Una propiedad, en cambio, es un enunciado universal sobre el comportamiento, y aunque el corredor solo pueda comprobarlo en una muestra finita, el enunciado sigue estando escrito con alcance universal, y esa escritura es un acto de diseño y no de verificación. Al obligarte a decir qué es siempre cierto sobre una función, la propiedad te obliga a decidir qué contrato ofrece esa función, cosa que la implementación por sí sola no obliga a decidir nunca: se puede escribir código correcto para los casos habituales sin haber determinado jamás qué pasa con la lista vacía, con el valor negativo o con la cadena de dos mil caracteres. Cuando intentas enunciar la propiedad, esas preguntas dejan de poder aplazarse, y con frecuencia descubres que el fallo no está en el código sino en que nunca decidiste. De ahí una consecuencia práctica poco intuitiva: buena parte del beneficio del fuzz testing se cobra mientras se escribe la propiedad, antes de ejecutarla ni una vez. Y de ahí también la relación correcta entre las dos técnicas, que no compiten sino que se relevan: la propiedad explora el espacio y descubre lo que no imaginabas; el contraejemplo mínimo que devuelve se congela como prueba por ejemplos y queda documentando para siempre un caso que ahora sí forma parte de lo que sabes. Una batería madura tiene las dos capas, y la de ejemplos crece precisamente con lo que la de propiedades fue encontrando.

⚔️ Enuncia lo que siempre es cierto
  1. Toma una función tuya con pruebas por ejemplos y enuncia tres propiedades universales que ninguno de esos ejemplos comprueba.
  2. Escribe una propiedad de ida y vuelta entre un codificador y un decodificador de tu dominio y ejecútala con mil casos.
  3. Construye un generador de un tipo tuyo componiendo constructores sobre primitivos, sin descartar valores por filtrado.
  4. Provoca un fallo deliberado en una función y observa la reducción; anota el tamaño del contraejemplo inicial y el del mínimo.
  5. Fija la semilla de un fallo, corrige el código y convierte el caso mínimo en una prueba por ejemplos permanente.
  6. Escribe a propósito una propiedad que reimplemente la función, explica por qué no vale nada y sustitúyela por una estructural.