La couverture de code des tests unitaires est une métrique couramment suivi au sein des projets afin de déterminer la confiance en la qualité de la phase de test et par extension de celle du produit.
Un taux minimal arbitraire est même fixé dans de nombreuses stratégies de test d'entreprise, devenant ainsi souvent l'alpha et l'oméga de toute discussion sur la qualité entre l'équipe et le management.
On a une couverture de TU de 100% ? Il ne peut plus rien nous arriver d'affreux maintenant...
L'illusion de la sécurité
Malheureusement très souvent, peu de recul est pris sur la signification de cette métrique, encore moins sur son utilisation.
Non la couverture de code n'est pas inutile, elle permet de quantifier l'effort réalisé par l'équipe de développement sur la phase de test mais prise seule elle ne dit absolument rien de la qualité et de la pertinence de cet effort.
Pour illustrer cela, prenons l'exemple de cette méthode très simple, renvoyant vrai ou faux selon si l'âge fournit en entrée est majeur ou non.
const estMajeur = (age) => age >= 18
Afin d'obtenir une couverture de test de 100%, un test unitaire est ajouté :
describe("Majorité", () => {
test("Devrait dire lorsqu'un age est majeur", () => {
expect(estMajeur(35)).toBe(true)
});
});
Nous avons donc une couverture de 100%, et tout est vert.
Pour autant, il est assez évident que notre "suite" de tests est beaucoup trop faible en l'état. Aucun test n'est réalisé pour un âge inférieur à 18 ans, encore moins pour l'âge de 18 ans pile...
Kill'em all !
C'est ici qu'intervient le mutation testing dont le but va être de qualifier la qualité de notre suite de tests de façon plus précise.
L'idée est d'introduire de petits changements dans le code (mutations) puis d'exécuter à nouveau la suite de tests. Si notre suite est de bonne qualité, une majorité des mutations devrait être détectée (killed) par la phase de test.
Les bénéfices alors sont multiples :
- Identifier les morceaux de code dont les tests sont trop faibles - là où les mutations ne sont pas tuées par la suite de tests,
- Identifier les tests trop faibles - ceux qui ne tuent jamais de mutations,
- Obtenir un score de mutation qui conjointement à la couverture de code donnera une vision beaucoup plus précise de la qualité. ### Théorie Pour cela, plusieurs concepts sont introduits:
-
Opérateur de mutation ou Mutation Operator - un opérateur de mutation est un changement appliqué au code original. Par exemple, modifier un
>
par un<
ouand
paror
dans une condition. - Mutant - un mutant est la version modifiée de l'entité originale (par exemple une classe ou un module), à laquelle a donc été appliqué un opérateur de mutation.
-
Mutations tuées / survivantes - lors de l'exécution des tests sur un mutant (i.e. le code modifié), deux résultats sont possibles :
- Au moins un test a échoué et a donc détecté la mutation, le mutant est alors considéré tué ;
- Aucun test n'a échoué, le mutant a donc survécu.
-
Mutations équivalentes - parfois une mutation n'est pas "pertinente" car ne déclenchant pas de comportement "déviant", par exemple:
- Mutations dans du code mort / inutile
- Mutations n'affectant que les performances
- Mutations n'impactant que l'état interne du système
Mise en pratique
Appliquons tout cela à notre méthode précédente et à sa suite de tests. Pour cela nous allons utiliser Stryker, une librairie de mutation testing disponible en JavaScript, Scala et C#. Pour les langages basés sur la JVM, pitest est une implémentation équivalente.
De part le principe même du mutation-testing, aucun effort autre que la configuration de la librairie n'est nécessaire.
Après exécution, nous avons un premier niveau de rapport dans le retour console nous apprenant entre autre que :
- 6 mutants ont été générés.
- Sur ces 6 mutants, 2 ont survécu à notre suite de tests.
- Notre suite de tests a un score de 66% (1/3 des mutations n'ont pas été détectés). Nous voyons très vite que les 2 mutations non détectées concernent effectivement le "trou" que nous avions prédit.
1ère mutation ayant survécu :
La mutation a modifié notre comparaison afin de renvoyer toujours true
. Notre suite de test ne vérifiant que le cas où l'on renvoie effectivement true
, la mutation a survécu, c'est-à-dire que notre test n'a pas échoué.
2ème mutation ayant survécu :
De la même manière, ne testant pas le cas de l'égalité, la mutation associée n'a pas été détectée.
L'analyse des résultats nous amène donc à renforcer notre suite de test comme suit:
const { estMajeur } = require('../src/majorite')
describe("Majorité", () => {
test("Devrait dire lorsqu'un age est majeur", () => {
expect(estMajeur(35)).toBe(true)
});
test("Devrait être majeur à 18 ans", () => {
expect(estMajeur(18)).toBe(true)
});
test("Devrait dire lorsqu'un age est mineur", () => {
expect(estMajeur(12)).toBe(false)
});
});
Notre couverture de code est toujours de 100%, en revanche notre score de mutation est maintenant lui aussi de 100%.
Conclusion
Au delà du gimmick "testez vos tests", il est important de sensibiliser l'ensemble des acteurs à l'évaluation de la pertinence des tests unitaires. Dans cette optique, le mutation testing est une pratique à l'outillage facile à mettre en place et fournissant un feedback de valeur très rapidement.