Tests par propriétés

Vous êtes déjà un expert TDD, votre application a une couverture de tests de plus 80%. Mais vous avez le sentiment que tout n'est pas testé, qu'il reste d'obscurs cas que vous n'arrivez pas exprimer.

Pourquoi ne pas demander à un programme de vous aider à tester ?

Vous pouvez déjà passer par le mutation testing. Cette méthode donne une première approche, mais il en existe une autre : les tests par propriétés.

Cette méthode se résume à exprimer des propriétés et de laisser un programme la vérification de celles-ci.

C'est une façon de tester qui provient des langages fonctionnels et donc qui peut paraître étrange si on vient du monde objet "classique".

Donc regardons plus en détail son fonctionnement et à quoi elle pourrait servir.

Introduction

Cette méthode s'articule donc autour de propriétés. Il faut l'entendre au sens mathématique du terme. Rappelez-vous vos cours : ∀a ∈ ℕ a ≥ 0. Que l'on peut lire "quel que soit (∀) a appartenant (∈) à l'ensemble des entiers naturels (ℕ) alors a est supérieur ou égal à 0 (a ≥ 0)".

Prenons un exemple plus technique, avec l'opérateur de concaténation de chaîne de caractères. On sait que "ta" + "to" va nous donner "tato". Mais comment tester cette méthode de concaténation ?

Avec des tests unitaires classique j'aurais écrit ceci :

test "concaténation des chaînes 'ta' et 'to'" {
  assert( ("ta" + "to") == "tato" )
}

On voit bien que tester un autre couple de valeur serait inutile et ne ferrait que générer de la duplication de code. Pourquoi ne pas généraliser notre test avec une propriété et laisser un programme choisir les valeurs d'entrées de notre test (ici "ta" et "to") ?

Commençons pas définir les propriétés de la concaténation. Par exemple, on sait que si on concatène "to" et "ta" la chaîne résultante finira par "ta". Donc en généralisant : quelque soit deux chaînes de caractères a et b la chaîne résultante de leur concaténation doit se finir par la chaîne b. Soit en code :

val a: String
val b: String
((a+b) endsWith b) == true

On pourrait aussi rajouter d'autres propriétés. Par exemple, sur les longueurs des chaînes :

( (a+b).length == a.length + b.length )

Les outils

Il existe des outils de tests par propriétés pour quasiment tous les langages. On peut citer l'originel QuickCheck pour Haskell, QuickCheck pour Java, RushCheck pour Ruby, et mon préféré ScalaCheck pour Scala.

La suite des exemples sera illustré avec ScalaCheck. Les exemples seront donc en Scala, mais la syntaxe reste lisible pour tout un chacun. Il faut juste savoir que la forme (x: Int, y: String) => x.toString + y représente une fonction avec :

  • x et y les deux paramètres. x est un entier, y une chaîne
  • tout ce qui se trouve après le => est le corps de la méthode

Un premier exemple

Exprimons nos deux exemples de l'introduction en ScalaCheck :

Prop.forAll {
  (a: String, b:String) => (a+b).endsWith(b)
}

L'api est assez claire. Prop.forAll permet de déclarer une propriété de type quelque soit. Ensuite, on défini une propriété par une fonction classique.

Notre deuxième exemple s'exprime de la même façon :

Prop.forAll {
  (a: String, b: String) => a.length + b.length == (a+b).length
}

On peut aussi définir des propriétés que sur un sous ensemble. Par exemple, si on veut tester l'opération racine carrée, on ne va tester nos propriétés que sur des nombres ≥ 0. Ce qui donne en code :

Prop.forAll {
  (a: Int) =>
    (a >= 0) ==> (math.sqrt(a*a) == a)
}

L'opérateur ==> est l'opérateur d'implication. Dans notre exemple ∀a ≥ 0 √a² = a, c'est la partie a ≥ 0, la condition d'entrée.

Tests unitaires

On a vu comment définir des propriétés, maintenant on va voir comment les intégrés avec des tests unitaires. La méthode que je trouve la plus simple est d'utiliser ScalaTest qui possède une intégration de ScalaCheck.

Il suffit d'utiliser la méthode check qui permet de tester une propriété :

check(Prop.forAll {
  (a: String, b:String) => (a+b).endsWith(b)
})

On a donc un test unitaire, mais que va faire le programme lors de l'exécution des tests ? Il va générer aléatoirement des chaînes de caractères et va tenter de valider les propriétés. Par défaut, ScalaCheck va tester 500 entrées.

Les valeurs sont choisies par un générateur. Le framework intègre plusieurs générateur : pour les types de base (Int, Long, Double, String, Char, etc.), et pour les Tuple et les List. De ce que j'ai pu comprendre de la documentation est que les valeurs sont simplement tirées aléatoirement. Il est néanmoins possible de déclarer ses propres générateurs ou de contraindre ceux déjà existants. Par exemple, ne prendre que des entiers pairs. Pour plus d'information, la documentation est disponible ici.

Un exemple complet

Nous voulons tester une méthode isPalindrome qui détermine si une liste est un palindrome ou non. Un palindrome est une liste qui est symétrique par rapport à son milieu : List(1, 2, 3) n'en est pas un, alors que List(1, 2, 2, 1) l'est.

Définissons la propriété suivante :

Prop.forAll {
  list: List[Int] => isPalindrome(list)
}

Maintenant, lançons nos tests :

[info] - fails *** FAILED *** [info] Falsified after 2 passed tests: [info] arg0 = List(1, 0) // 31 shrinks

Notre test a échoué. En effet, toute les listes ne sont pas des palindromes. L'important ici est la notion de shrink. Une fois que ScalaCheck constate qu'une propriété est fausse pour une valeur, il essaye de trouver une valeur plus simple qui fait échouer le test. Ce qui permet de comprendre plus simplement la raison de l'échec du test. Sans ce shrink on aurait eu un résultat comme ceci :

[info] - fails *** FAILED *** [info] Falsified after 4 passed tests: [info] arg0 = List(-802254917, -1964890209, 2147483647, -614229467)

Ce qui est effectivement plus complexe à comprendre. Prenons une 2ème propriété. Celle-ci pose une condition, qui est que l'on ne veut tester notre propriété que sur les listes qui sont égales à leur inverse :

Prop.forAll {
  list: List[Int] =>
    (list.reverse == list) ==> isPalindrome(list)
}

[info] - constrains *** FAILED *** [info] Gave up after 18 successful property evaluations. 501 evaluations were discarded.

De même, nous avons une erreur. Ici le framework nous indique qu'il a "jeté" 501 valeurs d'entrées. En effet, notre condition d'entrée est trop complexe. La probabilité est assez faible qu'une liste aléatoire soit un palindrome. Il faut donc faire attention aux conditions et ne pas en prendre des trop contraignantes. Modifions donc légèrement notre propriété, en testant l'inverse :

Prop.forAll {
  list: List[Int] =>
    (list.reverse != list) ==> !isPalindrome(list)
}

Voilà enfin une propriété valide.

Conclusion

Cette méthode est loin d'être parfaite. Elle peut effectivement permettre d'écrire moins de tests. J'ai pu tester des cas où 4-5 tests écrits en TDD étaient résumés en une seule propriété. Mais ils seront plus complexes à écrire et à lire. Et surtout ils seront plus longs à écrire.

À mon sens, cette méthode vient en complément de tests classiques, par exemple tester une succession d'actions sur un système (ex : un protocole réseau). Utiliser les tests par propriétés est une bonne idée quand le résultat est complexe à calculer mais simple à tester.