Il y a bien longtemps, dans une galaxie lointaine, très lointaine…
Une sombre menace plane sur la planète Quality. Des clones mutants, silencieux et perfides, se répandent dans le code, menaçant d’infiltrer chaque système pour semer le chaos et la destruction.
Mais l’espoir n’est pas perdu. La courageuse princesse Leia rassemble ses troupes de développeurs et, armée de puissants tests, prépare une contre-attaque acharnée pour éradiquer ces mutants et sauver Quality de l’effondrement.
Développeurs, soyez prêts. La planète Quality a besoin de vous ! Ensemble, nous apprendrons à nous défendre contre ces mutants et repousser l'obscurité qui menace notre code.
Un peu de contexte...
En tant que développeurs, on aime quand tout se passe comme prévu. Une pratique aujourd'hui indispensable de notre quotidien consiste à écrire des tests automatisés en amont ou en aval des développements. On peut justifier ces tests pour :
- Détecter les bugs au plus tôt en cas d'évolution, et vérifier la non-régression,
- Spécifier de nouvelles exigences et vérifier que le code fonctionne comme attendu,
- Gagner du temps lors de la phase de tests manuels,
- Gagner du temps lors des phases de développement futures et faciliter le refactoring,
- Documenter le code,
- Ou encore, guider le développeur explorateur dans le design de son code.
Les tests, c'est bien. Encore faut-il qu'ils soient pertinents, robustes et suffisamment exhaustifs.
Modifier le code en toute confiance, tester te permettra ; et en production le vendredi, sereinement déployer, tu pourras.
— Yoda (Grand maître développeur)
Les tests de mutation, qu'est-ce que c'est ?
Revenons au sujet de cet article : Les tests de mutation.
Les tests de mutation, c'est un peu comme un jeu d'attrape-moi-si-tu-peux dans le code. L'idée est d'y introduire volontairement de petits changements affectant le comportement du code, qu'on appelle mutants, et de vérifier si les tests existants sont capables de les détecter.
Comment sont générés ces mutants ? Généralement, les outils permettant de les générer travaillent directement au niveau du bytecode pour appliquer des modifications, par exemple :
- Changer des opérateurs arithmétiques,
- Altérer des opérateurs de comparaison,
- Modifier des constantes,
- Retirer des blocs de code,
- Modifier des valeurs de retour,
- etc.
Ensuite, l'objectif est simple : les tests doivent tuer les mutants.
Pour tuer un mutant, au moins un des tests doit échouer.
La Force est avec moi ! Mais n'est-ce pas créer un déséquilibre que de pousser nos tests à l'échec ?
— Luke Skywalker (Développeur innocent)
Luke, je sens la peur t'envahir, laisse-toi aller vers le côté obscur et ensemble nous...
— Darth Vader (Développeur malveillant)
Voooous ne passereeeez paaaaaaaaaas ! kof kof ... kof...
— Gandalf (Développeur itinérant)
Les limites de la couverture de code
Quand on évoque la notion de couverture de code, on pense généralement qu'il s'agit de la part de code traversé par l'exécution des tests automatisés. Cette métrique est très souvent mise en œuvre pour justifier d'une certaine qualité du code.
Souvent, elle est imposée au niveau d'une entreprise sans en considérer les objectifs sous-jacents.
Il n'est pas rare d'exiger un taux de couverture de 80%, 90%... Objectif qui peut être, selon le contexte, facilement atteignable. On parle alors d'Assertion Free Testing.
Chewie, tu entends ? Nous n'avons qu'à traverser, alors traversons !
— Han Solo (Développeur fougueux)
Rawrgwawgrr !
— Chewbacca (Développeur d'accord)
En changeant notre point de vue sur la couverture de code, on parvient à rendre nos tests plus pertinents. Les tests de mutations sont un outil supplémentaire pour parvenir à cet objectif, qui vont nous permettre de révéler les défaillances de la couverture de code et donc de nos tests. En plus de la couverture induite par l'exécution, ils se concentrent sur la vérification des assertions.
La couverture doit être une conséquence et non un objectif, concentre ta force là où elle te permettra d'aller plus loin.
— Obi-Wan Kenobi (Développeur sage)
Les tests de mutation par l'exemple
La théorie, c'est nécessaire, mais la pratique, c'est indispensable pour se forger un avis.
Dans la suite de cet article, nous allons mettre en place ce type de tests dans un projet Java qui me sert de base à moultes expérimentations.
Je suis prête à tous les affronter. Vitesse lumière !
— Rey (Développeuse curieuse... et fougueuse aussi)
PITest : des mutants au sein de mes tests Java
De nombreux outils de mutation testing existent selon les langages : Stryker pour JavaScript / TypeScript
, mutmut pour le langage python
... Il en est un dans l'écosystème Java
(et JVMs associées) très populaire : PITest.
Sa configuration avec un plugin maven est relativement simple.
ℹ
Pour plus d'informations sur les possibilités de configuration du plugin, je vous recommande d'aller voir la documentation existante : PITest : Maven Quick Start
<plugins>
...
<plugin>
<groupId>org.pitest</groupId>
<artifactId>pitest-maven</artifactId> #1
<version>${pitest-maven.version}</version>
<dependencies>
<dependency>
<groupId>org.pitest</groupId>
<artifactId>pitest-junit5-plugin</artifactId> #2
<version>${pitest-junit5-plugin.version}</version>
</dependency>
</dependencies>
<configuration>
<outputFormats>
<outputFormat>HTML</outputFormat> #3
</outputFormats>
<targetClasses>
<targetClass>n.g.a.games.playhaa.games.**.domain.**.*</targetClass> #4
</targetClasses>
<targetTests>
<targetTest>n.g.a.games.playhaa.games.**.domain.**.*Test</targetTest> #5
</targetTests>
</configuration>
</plugin>
...
</plugins>
- #1 - Le plugin maven pour PITest
- #2 - Le plugin de support pour les tests écrits avec JUnit 5
- #3 - Le format de sortie : Par défaut, c'est HTML (mais c'est pour vous dire que ça existe...)
- #4 - Le filtre de sélection des classes à muter
- #5 - Le filtre de sélection des tests à exécuter avec les mutations
Maintenant que vous avez intégré PITest à votre projet, il ne vous reste plus qu'à lancer votre premier test de mutation à l'aide de la commande suivante :
mvn clean test-compile org.pitest:pitest-maven:mutationCoverage
Les mutants ont-ils survécu ? Analyse des résultats de l'exécution de PITest
À la suite de l'exécution des tests, un rapport HTML a été généré dans le répertoire target/pit-reports
.
Ouvrons le fichier index.html
et analysons un peu les résultats.
Tout d'abord, on a un résumé des résultats globaux :
-
Number of classes : C'est le nombre total de classes qui ont muté dans le projet (correspondant au filtre
targetClasses
), - Line Coverage : C'est le taux de couverture du code par les tests (exécution),
- Mutation Coverage : C'est le ratio entre le nombre de mutants tués par les tests et le nombre de mutants générés,
- Test Strength : C'est le ratio entre le nombre de mutants tués parmi tous les mutants couverts par les tests.
Ces résultats globaux sont suivis d'une synthèse des résultats par package. Pour chaque package, il est possible d'aller consulter un rapport plus détaillé :
Si l'on souhaite obtenir le détail sur les mutations qui ont été réalisées, il est également possible d'accéder au rapport de chacune des classes qui ont été mutées :
On voit que ce n'est pas fait en TDD, maître...
— C3PO (Développeur rabat-joie)
Que constate-t-on ?
- Dans le package
n.g.a.games.playhaa.games.join.domain
, le taux de couverture de code est de 100%. - ... Par conséquent, la couverture pour la classe
JoinGameService
est également de 100%. - Toutefois, dans cette classe, 3 mutants ont survécu, car non détectés par les tests :
- À la ligne
43
, l'addition a été remplacée par une soustraction - À la ligne
44
, la condition a été inversée - À la ligne
45
, la condition a également été inversée
- À la ligne
Pourquoi de telles mutations qui semblent absolument non pertinentes ? Parce que le code évolue, les développeurs changent, le code devient de plus en plus riche et parfois complexe. L'introduction d'un changement sans en mesurer la portée peut avoir des effets de bords non désirés et provoquer des bugs.
Ces mutations représentent la réalité de ce qui peut arriver dans la vie d'un logiciel.
Si ces mutants ont survécu aux tests, c'est qu'aucun test n'a échoué suite à leur introduction. En résumé, il manque au moins une assertion.
L'explication du taux de couverture de 100% se trouve dans un test écrit pour le cas nominal.
@DisplayName("Should join an existing game")
@Test
void shouldJoinAnExistingGame() {
// Arrange
GameId joinedGameId = new GameId("2a8fb592-876f-4f38-a014-3df9e810b1b0");
// ... prepare existing joinable game
int maxPlayers = 2;
DeckId creatorDeckId = new DeckId("3a8fb592-876f-4f38-a014-3df9e810b1b0");
Deck creatorDeck = TestDeckBuilder.defaultBuilder().id(creatorDeckId).build();
GamePlayer currentCreatorPlayer = TestGamePlayerBuilder.defaultBuilder().deck(creatorDeck).build();
Game existingGame = new Game(joinedGameId,
"L'attaque des clones", Difficulty.STANDARD, GameStatus.WAITING,
List.of(currentCreatorPlayer), maxPlayers); // #1
// ... prepare command
DeckId joinerDeckId = new DeckId("73fa2619-5838-40e6-9aa9-8a341db3c418");
JoinGameCommand joinGameCommand = new JoinGameCommand(joinedGameId, joinerDeckId); // #2
// ... prepare use case
JoinGameRepository joinGameRepository = new InMemoryJoinGameRepository(existingGame);
JoinGameUseCase useCase = new JoinGameService(joinGameRepository);
// Act
Game gameUpdated = useCase.join(joinGameCommand); // #3
// Assert
assertEquals(joinedGameId, gameUpdated.id());
assertTrue(gameUpdated.hasPlayerDeck(joinerDeckId)); // #4
}
- #1 - On simule une partie existante avec un seul joueur (le créateur) et un nombre de joueurs maximum de 2.
- #2 - On initie une commande avec l'id de la partie à rejoindre et l'id du deck du nouveau joueur
-
#3 - On appelle la méthode
join
du service lié à notre cas d'utilisation - #4 - On vérifie que le deck du nouveau joueur est bien présent dans la partie
L'objectif de ce test est de vérifier qu'un nouveau joueur qui rejoint une partie s'y retrouve bien. En aucun cas, on ne vérifie l'état de la partie, ce n'est pas l'objectif ici.
En analysant davantage les tests existants de cette classe, on constate qu'aucun d'entre eux ne vient vérifier un changement sur l'état d'une partie suite à l'arrivée du dernier joueur et c'est la cause de la survie de nos chers mutants !
Ajoutons donc un nouveau test avec l'assertion sur l'état du jeu :
@DisplayName("Should mark game as READY when max players is reached")
@Test
void shouldMarkGameAsReadyWhenMaxPlayersIsReached() {
// ... les conditions d'entrée sont identiques au test du cas nominal précédent
// Assert
assertEquals(GameStatus.READY, gameUpdated.status());
}
Maintenant que nous avons ajouté ce nouveau test, relançons nos tests de mutation et vérifions les résultats obtenus :
Les mutants introduits dans notre classe ont tous été éliminés !
Un peu d'explications :
Lors de l'exécution du test du cas nominal vu précédemment - sans mutation -, la partie passe bien à l'état READY. Cependant, aucune assertion ne permettait de s'en assurer. Nos 3 mutants ont alors saisi cette occasion pour s'introduire discrètement dans notre classe et passer inaperçus.
Nos efforts pour consolider les tests ont porté leurs fruits. Grâce à votre détermination, nous avons renforcé nos défenses et remporté une première bataille.
Que la force soit avec nous !
— Princesse Leia Organa (Lead Développeuse motivante)
Les limites des tests de mutation
Parlons maintenant des limites de ces tests, car il y en a bien sûr ! On peut citer principalement :
- Le temps d'exécution,
- La consommation en ressources machine.
L’un des coûts principaux de la MT est les ressources computationnelles nécessaires pour exécuter les tests. Cette technique implique la génération d’un grand nombre de mutants, chacun représentant une version légèrement modifiée du logiciel original, et l’exécution de la suite de test contre chacun de ces mutants. À mesure que le nombre de mutants augmente, la quantité de puissance de calcul et le temps d’exécution requis pour le processus de test augmentent de manière exponentielle.
— Moez Krichen (Une enquête sur le test de mutation)
Concentrons-nous sur la limite de temps d'exécution. Dans le cas des tests de mutation, ce temps est fonction de plusieurs paramètres, par exemple :
- Les caractéristiques de la machine qui exécute les tests (processeur, mémoire, etc.),
- Le nombre de classes à muter et donc le nombre de mutants générés,
- Le nombre de tests sélectionnés et leur temps d'exécution dans un contexte normal (sans mutations),
- Les optimisations apportées telles que la parallélisation, les types de mutants générés,
- etc.
Essayons de faire quelques tests dans le projet qui nous sert en exemple dans cet article.
- Les résultats suivants sont obtenus sur un environnement de développement local. Ils sont exécutés sur un laptop possédant un processeur Core i7 (1.80Ghz / 4 cœurs) et 16Go de RAM.
- Le terme domaine, employé par la suite, correspond au cœur de l'application. Il est préservé de toute dépendance extérieure au projet (librairies, frameworks, etc.) et ne dépend pas de ce qui l'entoure (controllers HTTP, repositories JPA, etc.). On y retrouve uniquement des tests unitaires (qui doivent-être rapides à s'exécuter). À l'extérieur du domaine, on retrouvera des tests unitaires, mais aussi des tests d'intégration (qui sont plus longs à s'exécuter).
- Le nombre de threads utilisés pour paralléliser l'exécution des tests de mutation avec PITest est de 4,
- Enfin, aucune optimisation supplémentaire n'a été réalisée lors de ces tests. L'objectif est ici, d'obtenir de premiers résultats et d'en sortir quelques hypothèses.
Contexte | Le domaine "games" | Tous les domaines | Tout le projet |
---|---|---|---|
Nombre de classes sélectionnées | 9 | 20 | 66 |
Nombre de mutants générés | 84 | 167 | 499 |
Nombre de tests sélectionnés | 24 | 62 | 131 #1 |
Nombre d'exécutions de tests | 111 | 218 | 855 |
Temps d'exécution des tests de mutation #2 | 26 sec | 54 sec | 11 min 11 sec |
Temps d'exécution de référence des tests sélectionnés #3 | 982 ms | 1 sec 673 ms | 14 sec 72 ms #4 |
- #1 - Le périmètre projet ici inclut des tests d'intégration qui sont plus longs à s'exécuter
- #2 - Temps nécessaire à l'exécution parallèle des tests de mutation,
- #3 - Temps nécessaire à l'exécution des tests sélectionnés, sans mutations et sans parallélisation,
- #4 - Dont 11 secondes pour les tests d'intégration nécessitant le démarrage de l'application context.
On peut d'ores et déjà constater que ces paramètres ont eu un impact significatif sur les temps d'exécution :
- Le nombre de classes en entrée (et donc le nombre de mutants générés)
- Le temps d'exécution trop long de certains tests (ici les tests d'intégration)
Que peut-on en conclure ?
Les avantages d'intégrer les tests de mutation dans notre processus de développement logiciel sont réels, nous avons pu le voir. Ils nous permettent d'identifier les lacunes et les faiblesses dans nos tests dans l'optique de les consolider.
Les projets vivent, les développeurs changent, ainsi que les pratiques de test. Ces mutants seront alors de précieux alliés qui permettront de maintenir un niveau de qualité et de fiabilité dans le temps.
Toutefois, il faut également considérer leurs limites, dont les principales : le temps d'exécution global pour l'ensemble des mutants générés, et la consommation en ressources de calcul. Pour contourner ces limites, il conviendra alors de faire les meilleurs choix possibles en fonction de votre contexte.
Tout ceci est bien joli maître, mais si cela avait été fait en TDD, sans doute que...
Maître, que faites-vous ... non ne touchez (...)
— C3PO (Développeur rabat-joie une fois de trop)
Et maintenant... c'est à vous de jouer ;)
💡 En complément, on peut aussi reconsidérer un peu sa manière de concevoir et de tester. Il est une pratique qui, si elle est maitrisée, permet de grandement limiter ces défaillances : le TDD (Test Driven Development), mais ça, c'est une autre histoire...
Top comments (0)