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.

2 commentaires pour “Tests par propriétés”

  1. Bonjour,

    Je suis assez moyennement convaincu par cette méthode de test.
    Il est vrai que je viens pas du monde des langages fonctionnels…
    Mais je me suis littéralement planté sur des séances d’écriture de tests automatisés (pas encore de TDD) il y a quelques temps, car je n’avais pas compris une chose : un test est un exemple.

    J’en arrivais à écrire des tests relativement génériques, dans lesquelles n’importe quel valeur donnée pouvait passer, qui plus est en allant à fond dans les mocks. Du coup, en plus d’y passer un temps considérable (2h par test !! oui… j’ai appris… why you need to fail…), je me suis rendu compte que l’algorithme de mon test était à peu de chose près celui du code testé !

    J’en suis donc revenu. Or, j’ai l’impression que cette approche conduit au même résultat. Le test, ou plutôt la contraposée, est équivalent, à peu de chose près, au code, car on doit certainement faire appel dans l’implem à list.reverse.

    Donc pour l’avoir vécu, oui, ce type de test est une réelle galère, un gouffre sans fond. Ceci dit, je peux comprendre son utilité (1% ? 2% des cas ?), mais ils ne doivent surtout pas être généralisés…

  2. @Jérôme :

    2h par test ?… moi je peux y passer 4h, alors qu’est-ce que je devrais dire :-)

    Pour le reste je suis d’accord : un test peut être vu comme un exemple, et plus il y a de mocks et plus c’est mauvais à mon avis, et si code de test = code testé, c’est mauvais aussi.

    Il me semble que la méthode donnée dans l’article dépasse la notion d’exemple, pour aborder la notion d’ensemble de validité. Mais il est vrai que, si cet ensemble est infini, alors on retourne à la notion d’exemple, même s’il y en a beaucoup.

    Quand l’ensemble est infini, je subodorre que cette méthode est inutile, voire nuisible : il vaut mieux réfléchir, notamment pour trouver les comportements limites.

    Perso il m’arrive de créer des classes ou des énumérations rien que pour expliciter cette notion d’ensemble de validité, et ça facilite beaucoup le test.

    Ce que je trouve intéressant aussi dans cet article est la notion de générateur, qui me parait très riche. Mais comme je comprends pas bien le scalla, j’ai un peu de mal à comprendre le code.

    À réfléchir, merci à l’auteur.

Laissez un commentaire