Test automáticos con QuickCheck ¿Cómo analizar nuestro código en busca de bugs?

Test automáticos con QuickCheck ¿Cómo analizar nuestro código en busca de bugs?
Sin comentarios Facebook Twitter Flipboard E-mail

Verificar que nuestro código está libre de bugs es una tarea muchísimo más complicada de lo que pudiera parecer en un principio. El número de relaciones que se producen entre las piezas de código que vamos añadiendo aumenta de forma vertiginosa y desgraciadamente nadie parece saber aún la forma de escribir código libre de bugs. No deja de ser sorprendente que el datamining y en particular el machine learning no se apliquen de forma efectiva dada la ingente cantidad de programadores que hay en el mundo escribiendo cabezonamente el mismo código y cometiendo una y otra vez los mismos errores.

Hasta entonces, una de las herramientas que puede sernos de utilidad a la hora de validar nuestro código es QuickCheck, que nos permite escrutar automática y probabilísticamente nuestros algoritmos para encontrar errores.

¿Dónde se esconden los bugs?

La experiencia nos muestra que un programador senior genera menos bugs que un programador junior, e igualmente tenemos la sensación de que un programador senior escribirá test de mejor "calidad" que un programador junior pero, ¿porqué?, ¿qué significa que un test es de mejor "calidad" que otro?

Intuitivamente reconocemos relaciones que la experiencia parece confirmar. Si escribimos un proceso que trabaja con cadenas y éste es verificado para palabras como "canario", "baloncesto" o "picapiedra" nos sorprendería que fallara para palabras como "campanario", "balonmano" o "piedrafita". Nos sorprendería menos encontrar un error al verificar con cadena vacía, símbolos o de longitudes "diferentes". Si se trata de cierto cálculo contable (totales, descuentos, etc...) nos sorprendería que fuera validado para importes como 10.25, 123.00 o 75.73 pero no para otros como 10.15, 232.00 o 73.75.

Y nos sorprendería porque, si bien todos sabemos que terminan apareciendo bugs que fallan con "balonmano" pero no con "baloncesto" o con 232.00 pero no con 123.00, no son los más frecuentes.

Así, siempre de forma empírica e intuitiva los bugs suelen agruparse en clases (no excluyentes); es decir, existen clases de parámetros para los cuales nuestro código es o no correcto. Como en los ejemplos anteriores, si verificamos que nuestro código funciona para las cadenas "", "A", "AA", "AAA", "AAAAA", "AAAAAAA", ... (número primo de aes) nos sorprendería que no funcionara para un número no primo de aes... ¡salvo que nuestro algoritmo esté relacionado de alguna forma con la primalidad!

Clases De Test

Si supiéramos identificar, ¡para cada algoritmo!, las clases de parámetros que producen el mismo resultado en nuestro test sólo tendríamos que revisar un representante de cada clase y no todos.

Quickcheck

QuickCheck realiza de forma automática una búsqueda sobre el dominio de los parámetros intentando recorrer, precisamente, las más clases de parámetros que hemos indicado antes de forma que para cada test se realice una evaluación transversal.

Test Transversales

Nosotros entonces, únicamente suministramos una propiedad que debe cumplirse en la hipótesis de que nuestro código sea correcto (un invariante) y Quickcheck lo revisará generando automáticamente los parámetros.

Por ejemplo, supongamos la siguiente función en Java que indica si dos cadenas son idénticas:


public static boolean equalStrings(String a, String b) {
    boolean equals = a.length() == b.length();
    if(equals)
        for(int i = 0; i < a.length(); i++)
            equals = a.charAt(i) == b.charAt(i);
    return equals;
}

Entonces usando una implementación de Quickcheck en Java podríamos verificar si se cumple que cualquier cadena es igual a ella misma con el siguiente test:


@Property
public void equalToItself(String xs) {

    assertTrue("Cualquier cadena es igual a ella misma",

            Foo.equalStrings(xs, xs));

}

Quickcheck (en este caso bajo JUnit) lanzaría 100, 500 o los test que sean y obtendríamos que todo es correcto. Podríamos verificar también que una cadena a la que se añade una X nunca es igual a esa misma cadena añadiéndole una Y con un test como:


public void differentSuffix(String xs) {

    assertFalse("Cualquier cadena con diferentes sufijos nunca es igual",

            Foo.equalStrings(xs + "X", xs + "Y"));

}

Y obtendríamos también que nuestra función funciona perfectamente.

Buscando invariantes

Aunque Quickcheck es una herramienta fantástica para generar automáticamente argumentos para nuestros test, no debemos olvidar que el dominio de búsqueda aumenta exponencialmente con el tamaño de nuestros parámetros. Una función que toma como argumento tan sólo dos números de 32 bits tiene un dominio de 264 = 18.446.744.073.709.551.616 elementos.

Por ello, la elección de los invariantes a verificar sigue siendo crítica, por lo que debemos buscar entre aquellos que conecten relaciones utilizadas en la implementación pero de forma que cubran el dominio (no poner invariantes por poner). Pensemos en como hemos verificado que nuestra función equalStrings es correcta, ¿cuantos casos de entre todos los posibles hemos descartado con los dos test que hemos lanzado sobre la función equalStrings? ¡poquísimos! porque de todos los pares de cadenas posibles, las cadenas iguales y las cadenas que sólo discrepan en la última posición (¡aunque consideremos las infinitas cadenas iguales y las infinitas cadenas que sólo discrepan en la última posición!) son sólo dos casos de entre otros muchos posibles, de hecho, probemos con el mismo test anterior pero en lugar de sufijos, prefijos:


@Property
public void differentPrefix(String xs) {

    assertFalse("Cualquier cadena con diferentes prefijos nunca es igual",

            Foo.equalStrings("X" + xs, "Y" + xs));

}

¡caramba! ahora el test falla y nos indica un problema en nuestra implementación.

Conclusión

Los test "básicos" o los de regresión son triviales de implementar porque no pretenden validar la implementación, para hacerlo, deben generarse buenos test y ésto dista mucho de ser fácil. Quickcheck nos permite generar con facilidad miles o millones de casos de prueba pero sigue siendo crítico saber identificar el tipo de código que estamos intentando validar para buscar y definir aquellos invariantes que son más "transversales" a las clases de parámetros para los cuales es mas sensible nuestro código.

No ha sido mi intención escribir un tutorial sobre Quickcheck, sino llamar la atención sobre dicha herramienta y destacar la importancia de comprender cuando, cómo, porqué y en qué medida funciona la búsqueda de bugs mediante test unitarios. Si quieres darle una oportunidad, es probable que haya una implementación para tu lenguaje favorito.

Comentarios cerrados
Inicio