Dernièrement, je suis intervenu sur un projet Maven Java 17, pour rétablir l’indicateur de couverture du code par les tests affiché dans SonarQube. C’est un problème classique dont les causes peuvent être multiples. Dans mon cas, c’était l’activation des preview features de Java 17 pour permettre la JEP 406: Pattern Matching for switch. J’ai profité de cette petite aventure pour aplanir le sujet, je l’espère, une bonne fois pour toutes.
Petits rappels sur Surefire, Failsafe, JaCoCo, SonarQube et SonarScanner
Afficher dans SonarQube la couverture du code par les tests d’un projet Maven fait intervenir plusieurs plugins. Commençons par préciser brièvement leur rôle.
Surefire
Surefire est un plugin proposé nativement par Maven, dont le principal goal, test
, est dédié à l’exécution des tests unitaires. Par défaut, Surefire exécute le code des classes du dossier src/test/java/
dont le nom satisfait l’un de ces patterns : **/Test*.java
, **/*Test.java
, **/*Tests.java
ou **/*TestCase.java
(référence).
Failsafe
Failsafe est un autre plugin standard de Maven, dédié à l’exécution des tests d’intégration. 2 goals principaux sont définis, integration-test
et verify
, respectivement prévus pour exécuter les tests d’intégration et vérifier leur exécution.
Par défaut, Failsafe considère comme tests d’intégration les classes du dossier src/test/java/
dont le nom satisfait l’un de ces patterns : **/IT*.java
, **/*IT.java
ou **/*ITCase.java
(référence).
À la différence de Surefire toutefois, Failsafe n’est pas automatiquement bindé dans le cycle de vie par défaut, il faut l’ajouter explicitement à l’élément <build />
comme un plugin tiers.
Au besoin, on pourra trouver ici la liste des plugins automatiquement appelés dans le cycle de vie par défaut.
JaCoCo
JaCoCo est un outil permettant de mesurer la couverture du code par les tests. Développé au sein du même repository GitHub, le plugin du même nom permet d’intégrer JaCoCo dans un projet Maven. Plusieurs goals existent, pour répondre à une variété de situations. Dans la suite de ce post, nous utiliserons :
prepare-agent
pour injecter dans Surefire un Agent chargé d’instrumenter les classes de tests unitaires à la volée. Grâce à l’Agent, le fichierjacoco.exec
collecte les données sur la couverture du code par les tests.report
pour mettre en forme les données collectées au fil de l’exécution des tests par l’Agent. HTML, CSV et XML sont supportés.prepare-agent-integration
etreport-integration
, même chose queprepare-agent
etreport
mais pour Failsafe et les tests d’intégration.report-aggregate
pour produire un rapport agrégé à partir de plusieurs fichiers de données.
SonarQube
SonarQube est un outil d’analyse statique de code. Il se présente comme une application Web, et permet de suivre l’évolution de la qualité du code au cours du temps. SonarScanner for Maven permet de lancer une analyse à partir du build Maven, grâce au goal sonar
.
Phases et ordre d’exécution
Phases par défaut
La plupart des plugins Maven ont été écrits pour que chaque goal s’exécute à une phase bien précise du cycle de vie par défaut. Le maven-help-plugin
permet d’afficher la phase à laquelle chaque goal est lié. Par exemple, pour le goal integration-test
du plugin Failsafe :
$ mvn help:describe -DartifactId=maven-failsafe-plugin -DgroupId=org.apache.maven.plugins -Dgoal=integration-test -Ddetail
L’exécution en ligne de commande affiche (entre autres choses) :
[INFO] Mojo: 'failsafe:integration-test' failsafe:integration-test Description: Run integration tests using Surefire. Implementation: org.apache.maven.plugin.failsafe.IntegrationTestMojo Language: java Bound to phase: integration-test
Plugin | Surefire | Failsafe | JaCoCo | Sonar | |||||
---|---|---|---|---|---|---|---|---|---|
Goal | test |
integration-test |
verify |
prepare-agent |
report |
prepare-agent-integration |
report-integration |
report-aggregate |
sonar |
Phase | test |
integration-test |
verify |
initialize |
verify |
pre-integration-test |
verify |
(aucune) | (aucune) |
jacoco:report-aggregate
et sonar:sonar
n’étant reliés à aucune phase par défaut, c’est un choix qui nous incombe.
Dans le cycle de vie par défaut, après verify
vient install
, dont le rôle est d’installer le package dans le repository local. Je choisis verify
, qui me paraît plus approprié pour l’exécution des 2 goals.
Plusieurs goals sont donc lancés durant verify
. Comment contrôler l’ordre d’exécution au sein d’une même phase ? Des éléments de réponse existent dans cette demande de documentation, toujours ouverte au moment d’écrire ce post. Marquée comme résolue en 3.0.3 (bien que le changelog ne la mentionne pas), cette autre demande implique que l’on peut se fier à l’ordre d’apparition dans le POM pour contrôler l’ordre d’exécution.
Jusqu’à la version 0.8.8 de JaCoCo, comme report-aggregate
s’appuie sur les dépendances décrite dans le POM, ce goal fonctionne mieux dans un module Maven dédié d’un projet multi-modules. La version 0.8.9, en devenir au moment de rédiger ce post, ajoute un paramètre includeCurrentProject
qui permettra d’utiliser report-aggregate
dans un projet sans module.
Dans la suite du post, nous verrons 2 mises en œuvre :
- Dans un projet sans module, avec l’import dans SonarQube de 2 rapports de couverture, l’un par les tests unitaires, l’autre par les tests d’intégration
- Dans un projet avec modules, avec l’agrégation de tous les fichiers de données en un seul rapport
Ci-dessous un diagramme montrant les dépendances entre goals pour ces 2 scénarios :
Projet Maven sans module
Pour un projet sans module avec tests unitaires et d’intégration, les parties intéressantes du pom.xml
ressemblent à ceci :
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>demo</groupId> <artifactId>simple-java-project</artifactId> <version>1.0-SNAPSHOT</version> <name>simple-java-project</name> <!-- FIXME change it to the project's website --> <url>http://www.example.com</url> <properties> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <maven.compiler.source>17</maven.compiler.source> <maven.compiler.target>17</maven.compiler.target> <sonar.host.url>http://localhost:9000</sonar.host.url> <sonar.login><!-- SonarQube-generated token --></sonar.login> </properties> <dependencies> <!-- [...] --> </dependencies> <build> <pluginManagement> <!-- [...] --> </pluginManagement> <plugins> <plugin> <artifactId>maven-failsafe-plugin</artifactId> <executions> <execution> <goals> <goal>integration-test</goal> <goal>verify</goal> </goals> </execution> </executions> </plugin> <plugin> <groupId>org.jacoco</groupId> <artifactId>jacoco-maven-plugin</artifactId> <version>0.8.8</version> <executions> <execution> <id>prepare-agent</id> <goals> <goal>prepare-agent</goal> </goals> </execution> <execution> <id>report</id> <goals> <goal>report</goal> </goals> </execution> <execution> <id>prepare-agent-integration</id> <goals> <goal>prepare-agent-integration</goal> </goals> </execution> <execution> <id>report-integration</id> <goals> <goal>report-integration</goal> </goals> </execution> </executions> <configuration> <formats>XML</formats> </configuration> </plugin> <plugin> <groupId>org.sonarsource.scanner.maven</groupId> <artifactId>sonar-maven-plugin</artifactId> <version>3.9.1.2184</version> <executions> <execution> <phase>verify</phase> <goals> <goal>sonar</goal> </goals> </execution> </executions> </plugin> </plugins> </build> </project>
Pour remonter la couverture du code par les tests dans SonarQube, seul le format XML est nécessaire. Je surcharge donc le format de sortie de JaCoCo, faisant ainsi l’économie des exportations en CSV et HTML dont je n’ai pas besoin.
prepare-agent
configure l’exécution de Surefireprepare-agent-integration
configure l’exécution de Failsafe- Quand Surefire s’exécute, le fichier
target/jacoco.exec
accumule les données - Quand Failsafe s’exécute, le fichier
target/jacoco-it.exec
accumule les données report
traite les données detarget/jacoco.exec
et enregistre un rapport XML danstarget/site/jacoco/jacoco.xml
report-integration
traite les données detarget/jacoco-it.exec
et enregistre un rapport XML danstarget/site/jacoco-it/jacoco.xml
sonar
trouve les rapports et les importe dans SonarQube
Projet Maven multi-modules
L’exemple présenté ci-dessus fonctionne presque correctement dans un projet Maven multi-modules. Une mesure de la couverture du code par les tests est remontée, mais elle traite chaque module en isolation, comme s’il s’agissait de projets indépendants.
Pour illustrer un peu le problème, prenons un exemple très simple. Supposons Adder
une classe dans un module lib
et AdderTest
une classe de tests dans un module app
.
class AdderTest { @Test void shouldAddNumbers() { assertEquals(4, new Adder().add(1, 3)); } }
On ne distribuerait évidemment pas les classes ainsi en vrai. En revanche, il est courant que des tests de composants ou d’intégration de modules applicatifs servent de filet de sécurité pour attraper des régressions sur des abstractions communes de modules utilitaires. Bien que la méthode add(int, int)
soit testée, avec un tel découpage en modules, la couverture du code par les tests tombe à 0. Pour ces situations, JaCoCo propose un goal Maven : report-aggregate
, qui permet de consolider l’analyse de plusieurs modules.
La pratique courante est d’ajouter un module Maven supplémentaire dédié à la consolidation et l’exécution du SonarScanner. Ce module, (appelé coverage
dans l’exemple qui suit), ne contient pas de code Java, mais tire les autres modules du projet comme dépendances. Nous pourrions penser que configurer SonarScanner au sein d’un module en réduirait le périmètre d’étude, mais il n’en est rien : tout le projet est scanné.
Par défaut, report-aggregate
crée le rapport dans le fichier target/site/jacoco-aggregate/jacoco.xml
du module coverage
. Cet emplacement ne fait, en revanche, pas partie des emplacements par défaut scrutés par SonarScanner. Pour chaque module (parent compris), SonarScanner importe dans SonarQube le rapport agrégé, qui n’en conserve que les informations se rapportant au module scanné. Il faut donc explicitement renseigner la propriété sonar.coverage.jacoco.xmlReportPaths
de telle sorte qu’à chaque analyse de module, SonarScanner utilise le seul fichier jacoco.xml
qui existe.
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <artifactId>multi-module-project</artifactId> <groupId>demo</groupId> <version>1.0-SNAPSHOT</version> </parent> <artifactId>coverage</artifactId> <version>1.0-SNAPSHOT</version> <name>coverage</name> <!-- FIXME change it to the project's website --> <url>http://www.example.com</url> <properties> <sonar.host.url>http://localhost:9000</sonar.host.url> <sonar.login><!-- SonarQube-generated token --></sonar.login> <sonar.coverage.jacoco.xmlReportPaths>${project.basedir}/../coverage/target/site/jacoco-aggregate/jacoco.xml</sonar.coverage.jacoco.xmlReportPaths> </properties> <dependencies> <dependency> <groupId>demo</groupId> <artifactId>lib</artifactId> <version>${project.version}</version> </dependency> <dependency> <groupId>demo</groupId> <artifactId>app</artifactId> <version>${project.version}</version> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.jacoco</groupId> <artifactId>jacoco-maven-plugin</artifactId> <executions> <execution> <id>report-aggregate</id> <phase>verify</phase> <goals> <goal>report-aggregate</goal> </goals> </execution> </executions> <configuration> <formats>XML</formats> </configuration> </plugin> <plugin> <groupId>org.sonarsource.scanner.maven</groupId> <artifactId>sonar-maven-plugin</artifactId> <version>3.9.1.2184</version> <executions> <execution> <phase>verify</phase> <goals> <goal>sonar</goal> </goals> </execution> </executions> </plugin> </plugins> </build> </project>
Modifier le paramètre argLine
de Surefire ou Failsafe
Certaines situations nécessitent de passer des arguments à la JVM qui exécute les tests. Cela peut être pour accroître la quantité de mémoire allouée à la heap, obtenir un dump de celle-ci suite à un crash, contourner l’encapsulation forte du JPMS, ou encore activer les preview features de Java. Surefire et Failsafe mettent à disposition le paramètre argLine
qui permet de passer des options à la JVM lors de l’exécution des tests. Sa définition, et le code qui détermine comment lancer la JVM, sont communs aux 2 plugins. Le propos qui suit s’applique donc tout autant à surefire:test
qu’à failsafe:integration-test
.
Une modification hâtée d’argLine
peut entraîner l’affichage d’une couverture du code par les tests erronée dans SonarQube. En effet, le paramètre est insidieusement modifié par les goals prepare-agent
et prepare-agent-integration
de JaCoCo pour instrumenter les classes pendant l’exécution des tests. Au runtime, la propriété argLine
du projet Maven est mise à jour, comme si le développeur avait configuré un élément <argLine />
dans l’élément <properties />
du projet. Toutefois, si l’élément <argLine />
est également renseigné dans l’élément <configuration />
du plugin, alors c’est cette valeur qui l’emporte (c’est un comportement standard de Maven).
Pour gérer ce cas, une sommaire capacité d’interpolation, le late property replacement, a été bricolée dans Surefire/Failsafe pour permettre à l’élément <argLine />
de la <configuration />
d’inclure toute propriété de projet ; en particulier la propriété argLine
modifiée par JaCoCo. Pour que la couverture du code par les tests soit correcte, il faut que l’élément <argLine />
de la <configuration />
de Surefire/Failsafe contienne @{argLine}
.
Dans l’exemple ci-dessous, la configuration de la quantité de mémoire allouée à la heap empêche prepare-agent
d’instrumenter l’exécution de Surefire.
<plugin> <artifactId>maven-surefire-plugin</artifactId> <configuration> <argLine>-Xmx512m</argLine> </configuration> </plugin>
À l’inverse, la configuration ci-dessous permet à Surefire d’inclure la propriété argLine
du projet dans la configuration d’argLine
au niveau du plugin.
<plugin> <artifactId>maven-surefire-plugin</artifactId> <configuration> <argLine>@{argLine} -Xmx512m</argLine> </configuration> </plugin>