Test de mutation Java avec PIT : Ne vous fiez plus à vos chiffres de couverture

23 juin 2026

Test de mutation Java avec PIT : Ne vous fiez plus à vos chiffres de couverture

Vous disposez de 100% de couverture des lignes pour les tests unitaires. Et pourtant, un bug est publié.

Cela se produit parce que la couverture de lignes measure quelles lignes de code vos tests exécutent, et non s’ils sont réellement capables de détecter un défaut. Un test qui appelle une méthode, mais ne vérifie rien, fera grimper votre couverture à 100% sans détecter quoi que ce soit. Ce n’est pas un cas isolé. C’est une faille systématique dans la manière dont la plupart des équipes mesurent la qualité des tests.

Le test de mutation est la réponse. Il fonctionne en introduisant automatiquement de petites fautes dans votre code, appelées mutants, et en exécutant votre ensemble de tests sur chacun d’eux. Si les tests échouent, le mutant est écarté. Si les tests passent, le mutant a survécu et vous avez découvert une défaillance dans votre ensemble de tests.

Avant d’aller plus loin, une remarque importante : le PIT n’exige pas que vous écriviez une nouvelle catégorie de tests. Il s’exécute sur les tests JUnit que vous possédez déjà. Considérez-le comme une couche de qualité superposée à votre ensemble de tests existant. Il évalue la performance de ces tests, plutôt que de les remplacer ou de les compléter avec quelque chose de nouveau.

Le PIT (PITest) est l’outil standard pour les tests de mutation sur la JVM. Il est rapide par rapport à des systèmes de mutation plus anciens, s’intègre directement avec Maven et Gradle et génère un rapport HTML consultable dans un navigateur. Cet article couvre tout ce dont vous avez besoin pour configurer le PIT dans un projet Spring Boot avec Maven et JUnit 5, lire le rapport, définir des seuils de qualité et l’exécuter en CI.

Vous voulez le code ? Le projet de démonstration complet est disponible sur GitHub : loiane/spring-boot-pit-demo

Dans cet article, nous aborderons :

Pourquoi la couverture de ligne n’est pas suffisante

La couverture de lignes répond à une question : mon ensemble de tests a-t-il exécuté cette ligne ? Elle ne répond pas : mon ensemble de tests détecterait-il un défaut sur cette ligne ?

L’écart est réel. Considérons cette méthode de service :

public double applyDiscount(double price, double discountPercent) {
    return price - (price * discountPercent / 100);
}

Et ce test :

@Test
void testApplyDiscount() {
    service.applyDiscount(100.0, 10.0);
    // no assertion
}

Couverture de ligne : 100%. Détection de défauts : 0%. Si quelqu’un modifie * la / dans la formule, le test passera encore.

Le test de mutation détecte cela. Le PIT remplacerait * par /, réexécuterait le test et constaterait qu’il est approuvé. Le mutant survit. Le rapport révèle l’écart.

La définition formelle : score de mutation = mutations éliminées / total des mutations. Un score de 80% signifie que 20% des bugs injectés n’ont pas été détectés par votre ensemble de tests.

Comment fonctionnent les mutants PIT

Le PIT modifie le bytecode compilé, pas les fichiers sources. Cela rend son exécution rapide et compatible avec le cycle de vie de compilation Java, sans qu’il soit nécessaire de réécrire le code source.

Pour chaque classe sous test, le PIT génère un ensemble de mutants en appliquant un opérateur à la fois. Ensuite, il collecte la couverture de chaque ligne de test pour déterminer quels tests existants couvrent la ligne mutée et exécute uniquement ces tests sur chaque mutant. Sans nouvelles classes de test, sans configuration séparée d’un exécuteur de tests. Le PIT découvre et réutilise les tests JUnit 5 déjà présents sur votre classpath. La plupart des exécutions modernes prennent quelques minutes, pas des heures, pour des services typiques.

Trois résultats sont possibles pour chaque mutant :

Résultat Signification
Mort Au moins un test a détecté la défaillance et a échoué.
Survivant Tous les tests ont été passés avec le bug présent.
Sans couverture Aucun test n’exécute la ligne modifiée.

Un mutant survivant et un mutant sans couverture indiquent une lacune dans les tests, mais ce sont deux problèmes différents. Sans couverture signifie que la ligne n’a pas été atteinte. Survivant signifie que la ligne a été atteinte, mais les tests n’affirment rien de significatif sur sa sortie.

Les mutateurs par défaut à connaître

Le PIT est fourni avec plusieurs groupes de mutateurs. Le groupe DEFAULTS demeure actif à moins que vous ne le remplaciez. Il n’est pas nécessaire de tout configurer pour commencer, mais savoir ce que le PIT modifie aide à comprendre le rapport.

Mutateur Ce que cela change Exemple
CONDITIONALS_BOUNDARY Remplace < par <=, > par >=, etc. if (a < b)if (a <= b)
NEGATE_CONDITIONALS Inverse les conditionnels: == devient !=, < devient >=, etc. if (a == b)if (a != b)
MATH Opérateurs arithmétiques échangés a + ba - b
INCREMENTS Inversions d’incréments ++ et — dans des variables locales i++i--
VOID_METHOD_CALLS Supprime complètement les appels à des méthodes vides. validate(input) → (supprimé)
NULL_RETURNS Renvoie null pour les méthodes qui ne retournent pas null return userreturn null
EMPTY_RETURNS Retourne une valeur vide pour les collections et les optionnels. return listreturn emptyList()
FALSE_RETURNS Renvoie false pour les méthodes booléennes return isValidreturn false
TRUE_RETURNS Renvoie true pour les méthodes booléennes return isValidreturn true

En plus de DEFAULTS, PIT propose un groupe STRONGER qui ajoute davantage de mutateurs de valeur de retour et un groupe ALL qui active tous les opérateurs expérimentaux. Les mainteneurs déconseillent explicitement l’usage de ALL en pratique, car il génère beaucoup de mutations équivalentes et complique la prise de décisions à partir des rapports.

Pour utiliser l’ensemble le plus robuste :

<configuration>
  <mutators>
    <mutator>STRONGER</mutator>
  </mutators>
</configuration>

Mutations équivalentes : pourquoi 100% n’est pas une cible réaliste

Toutes les mutations survivantes ne représentent pas une lacune dans le jeu de tests. Certaines mutations sont équivalentes : le code modifié produit exactement le même comportement observable que l’original, donc aucun test ne pourra jamais les éliminer.

Un exemple courant est une optimisation visant uniquement les performances, comme le prédimensionnement d’une collection :

// the "+ 1" is a capacity hint, not part of the result
Map<String, String> cache = new HashMap<>(items.size() + 1);

MATHLe mutateur du PIT peut être changé+ 1 pour- 1. La map stocke toujours exactement les mêmes entrées et retourne exactement les mêmes résultats, et seul son comportement interne de redimensionnement change, ce qui est invisible pour toute assertion fonctionnelle. Le comportement observable est identique, donc aucun test que vous écririez ne supprimera jamais ce mutant.

C’est important pour fixer des limites. Un taux de mutation entre 80 et 85% est un excellent résultat pour la plupart des codes de production. Chercher à atteindre 100% signifie généralement écrire des tests fragiles qui existent uniquement pour éliminer des mutations spécifiques et non pour valider le comportement.

L’approche pratique : traitez les mutations survivantes comme une liste de travail priorisée. Lisez le rapport, décidez lesquelles des survivantes représentent de vraies lacunes, ajoutez des assertions ciblées pour celles-ci et acceptez que certaines mutations survivantes resteront équivalentes.

Pour rendre les mutations équivalentes plus visibles, vous pouvez ajouter une sortie XML et des rapports de différences entre les exécutions. Plus de détails dans la section CI.

Configuring PIT dans un projet Spring Boot

Le Projet de Démo

La démo est une API REST Spring Boot composée de trois couches : une couche de contrôleur de développement ProductController, une couche de traitement ProductService et une couche d’accès aux données ProductRepository. Le code source complet est disponible sur GitHub à l’adresse loiane/spring-boot-pit-demo.

Structure du projet :

src/
  main/java/com/loiane/pit/
    controller/
      ProductController.java
    service/
      ProductService.java
    model/
      Product.java
    repository/
      ProductRepository.java
  test/java/com/loiane/pit/
    controller/
      ProductControllerTest.java
    service/
      ProductServiceTest.java

Pré-requisites

L’astuce JUnit 5

PIT ne détecte pas automatiquement les tests JUnit 5 par défaut. Si vous ajoutez le plugin pitest-maven sans le pont JUnit 5, PIT peut afficher 0 tests trouvés et échouer la compilation ou ignorer complètement la mutation. C’est la raison la plus fréquente pour laquelle une première installation de PIT ne produit rien en silence.

La solution : déclarez pitest-junit5-plugin comme une dépendance à l’intérieur du bloc du plugin, et non comme une dépendance du projet.

Configuration minimale fonctionnelle

Ajoutez ce qui suit à votre fichier <build><plugins> dans le pom.xml :

<plugin>
  <groupId>org.pitest</groupId>
  <artifactId>pitest-maven</artifactId>
  <version>1.20.4</version>
  <dependencies>
    <!-- Required for JUnit 5: PIT finds 0 tests without this -->
    <dependency>
      <groupId>org.pitest</groupId>
      <artifactId>pitest-junit5-plugin</artifactId>
      <version>1.2.3</version>
    </dependency>
  </dependencies>
</plugin>

Exécutez le programme une fois pour vérifier que la configuration fonctionne :

mvn test-compile org.pitest:pitest-maven:mutationCoverage

Le rapport s’affiche dans target/pit-reports/. Ouvrez index.html dans un navigateur. Si vous voyez une table de classes avec des scores de mutation, tout est prêt.

Configuration prête pour production

La configuration minimale suffit pour explorer. Pour un vrai projet, vous aurez besoin d’un périmètre explicite, de limites qui interrompent la compilation, de sorties HTML et XML (pour les comparaisons en CI) et d’un historique incrémental pour accélérer les exécutions locales.

<plugin>
  <groupId>org.pitest</groupId>
  <artifactId>pitest-maven</artifactId>
  <version>1.20.4</version>
  <dependencies>
    <dependency>
      <groupId>org.pitest</groupId>
      <artifactId>pitest-junit5-plugin</artifactId>
      <version>1.2.3</version>
    </dependency>
  </dependencies>
  <configuration>
    <!-- Scope to business logic: exclude DTOs, config, generated code -->
    <targetClasses>
      <param>com.loiane.pit.service.*</param>
      <param>com.loiane.pit.controller.*</param>
    </targetClasses>
    <targetTests>
      <param>com.loiane.pit.*</param>
    </targetTests>
    <!-- Use multiple threads to parallelize mutation analysis -->
    <threads>4</threads>
    <!-- Fail the build if mutation score falls below this percentage -->
    <mutationThreshold>80</mutationThreshold>
    <!-- Fail the build if test strength falls below this percentage -->
    <testStrengthThreshold>90</testStrengthThreshold>
    <!-- Use decimal precision to avoid the integer rounding blind spot -->
    <thresholdPrecision>1</thresholdPrecision>
    <!-- HTML for humans, XML for CI tooling and report diffing -->
    <outputFormats>
      <outputFormat>HTML</outputFormat>
      <outputFormat>XML</outputFormat>
    </outputFormats>
    <-- Speed up repeated local runs by reusing mutation history -->
    <withHistory>true</withHistory>
    <!-- Exclude logging calls from mutation. PIT already avoids common -->
    <!-- logging frameworks by default; list them explicitly to be safe -->
    <avoidCallsTo>
      <avoidCallsTo>java.util.logging</avoidCallsTo>
      <avoidCallsTo>org.slf4j</avoidCallsTo>
    </avoidCallsTo>
  </configuration>
</plugin>

Note surthresholdPrecision : la comparaison des seuils utilise par défaut des pourcentages entiers. Un projet ayant une note de mutation de 80,49% avec une limite à 80 passe, car 80,49 arrondit à 80. Un projet à 79,51% passe également (il est arrondi à 80). Cela représente une différence de 1%. Définir thresholdPrecision sur 1 revient à comparer avec une décimale et élimine cette différence.

Note surwithHistory : cela indique à PIT d’effectuer un caching des résultats des mutations entre les exécutions locales. Si une classe et ses tests n’ont pas changé, PIT ignore l’exécution répétée de ces mutations. Dans un projet de taille moyenne, cela peut réduire le temps d’exécution de 50 à 70% pour les exécutions incrémentielles. C’est utile localement, car l’intégration continue (CI) repart de zéro.

Éliminer le bruit

Tout le code ne doit pas être modifié. DTOs, enregistrements, classes de configuration et l’auto-configuration de Spring Boot sont des éléments à exclure. les modifier génère des mutations équivalentes et augmente le rapport sans ajouter d’indice pertinent.

<excludedClasses>
  <param>com.loiane.pit.model.*</param>
  <param>com.loiane.pit.*Application</param>
  <param>com.loiane.pit.config.*</param>
</excludedClasses>

Excluez également des méthodes qui ne peuvent pas être testées de manière significative par mutation, comme hashCode, equals, toString dans les types valeur simples :

<excludedMethods>
  <param>hashCode</param>
  <param>equals</param>
  <param>toString</param>
</excludedMethods>

Utiliser un profil Maven pour maintenir des builds rapides

Le test de mutation est lent par rapport à l’exécution d’un test unitaire. Dans un projet moyen, une exécution complète du PIT peut prendre de 3 à 10 minutes. Vous ne souhaitez pas cela pour tous les tests : mvn test.

La recommandation est d’isoler le PIT derrière un profil Maven. Déplacez toute la configuration du plugin à l’intérieur d’un bloc <profiles> :

<profiles>
  <profile>
    <id>pitest</id>
    <build>
      <plugins>
        <plugin>
          <groupId>org.pitest</groupId>
          <artifactId>pitest-maven</artifactId>
          <version>1.20.4</version>
          <dependencies>
            <dependency>
              <groupId>org.pitest</groupId>
              <artifactId>pitest-junit5-plugin</artifactId>
              <version>1.2.3</version>
            </dependency>
          </dependencies>
          <configuration>
            <!-- configuration complète ici -->
          </configuration>
        </plugin>
      </plugins>
    </build>
  </profile>
</profiles>

Désormais, la configuration normale reste inchangée :

mvn test            # rapide, pas d’analyse de mutation
mvn -Ppitest test   # exécute les tests unitaires + l’analyse complète des mutations

En CI, activez le profil uniquement lors de l’étape de test de mutation.

Lecture du rapport PIT

Le rapport HTML se situe dans target/pit-reports/. Ouvrez index.html.

Page de vue d’ensemble

Le tableau récapitulatif affiche toutes les classes en portée avec quatre métriques :

Colonne Ce que mesure
Ligne % Couverture de ligne standard
Mutation % Score de mutation (morts / total)
Force du test Mort / (mort + survivant), exclut les mutants sans couverture
Mutations Comptage brut : morts, survivants, sans couverture, dépassement de temps

Triez par le pourcentage de mutation croissant pour repérer en premier les classes avec le plus faible niveau de tests.

Le projet de démonstration a atteint 92% de couverture des mutations et 92% de robustesse des tests. Les fines bandes roses dans la ligne com.loiane.pit.service indiquent les mutations restantes qui attendent encore une confirmation.

Analyse au niveau de la classe

Cliquez sur n’importe quel nom de classe pour ouvrir la vue du code source. Chaque ligne est annotée :

Les lignes roses foncées sont celles sur lesquelles vous devez porter une attention particulière. Elles montrent que vos tests atteignent le code, mais ne le valident pas suffisamment pour détecter une modification légère.
Visualização do código-fonte PIT de ProductService.java com os mutantes eliminados em linhas verdes e dois mutantes de limite sobreviventes destacados em rosa nas verificações de desconto e quantidade.

Dans l’image ci‑dessus de ProductService, les lignes vertes sont entièrement éliminées, tandis que les deux lignes roses (la ligne de garde discountPercent dans applyDiscount et la ligne de garde bulkDiscountRate dans validate) présentent chacune un mutant CONDITIONALS_BOUNDARY survivant. Toutes deux sont atteintes par les tests, mais jamais vérifiées exactement à la limite.

Agir en fonction du rapport

Utilisez ce tableau de décision lors de l’analyse des survivants :

Statut Signification Action recommandée
Mort Le test a détecté la défaillance. Aucune action nécessaire
Survivant Les tests passent, mais avec un bogue présent. Ajouter ou renforcer une assertion
Sans couverture Aucun test n’exécute cette ligne Ajouter un test qui couvre ce chemin
Temps limite dépassé Il est probable que la mutation ait provoqué une boucle infinie. Généralement du bruit; vérifier si cela se produit fréquemment.

Pour les mutants survivants, cliquez sur la ligne pour voir la mutation exacte. PIT montre ce qui a changé. Rédigez l’assertion minimale qui détecterait ce changement précis. Dans la plupart des cas, cela signifie vérifier la valeur de retour de la méthode testée, et pas seulement si elle a été appelée.

Exemple : dans le projet de démonstration, le rapport PIT affiche un taux de mutation de 92% avec trois survivants, tous des mutants CONDITIONALS_BOUNDARY sur des vérifications de limites exactes (la garde discountPercent > 100 dans applyDiscount, la garde quantity < 0 dans bulkDiscountRate et stockQuantity < 0 dans validate). Chacun signifie que les tests exercent la méthode sans jamais vérifier le comportement exactement à la valeur limite. Ajoutez un test qui fournit une entrée exactement à la limite et vérifie la sortie attendue, et le mutant sera éliminé.

Exécution du PIT sur GitHub Actions

Ce workflow exécute le PIT comme une étape distincte pour chaque push et pull request dirigée vers main. Le rapport HTML est archivé en tant qu’artefact de compilation afin que les réviseurs puissent l’inspecter sans avoir à relancer l’analyse.

Créer le fichier .github/workflows/mutation.yml :

name: Mutation Testing

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  mutation:
    name: PIT Mutation Analysis
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up Java
        uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: 'temurin'
          cache: maven

      - name: Compile and run mutation tests
        run: ./mvnw -Ppitest -B test-compile org.pitest:pitest-maven:mutationCoverage

      - name: Archive mutation report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: pit-mutation-report
          path: target/pit-reports/
          retention-days: 14

Quelques décisions de conception de ce flux de travail méritent d’être mentionnées :

Échec de la compilation en raison de violations de seuil

Avec les options –no-limit mutationThreshold et –no- testStrengthThreshold, PIT peut terminer la tâche avec un code de sortie non nul lorsque le score tombe en dessous du seuil. GitHub Actions interprète ces codes de sortie comme des échecs, ce qui signifie qu’aucune configuration supplémentaire n’est nécessaire et la tâche passera rouge.

Commencez avec un seuil de 70% et augmentez-le progressivement à mesure que vous ajoutez des assertions. Définir un seuil à 80% dès le premier jour, sur un code legacy avec une faible couverture, crée simplement des frictions. Le seuil est un plancher, pas un objectif.

Performance : Maintenir PIT rapide dans des projets réels

Dans un petit projet, le PIT s’exécute en moins d’une minute. Dans une application Spring Boot de taille moyenne, il peut prendre de 5 à 15 minutes sans ajustements. Voici les contrôles les plus efficaces :

Utilisez plusieurs threads. Le PIT parallélise l’analyse des mutations entre les threads. Configurer <threads>4</threads> sur un exécuteur CI à 4 cœurs réduit habituellement le temps d’exécution de moitié. Le nombre idéal se situe généralement entre 1 et le nombre de processeurs disponibles.

Définissez le périmètre targetClasses de manière agressive. C’est le levier le plus important. Chaque classe ajoutée au périmètre contribue avec son ensemble de mutants à l’exécution, et le temps d’exécution croît avec le nombre total de mutants générés. Concentrez-vous sur la logique métier (services, objets de domaine, validateurs) et excluez :

Utilisez withHistory localement. En exécutant PIT régulièrement pendant le développement, cet historique réutilise les résultats de classes inchangées. La première exécution est lente. Les exécutions suivantes sur du code inchangé sont instantanées.

Excluez les tests lents de l’exécution des mutations. Si votre ensemble de tests comprend des tests d’intégration ou des slices lourds comme les @SpringBootTest, PIT essaiera de les exécuter pour chaque mutant. Utilisez –fast-test ou excludedTestClasses pour restreindre PIT uniquement aux tests unitaires rapides. Exécutez les tests d’intégration sur une étape CI séparée.

Utilisez le mode simulation pour déboguer les soucis de configuration. Si PIT se comporte de manière inattendue (mauvais classes détectées, tests non détectés), ajoutez <dryRun>true</dryRun> pour obtenir les modifications nécessaires à la configuration. Le mode simulation collecte la couverture et génère des mutants sans les exécuter sur les tests. C’est bien plus rapide et cela révèle les erreurs de configuration sans attendre l’exécution complète des mutations.

Conclusion

La couverture de lignes est une exigence de base nécessaire. Cependant, ce n’est pas un indicateur suffisant de la qualité des tests. Le test de mutation comble cette lacune. Il indique non seulement quelles lignes vos tests couvrent, mais aussi quelles modifications logiques vos tests détecteraient réellement.

Le PIT est la manière la plus pratique d’ajouter des tests de mutation à un projet Java aujourd’hui. Avec le plugin JUnit 5 installé et un profil Maven encapsulant la configuration, vous pouvez l’intégrer à un projet Spring Boot existant en moins d’une heure, l’exécuter en CI via un workflow GitHub Actions et obtenir un rapport qui indique exactement où renforcer vos assertions.

Une feuille de route raisonnable pour l’adoption :

  1. Ajoutez le plugin avec la dépendance JUnit 5. Exécutez localement une fois. Établissez un score de mutation de référence.
  2. Le périmètre se limite aux paquets de logique métier. Excluez les DTOs, la configuration et le code généré.
  3. Définissez une limite égale ou légèrement inférieure à la baseline. Confirmez.
  4. Ajoutez des vérifications spécifiques pour les mutants survivants dans les classes critiques. Augmentez le seuil à mesure que vous améliorez le processus.
  5. Ajoutez le workflow GitHub Actions.Archivez les rapports. Contrôlez les PRs sur la base du seuil établi.
  6. Utilisez withHistory localement pour maintenir un cycle de rétroaction rapide pendant le développement.

L’objectif n’est pas d’atteindre 100%. L’objectif est d’avoir la confiance que les tests que vous avez réalisés détecteront les erreurs qui comptent.

Fabien Delpont

Auteur

Fabien Delpont

Fabien Delpont, développeur et créateur du site Python Doctor.