Afficher la couverture du code par les tests dans SonarQube

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 fichier jacoco.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 et report-integration, même chose que prepare-agent et report 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.

Le Commissariat de police (Les Inconnus, 1990)

Et surtout qui nous décombe.

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 :

Plugins Maven impliqués dans la remontée de la couverture du code par les tests dans SonarQube

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 Surefire
  • prepare-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 de target/jacoco.exec et enregistre un rapport XML dans target/site/jacoco/jacoco.xml
  • report-integration traite les données de target/jacoco-it.exec et enregistre un rapport XML dans target/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>

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *