Tests basados en propiedades con simple-check

Cualquiera que siga este blog sabe lo mucho que me preocupa construir software de calidad y lo útil que considero el uso de tests automatizados para conseguir ese objetivo. He escrito sobre varios tipos de tests, incluyendo tests unitarios, tests de aprobación y tests de extremo a extremo, entre otros.

En este post vamos a ver otro tipo de tests bastante menos frecuente pero que también tiene su utilidad: los tests basados en propiedades (property based testing).

¿Qué son los tests basados en propiedades?

La idea en que se fundamentan los tests basados en propiedades es muy simple: generamos (normalmente de forma aleatoria) los datos de entrada a un algoritmo, ejecutamos el algoritmo sobre esos datos de entrada y comprobamos que el resultado cumple determinadas propiedades.

Por ejemplo, si tenemos un algoritmo que ordena un vector de números enteros, podríamos generar aleatoriamente vectores con distinta longitud y contenido y verificar que se cumplen las siguiente propiedades:

  • El número de elementos del vector resultado es igual al número de elementos del vector original.
  • Si el vector original no estaba vacío, el primer elemento del vector resultado se corresponde con el menor elemento del vector original.
  • Dados dos índices i,j del vector resultado, se debe complir que vector[i] <= vector[j].

Usando estas propiedades, podemos generar muchos (cientos o miles) de posibles vectores de entrada y comprobar que después de ordenarlos se cumplen las propiedades. Esto no «demuestra» que el algoritmo sea correcto, pero sí proporciona un grado de confianza bastante elevado.

Usando simple-check para crear los tests

La librería más conocida (y creo que la primera, o al menos una de las primeras) para construir este tipo de tests es QuickCheck para Haskell. Como (de momento) no estoy muy metido en el mundo Haskell y para .NET no hay demasiadas cosas al respecto, vamos a ver un ejemplo completo usando simple-check una adaptación de QuickCheck para Clojure, un lenguaje que cada vez me gusta más.

Nota: Si no te interesa Clojure, puedes saltarte directamente esta parte e ir a las conclusiones, pero las ideas que voy contar son fácilmente replicables en otros lenguajes y puede que te resulten útiles.

La función que vamos a testear es una función que permite «validar» que los datos de una persona son correctos. La persona la vamos a representar como un map con dos claves, :name y :age y el resultado de la función será un conjunto con las propiedades que no son válidas, teniendo en cuenta las siguientes reglas:

  • El nombre no puede estar vacío.
  • La edad debe ser mayor o igual a 18.

Una manera de implementar esta validación tan simplificada sería ésta:

(defn validate [{:keys [name age]}]
  (-> #{}
      (#(if (empty? name) (conj % :name) %))
      (#(if (< age 18) (conj % :age) %))))

Para crear nuestros tests, asumiremos que tenemos importados los espacios de nombres necesarios con los siguientes alias:

(ns simple-check.playground
  (:require [simple-check.core :as sc]
            [simple-check.generators :as gen]
            [simple-check.properties :as prop]))

Ahora que ya tenemos planteado el escenario, vamos a ir viendo las partes que necesitamos.

Generadores

Los generadores son las funciones usadas por simple-check para construir los datos de entrada de nuestros tests. Tenemos generadores que construyen distintos tipos de datos, otros que modifican el valor generado por otro generador, otros para combinar generadores, etc.

Para nuestras pruebas, podríamos definir generadores de este estilo:

;; Los generadores básicos para nombre y edad son simplemente
;; generadores de cadenas de caracteres ascii y de números naturales
(def name-gen gen/string-ascii)
(def age-gen gen/nat)

;; Generadores específicos para nombres válidos y no válidos
(def valid-name-gen (gen/not-empty name-gen))
(def invalid-name-gen (gen/return ""))

;; Generadores para edad válida y no válida
(def valid-age-gen (gen/such-that (partial <= 18) age-gen))
(def invalid-age-gen (gen/such-that (partial > 18) age-gen))

;; Función para construir generadores de personas usando los 
;; generadores especificados para la edad y el nombre
(defn person-gen [name-gen age-gen]
  (let [make-person (fn [[name age]] {:name name :age age})]
    (gen/fmap make-person (gen/tuple name-gen age-gen))))

Podemos comprobar que los generadores funcionan como esperamos usando la función gen/sample:

(take 10 (gen/sample (person-gen name-gen age-gen)))
;; => ({:name "", :age 0} {:name "", :age 1} ...

Propiedades

Como veíamos antes, las propiedades representan aquello que debería cumplirse siempre. En simple-check las propiedades se definen usando la macro for-all:

;; Toda persona menor de edad genera un error para :age
(def age-under-18-generates-an-error
  (prop/for-all [p (person-gen name-gen invalid-age-gen)]
                (:age (validate p))))

;; O podemos hacerlo al reves:
;; Toda persona válida tiene más de 17 años
(defn valid? [person] (empty? (validate person)))
(def valid-persons-are-older-that-17
  (prop/for-all [p (gen/such-that valid? (person-gen name-gen age-gen))]
                (> (:age p) 17)))

Viendo el ejemplo anterior, es fácil comprobar que hay muchas formas distintas de definir propiedades y tenemos bastante flexibilidad para ello.

Comprobación

Ahora que ya tenemos definidas los generadores para crear nuestros datos de entrada y las propiedades que queremos que cumpla el resultado, llega el momento de ejecutar las pruebas. Para ello usamos la función quick-check incluida en simple-check:

;; Generamos 100 personas al azar y comprobamos que aquellas que son 
;; válidas tienen más de 17 años
(sc/quick-check 100 valid-persons-are-older-that-17)
;; => {:result true, :num-tests 100, :seed 1386497486476}

En caso de que encuentre un error, quick-check intentará reducir al máximo los datos de entrada para encontrar el menor valor que no cumple la propiedad, ayudándonos así a comprender mejor lo que está pasando.

Una de las cosas que más me gusta de clojure es lo agradable que es trabajar poco a poco con el REPL, pero llega un momento en que es poco práctico y es necesario incluir todas estas pruebas en una suite que se pueda ejecutar automáticamente. Para ello, simple-check permite enganchar todo el proceso a una suite automátizada de tests de clojure.test.

Conclusiones

Independientemente de que te guste más o menos la sintaxis de clojure que, admitámoslo, puede no ser la más agradable del mundo, sobre todo al principio, la idea del property based testing es interesante. Poder generar sintéticamente cientos o miles de casos de tests es una buena manera de aumentar la confianza en nuestro código.

Esta técnica de tests basados en propiedades es especialmente útil cuando estamos tratando con funciones puras, sin efectos colateras y sin dependencias externas. Aunque técnicamente sería viable (y algo trabajoso) usarlo con algo que dependiese del mundo exterior, hay que tener en cuenta que vamos a ejecutar cada test cientos o miles de veces, por lo que es crítico que la ejecución sea muy rápida.

En el mundo de .NET no conozco ninguna librería que haga exactamente lo que hace simple-check o QuickCheck, pero puedes conseguir resultados similares usando Pex o AutoFixture para generar los datos de prueba y realizar luego los asserts sobre las propiedades con la libería de testing que más te guste. La principal limitación de hacerlo así es que no hay una fase de «reducción» cuando se produce un error, por lo que puede que los datos de entrada usados para hacer fallar el test no sean todo lo claros que a uno le gustaría.

En definitiva, una herramienta más a tener en cuenta a la hora de testear nuestras aplicaciones para conseguir dormir tranquilos.

2 comentarios en “Tests basados en propiedades con simple-check

  1. En mi caso he probado QuickCheck en Haskell y todavia no en clojure y por lo que veo no tiene mucho que envidiar a la de haskell aunque los tipos te dan algo mas de automatismo en la definicion de las propiedades. En el caso de haskell no tienes que generar los casos en las propiedades sino que haskell «sabe» implicitamente como generar casos para los tipos mas comunes y te permite crear generadores para los tipos del usuario (creando instancias de la typeclass Arbitrary y CoArbitrary usando combinadores)
    Tal vez esto ultimo se podria conseguir cambiando la libreria y usando protocolos para definir las generaciones…

    Otra opcion en haskell (no la veo en simple-check) en la definicion de propiedades es la de «enfilar» los casos aleatorios en la misma propiedad sin definir generadores nuevos, reproduciendo lo que seria la implicacion logica:
    http://www.cse.chalmers.se/~rjmh/QuickCheck/manual_body.html#6
    Tambien tiene opciones para recoger resultados de los tests: contar los casos triviales, ver la distribucion de los datos, etc
    http://www.cse.chalmers.se/~rjmh/QuickCheck/manual_body.html#9

    Por otro lado me parece que los tests basados en propiedades son estrictamente mas «poderosos» que los unitarios y los pueden sustituir ya que puedes restringir las propiedades todo lo que quieras hasta llegar al nivel de concrecion de estos ultimos.

    Un ejemplo en haskell chequeando un ADT Heap: https://github.com/jneira/haskell-desk/blob/master/algo/Heap.hs

  2. Juan María Hernández dijo:

    Poder inferior los generadores a partir de los tipos simplifica las cosas. AutoFixture.NET hace algo parecido a lo que (creo) que te permite QuickCheck, que es usar generadores por defecto y sobreescribir los que quieras personalizar. No sé si en clojure sería viable hacerlo a través de las anotaciones de core.typed, pero imagino que sí.

    Las dos opciones que comentas de QuickCheck, creo que son equivalentes (o muy parecidas) al generador such-that que incluye simple-check, pero es posible que algo se me escape.

    En cuanto al poder de los tests basados en propiedades con respecto a los tests unitarios, es cierto. Un tests unitario típico no deja de ser un caso particular de un test basado en propiedades en el que los datos de entrada son siempre iguales.

    Sin embargo, creo que el enfoque que da al problema cada tipo de tests es diferente y, dependiendo del problema, pueden resultar más o menos útiles unos u otros.

    Con los tests unitarios sigues un razonamiento más «inductivo» (sobre todo si aplicas TDD), mientras que con tests basados en propiedades necesitas generalizar primero las propiedades.

    Creo que hay casos en los que es complicado generalizar las propiedades sin recurrir a «duplicar» el algoritmo que estás testeando, lo que haría que se perdiera todo el sentido de los tests basados en propiedades.

Comentarios cerrados.