Vos tests mentent, et vous ne le savez pas
\n\nImaginez la situation : vous avez écrit des tests unitaires, la code coverage (couverture de code) atteint 95%. Le responsable est satisfait, le pipeline CI/CD est vert. Mais vendredi soir, un bug arrive en production. Cela vous dit quelque chose ?
\n\nLe problème est que la couverture de code classique est une illusion. Elle montre quelles lignes de code ont été exécutées, mais ne répond pas à la question essentielle : les tests vérifient-ils réellement la logique ? C'est ici qu'intervient le mutation testing — une méthode qui "casse" impitoyablement votre code pour vérifier la qualité de vos tests.
\n\nDans cet article, nous allons voir ce qu'est le test de mutation en Java, comment il fonctionne, et montrer des exemples concrets avec l'outil PIT (Pitest). Vous apprendrez à transformer vos tests d'une "simple coche dans le rapport" en une véritable protection contre les bugs.
\n\nQu'est-ce que le mutation testing et pourquoi est-ce important ?
\n\nLe mutation testing (ou test de mutation) est une méthode d'évaluation de la qualité des tests qui consiste à introduire de petites modifications volontairement incorrectes dans le code source (appelées mutations). Ensuite, vos tests sont exécutés. Si au moins un test échoue — le mutant est "tué", le test a fonctionné. Si les tests réussissent — le mutant a "survécu", et c'est un signal d'alarme.
\n\nUne métaphore simple : vous vérifiez l'efficacité de votre alarme contre les voleurs. Vous introduisez un "voleur" (le mutant) et vous regardez si l'alarme (le test) se déclenche. Si le voleur s'en va tranquillement, l'alarme ne vaut rien.
\n\nPourquoi la code coverage est-elle un mauvais indicateur ?
\n\nImaginez ce code :
\n\npublic boolean isAdult(int age) {\n if (age >= 18) {\n return true;\n }\n return false;\n}\n\nEt le test :
\n\n@Test\nvoid testIsAdult() {\n assertTrue(isAdult(18));\n}\n\nLa couverture de code affichera 100% (les deux branches if-else sont exécutées ?). Non ! Seule la branche true a été exécutée. Mais le test est vert. Imaginez maintenant que quelqu'un modifie accidentellement la condition en age > 21. Le test passera toujours, car 18 > 21 est faux, et assertTrue attend vrai. Le bug partira en production.
Le test de mutation détectera immédiatement cette situation en créant un mutant qui change >= en > ou inverse la condition.
Comment fonctionne le mutation testing : théorie simplifiée
\n\nLe processus comprend trois étapes :
\n\n- \n
- Génération de mutants. L'outil (par exemple, PIT) crée de nombreuses versions de votre code avec de petites modifications : change les opérateurs, supprime les appels de méthodes, inverse les conditions. \n
- Exécution des tests. Pour chaque mutant, vos tests unitaires sont exécutés. \n
- Analyse des résultats. Si un test échoue — le mutant est tué. S'il réussit — le mutant est vivant. Le pourcentage de mutants tués s'appelle le mutation score (score de mutation). \n
Un bon indicateur : 80-90% de mutants tués. Tout ce qui est en dessous est une raison de réécrire les tests.
\n\nExemple pratique en Java avec PIT
\n\nPrenons un exemple concret. Supposons que nous ayons un service de calcul de réduction :
\n\npublic class DiscountService {\n public double calculateDiscount(double amount, boolean isPremium) {\n double discount = 0.0;\n\n if (amount > 100) {\n discount = 0.1; // 10% de réduction\n }\n\n if (isPremium) {\n discount += 0.05; // 5% supplémentaire\n }\n\n // La réduction ne peut pas dépasser 30%\n if (discount > 0.3) {\n discount = 0.3;\n }\n\n return discount;\n }\n}\n\nÉcrivons un test naïf :
\n\nimport org.junit.jupiter.api.Test;\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass DiscountServiceTest {\n\n private final DiscountService service = new