Mutation testing, ou l’art de tester ses tests unitaires


calendar_month 22/02/2022 à 09:49

Voici le deuxième article écrit par notre collaborateur Julien Busset, Ingénieur Etude et Développement chez Cat-Amania.

Lors du Touraine Tech, Benjamin Cavy nous a fait découvrir sa passion pour les mutations. Il ne s’agit pas de faire muter des cochons d’inde pour en obtenir un fuchsia, mais de faire muter son code pour vérifier s’il peut survivre à des modifications plus ou moins aléatoires. Bien sûr, je sais que vous êtes trop forts, que votre code est parfaitement sans faille (hum…) et que vos tests unitaires (TUs) couvrent 100% de votre code et sont tous passants (hum hum… pardon, ça me gratte la gorge). Cela dit, dans le doute, le mutation testing peut être un outil qui vous aidera malgré tout à améliorer significativement la résilience de votre code par la mise à l’épreuve de vos TUs.

Au fait, c’est quoi un test unitaire ?

L’écriture de TUs fait résolument partie des bonnes pratiques de développement, ce n’est plus à démontrer. Ces tests garantissent que les méthodes renvoient bien la sortie attendue pour une entrée donnée. On ne teste pas tout, mais on teste le plus possible, et on teste surtout ce qui est utile, ce qui doit fonctionner à tout prix. Pour savoir si nos tests couvrent la plus grande partie de notre code, nous pouvons utiliser des outils tels que le très populaire SonarQube. Ces outils proposent par exemple de mesurer la couverture de code, qui indique le pourcentage de lignes de code exécutées pendant les tests. Beaucoup d’entreprises définissent pour leurs applications une barrière de qualité qui intègre, entre autres, une couverture de code minimale.

« Tester, c’est douter »… Et c’est douter de ses tests

Mais la quantité de tests garantit-elle la qualité des tests ? Le manque de quantité garantit certainement le manque de qualité, mais pour Benjamin Cavy, la réciproque n’est pas forcément vraie : je peux exécuter beaucoup de code dans mes tests, mais ne vérifier l’exactitude que d’une partie de la sortie attendue. Par exemple, si une méthode doit multiplier un nombre en entrée par 2, puis stocker le résultat en base de données, puis le retourner à l’appelant de la méthode, alors je peux créer un test qui vérifie que ma méthode retourne bien 6 quand je mets 3 en entrée… Mais est-ce que j’ai bien vérifié que le résultat est aussi enregistré en base de données ? Que la vérification ait été faite ou non, la couverture de test sera complète sur cette fonction. Je reconnais que l’exemple est trivial, mais après tout ça peut arriver : comment vérifier que je teste vraiment, même avec une couverture de code à 100% ? Une ligne de code exécutée n’est pas forcément une ligne de code testée si une vérification de ce qu’elle exécute n’est pas réalisée.

Le mutation testing, ou le code à l’épreuve de la sélection naturelle et de la dérive génétique

Il est peu probable que votre code mute tout seul… Mais il est tout à fait possible que vous cassiez qu’un de vos collègues beaucoup moins compétent que vous casse votre code à votre insu. Il serait alors très utile que vos TUs lui permettent de se rendre compte de cela afin d’éviter toute régression. Pour Benjamin, un bon test est un test qui échoue quand il doit échouer. C’est à cela que répond le mutation testing : le principe est d’effectuer des modifications aléatoires mais viables de votre code (les mutations), puis de passer le code obtenu dans vos TUs. Si les TUs détectent la modification, c’est-à-dire s’ils échouent, alors la mutation est tuée (sic) par vos TUs. Sinon, la mutation survit à vos TUs, ce qui signifie qu’elle ne serait pas détectée si elle arrivait en vrai. Le but est que vos TUs tuent un maximum de mutants (sic). Ainsi, cette mesure, couplée à la couverture de test et le pourcentage de mutation possible, vous donne la « force » de vos tests (en pourcentage) qui est un bon indicateur de la résilience de votre code.

Comment ça marche ?

Ma foi ça marche bien ! Votre serviteur a testé une implémentation de ce concept sur une appli Spring Boot. Cette implémentation, présentée par Benjamin, s’appelle PIT, et a été réalisée en majeure partie par Henry Coles. Mon appli utilise Maven, ce qui facilite grandement l’utilisation de PIT : quelques lignes pour ajouter le pitest-maven dans le pom [ndr : n’oubliez pas la dépendance pitest-junit5-plugin si vous tournez comme moi avec JUnit 5 ^^’], et c’est parti ! Un petit build, et j’obtiens 18 minutes plus tard (pour ~2k lignes de code, et dans les TUs pas de chargement du contexte Spring mais utilisation d’un Mockserver) un joli rapport m’indiquant les mutants qui ont survécu, un peu comme dans un rapport Sonar avec les lignes de code en défaut surlignées en rouge. J’y trouve quelques loggers non testés (pas très grave), mais aussi quelques appels de méthodes qui ont survécu à leur suppression (!). J’ai obtenu 70% de force de mes tests, ce qui est bien mais pas top, donc avec une marge de progression. Il est bien sûr possible de paramétrer l’outil pour exclure des classes du mutation testing, par exemple. Tout comme Sonar, je trouve que le principal intérêt est pédagogique : si vous êtes un vieux loup de mer, vous pourrez sans doute vous en passer (encore qu’il est parfois utile de revenir aux fondamentaux), mais si vous êtes encore un marin d’eau douce, vous pourrez apprendre à mieux naviguer sur l’océan des TUs grâce à ce bel outil.