JUnit 5

Il y a plus d'une quinzaine d’années, le framework Java de tests unitaires JUnit avait connu une forte évolution avec l’introduction de JUnit 4. Au moment où j’écris le présent article, une nouvelle ère approche avec JUnit 5, dont la première release officielle est prévue pour le troisième trimestre 2017. Le développement de cette refonte complète a pu se faire grâce à un financement participatif. L’équipe JUnit a livré en début avril 2017 une quatrième version milestone de JUnit 5, comprenant une documentation complète.

En quoi consiste la future refonte de JUnit et quels en seront les impacts ?

Dans cet article, je présenterai l’architecture de JUnit 5, ainsi que ses évolutions les plus remarquables. Je présenterai ensuite un bref état des lieux sur le support de JUnit 5 par les outils de développement les plus populaires et je terminerai sur l’aspect migration des tests de JUnit 4 vers JUnit 5.

Les besoins

Les années d’expérience de JUnit 4 ont fait apparaître des rigidités limitant l’évolution du framework :

  • Les possibilités de Java 8 (comme les « lambdas ») ne sont pas exploitées par JUnit 4.
  • Le nommage de certaines annotations n’est pas très clair (exemple avec @Before et @BeforeClass).
  • Les possibilités d’extension ou de customisation de JUnit 4 sont assez réduites (on ne peut utiliser qu’un seul Runner pour une classe de test) et souffrent d’un problème de cohérence (on peut utiliser une Rule en plus du Runner).
  • Le principe des tests paramétrés est rudimentaire et assez lourd à mettre en place.
  • L’architecture de JUnit 3 et 4 ne présente pas d’API dédiée à l’intégration dans les IDE. Par conséquent, l’implémentation des IDE accède directement à des classes internes de JUnit, ce qui pose des problèmes de couplages nuisibles à l’évolution de JUnit.

La nouvelle architecture

Jusqu’à présent, JUnit se présentait sous forme d’une bibliothèque monolithique. JUnit 5 sera divisé en 3 modules :

  • JUnit Platform : contient tout l’aspect « moteur » de JUnit. On utilise ce module lorsqu’on veut faire exécuter les tests.
  • JUnit Jupiter : combine une API et des mécanismes d’extension. Ce sont les éléments de ce module qu’on utilise dans les tests unitaires.
  • JUnit Vintage : fournit un moyen d’exécuter les tests unitaires existants initialement écrits pour JUnit 3 et 4.

Ce découpage doit favoriser l’intégration de JUnit dans les outils (IDE, build, intégration continue, etc.) et surtout la customisation de JUnit, tout en permettant l’exécution des tests unitaires existants écrits avec JUnit 3 et 4.

Le code source JUnit 5 n’apporte aucune modification à celui de JUnit 4, car tout le code source de JUnit 5 utilise des chemins de packages différents de ceux utilisés dans les anciennes versions.

Remarque importante : mettant en jeu l’utilisation des lambdas, JUnit 5 nécessite au minimum Java 8.

Voici le diagramme UML issu de la documentation de JUnit 5, montrant les dépendances entre les 3 modules et les jars constituant ces modules :

Il ne faut pas se laisser impressionner par la complexité apparente car dans le contexte d’un projet, les modules s’utilisent de la façon suivante :

Nouveau jeu d’annotations

L’annotation principale @Test n’admet désormais plus d’arguments (les exceptions attendues et les gestions de timeout seront prises en charge par des assertions).

Les annotations @Before, @BeforeClass, @After et @AfterClass sont respectivement remplacées par @BeforeEach, @BeforeAll, @AfterEach et @AfterAll (dont les noms ont une signification plus claire par rapport au cycle de vie des tests unitaires).

De même @Ignore est remplacée par @Disabled (met mieux en évidence que cette annotation entraîne une désactivation des tests).

De nouvelles annotations sont introduites :

  • @RepeatedTest pour mettre en place des templates de tests,
  • @Tag pour pouvoir filtrer les tests à exécuter,
  • @DisplayName pour personnaliser l’affichage des intitulés de tests durant leur exécution (on n’aura plus besoin de fournir des noms de méthodes interminables et on pourra faire apparaître de vrais intitulés de scénarios dans les logs d’exécution des tests),
  • @Nested pour avoir une notion de classe de tests imbriquée (utile pour mieux structurer les logs d’exécution des tests),
  • @TestFactory pour avoir une fabrique de cas de tests (il s’agit là d’un nouveau concept de tests dits « dynamiques » : voir ci-après),
  • @ExtendWith permet d’enregistrer des extensions personnalisées de JUnit (qui se branchent sur les étapes du cycle de vie des tests),
  • @ParameterizedTest introduit une nouvelle approche pour les tests paramétrés. Elle s’utilise conjointement avec un sous-jeu d’annotations associées : @ValueSource, @EnumSource, @MethodSource, @CsvSource, @CsvFileSource et @ArgumentsSource (Voir ci-après pour l’assouplissement apporté au niveau des tests paramétrés).

Notons au passage que les méthodes de tests n’auront plus besoin d’être publiques et que les méthodes d’interfaces par défaut peuvent être annotées @Test.

Evolution des assertions

La classe Assert de JUnit 4 est remplacée par une classe Assertions qui reprend l’ensemble des méthodes existantes de JUnit 4 auxquelles sont ajoutées les nouvelles méthodes, dont par exemple :

  • assertAll qui regroupe en argument des lambdas exécutant d’autres assertions. Les assertions sont toutes exécutées et le moteur JUnit indique celles qui ont échoué :
assertAll("address", 
() -> assertEquals("John", address.getFirstName()), 
() -> assertEquals("User", address.getLastName()) );
  • assertThrows pour indiquer qu’on s’attend à voir survenir une exception et permet en même temps de récupérer l’exception émise (ce que ne permettait pas de faire l’ancien paramètre expect de l’annotation @Test dans JUnit 4) :
Throwable exception = assertThrows(IllegalArgumentException.class, () -> {
 throw new IllegalArgumentException("a message");
 }); 
assertEquals("a message", exception.getMessage());
  • assertTimeout ou assertTimeoutPreemptively selon que l’on souhaite attendre ou non la fin d’exécution d’un traitement testé par rapport à une contrainte de temps :
// The following assertion fails with an error message similar to:
// execution timed out after 10 ms
assertTimeoutPreemptively(ofMillis(10), () -> { 
// Simulate task that takes more than 10 ms.
Thread.sleep(100); });
  • Pour les messages optionnels d’échec associés aux assertions, ils ne seront plus en premier argument des méthodes <i>assertXXX</i> mais en dernier argument. De plus, on pourra rendre leur évaluation « paresseuse » grâce à des lambdas :
assertTrue(2 > 1, () -> "Assertion messages can be lazily evaluated -- "
+ "to avoid constructing complex messages unnecessarily.");

Voici un exemple tiré de la Javadoc de JUnit 5 qui résume les surcharges possibles pour la plupart des méthodes d’assertions :

static void

assertArrayEquals(byte[] expected, byte[] actual)
Asserts that expected and actual byte arrays are equal.

static void

assertArrayEquals(byte[] expected, byte[] actual, String message)
Asserts that expected and actual byte arrays are equal.

static void

assertArrayEquals(byte[] expected, byte[] actual, Supplier<String> messageSupplier)
Asserts that expected and actual byte arrays are equal.

Hamcrest ne sera plus inclus dans JUnit. Libre aux développeurs de choisir leur framework d’assertions (Hamcrest, AssertJ, …)

Evolution des « suppositions »

Le principe existant de JUnit 4 est repris à travers la classe Assumptions qui remplace Assume. Il reprend un sous-ensemble des méthodes de JUnit 4 mais ajoute la possibilité d’utiliser les lambdas.

(Pour rappel, les suppositions sont à utiliser avec parcimonie car leur principe peut contredire certains aspects de la notion de test « unitaire ».)

Exemple d’une assertion s’exécutant sous certaines conditions :

@Test
void testInAllEnvironments() { 
assumingThat("CI".equals(System.getenv("ENV")),
() -> { 
// perform these assertions only on the CI server
assertEquals(2, 2);
}); 
// perform these assertions in all environments
assertEquals("a string","a string"); }

Injection de dépendance sur les constructeurs et méthodes des classes de tests

Avec JUnit 4, les constructeurs et méthodes des classes de tests n’étaient pas censés avoir de paramètres. Avec JUnit 5, il devient possible de transmettre des paramètres à ces constructeurs ou méthodes, grâce à l’enregistrement dans le moteur d’instances de l’interface ParameterResolver.

Par défaut, JUnit 5 propose des accès au contexte d’exécution des tests via les interfaces suivantes qui peuvent être passées en argument des méthodes de tests ou de gestion de ressources :

  • TestInfo : informations sur le test en cours d’exécution
  • TestReporterInfo : possibilité d’ajouter des messages dans la log du rapport des tests
  • RepetitionInfo : numéro de répétition en cours sur nombre total de répétitions pour les tests « répétés » (pris en charges par l’annotation @RepeatedTest).

Par implémentation de l’interface ParameterResolver, les auteurs de JUnit ont pu écrire très rapidement, à titre d’exemple, un adaptateur Mockito, dont voici un exemple d’utilisation :

@BeforeEach
void init(@Mock Person person) {
when(person.getName()).thenReturn("a name"); } 
@Test
void simpleTestWithInjectedMock(@Mock Person person) {
assertEquals("a name", person.getName()); }

Possibilités d’abstraction : tests paramétrés ou « dynamiques »

Avec JUnit 4, la gestion des tests paramétrés s’effectuait à l’échelle de la classe de test, ce qui était très contraignant car si on ajoute des tests non paramétrés dans une classe de tests paramétrée, on se retrouve avec une exécution répétée des tests non paramétrés.

L’approche de JUnit 5 se fait par annotation sur les méthodes de tests concernées, ce qui permet de rassembler dans une même classe des tests paramétrés qui n’impactent plus les autres tests.

En plus d’une annotation @RepeatedTest permettant de simplement définir un test qui s’exécute plusieurs fois de suite, JUnit 5 apporte un nouveau moyen de répéter une exécution de test avec un jeu de données changeant. Cette fonctionnalité est apportée par l’annotation @ParameterizedTest. Cette annotation s’utilise conjointement avec un ensemble d’annotations permettant de définir le jeu de données pour le test paramétré. Voici un exemple simpliste avec l’annotation conjointe @ValueSource qui fournit le jeu de données sous forme d’un tableau de chaînes :

@ParameterizedTest @ValueSource(strings = { "Hello", "World" }) 
void testWithStringParameter(String argument) { assertNotNull(argument); 
}

Si on exécute ce test avec la nouvelle console de JUnit 5 on obtient la sortie détaillée suivante :

testWithStringParameter(String) ✔ 
├─ [1] Hello ✔ 
└─ [2] World ✔

Les sources d’arguments pour les tests paramétrés peuvent être variées : tableau de valeurs, items d’une énumération, « stream » fourni par une autre méthode, fichier CSV ou arguments injectés directement en paramètre de la méthode de tests.

Pour aller encore plus loin et pour des cas très particuliers, JUnit 5 introduit un nouveau type de tests dits « dynamiques ». Avec les tests paramétrés, le jeu de données est « statique ». Avec les tests dynamiques, le jeu de données est généré par des « fabriques », c’est-à-dire des méthodes annotées @TestFactory qui retournent une collection ou un flux de données.

Il y a une précaution à prendre avec les tests dynamiques : leur cycle de vie est différent des autres tests. En particulier les méthodes annotées @BeforeEach et @AfterEach ne sont pas appelées. Et si des tests non dynamiques sont exécutés dans la même classe de test, on doit être conscient que certaines ressources pourraient ne pas avoir été libérées ou initialisées correctement.

Le modèle d’extension

Le dernier apport important de JUnit 5 est la remise à plat de la possibilité d’ajouter des comportements au moteur d’exécution des tests. Avec JUnit 4, on disposait de trois « points d’extension » de JUnit (plus ou moins en compétition entre eux) via l’interface Runner et les annotations @Rule et @ClassRule. JUnit 5 fournit à la place un modèle unifié, fondé sur une seule interface Extension associée à l’usage d’une annotation @ExtendWith.

Les implémentations de l’interface Extension sont enregistrées par le moteur JUnit et l’annotation @ExtendWith permet d’utiliser les extensions aussi bien au niveau d’une classe de test que d’une méthode de test :

@ExtendWith(MockitoExtension.class)
class MockTests {
  // ...
}
@ExtendWith(MockitoExtension.class)
@Test
void mockTest() {
  // ...
}
On peut désormais cumuler les extensions :
@ExtendWith({ FooExtension.class, BarExtension.class })
  
class MyTestsV1 {
  // ...
}
@ExtendWith(FooExtension.class)
@ExtendWith(BarExtension.class)
class MyTestsV2 {
  // ...
}
Pour l’implémentation des extensions, l’interface `Extension` n’est qu’une interface de marquage possédant 7 interfaces descendantes qui impliquent chacune une partie du cycle de vie des tests unitaires :
  • BeforeAllCallback
  • BeforeEachCallback
  • BeforeTestExecutionCallback
  • TestExecutionExceptionHandler
  • AfterTestExecutionCallback
  • AfterEachCallback
  • AfterAllCallback

Ces interfaces fonctionnelles, aux noms évidents, définissent chacune un traitement qui viendra s’insérer dans le cycle de vie des tests unitaires, conformément au schéma suivant fourni par la documentation de JUnit 5 :

Dans le schéma précédent, la zone sur fond gris correspond aux traitements exécutés pour chaque méthode de tests de la classe et la zone sur fond blanc n’est exécutée qu’une fois pour chaque classe de tests.

Toutes les méthodes des sous-interfaces de Extension reçoivent en argument une classe de contexte qui peut contenir une collection d’informations. Ceci permet de gérer un « état » entre les différents traitements.

De cette manière le modèle offre une bien plus grande souplesse dans les mécanismes d’extension.

Le mécanisme de chargement des extensions, en plus de l’usage de l’annotation @ExtendWith, permet aussi de charger des extensions de façon globale et déclarative via le mécanisme de la classe standard du Java : java.util.ServiceLoader (recherche de classe automatique par ajout du nom complet de la classe d’extension dans un fichier org.junit.jupiter.api.extension.Extension du répertoire /META-INF/services).

Calendrier et prise en charge par les IDE, les outils de build et les frameworks

La prochaine version milestone de JUnit 5 (prévue pour fin juin 2017) prendra en charge des problématiques liées à Java 9.

La première release candidate de JUnit 5 est prévue pour fin juillet 2017.

L’IDE IntelliJ IDEA indique déjà sur son site Web supporter JUnit 5.

Un projet de support de JUnit 5 est prévu pour la prochaine version d’Eclipse (Oxygen) qui verra le jour fin juin 2017.

La prise en charge native de JUnit 5 par le Maven Surefire Plugin est actuellement dans le backlog. En attendant, ce n’est pas très handicapant car il suffit de compléter par deux dépendances la configuration du Maven Surefire Plugin pour pouvoir exécuter des tests JUnit 5 (et pour pouvoir compiler le code des tests unitaires on utilisera l’API du module JUnit Jupiter) :

<build>
  <plugins>

    (...)

    <plugin>
      <artifactId>maven-surefire-plugin</artifactId>
      <version>2.19</version>
      <dependencies>
        <dependency>
          <groupId>org.junit.platform</groupId>
          <artifactId>junit-platform-surefire-provider</artifactId>
          <version>1.0.0-M4</version>
        </dependency>
        <dependency>
          <groupId>org.junit.jupiter</groupId>
          <artifactId>junit-jupiter-engine</artifactId>
          <version>5.0.0-M4</version>
        </dependency>
      </dependencies>
    </plugin>
  </plugins>
</build>

(...)

<dependencies>

  (...)

  <dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter-api</artifactId>
    <version>5.0.0-M4</version>
    <scope>test</scope>
  </dependency>
</dependencies>

Pour l’utilisation de JUnit 5 via Gradle, un plugin a été écrit par les auteurs de JUnit, assez simple à activer (mais dont l’organisation des paramètres de configuration est encore susceptible d’être modifiée à l’heure où j’écris le présent article) :

buildscript {
  repositories {
    mavenCentral()
    // The following is only necessary if you want to use SNAPSHOT releases.
    // maven { url 'https://oss.sonatype.org/content/repositories/snapshots' }
  }
  dependencies {
    classpath 'org.junit.platform:junit-platform-gradle-plugin:1.0.0-M4'
  }
}

apply plugin: 'org.junit.platform.gradle.plugin'

A défaut de disposer du support de JUnit 5 dans un outil donné, une application en ligne de commande est fournie avec JUnit 5 pour pouvoir lancer « manuellement » les tests unitaires. Le point d’entrée est la classe ConsoleLauncher du jar junit-platform-console-standalone de JUnit 5. L’outil génère une log plus complète et structurée et offre un jeu d’options de paramétrage. Il permet aussi bien de lancer les tests via JUnit Jupiter que via JUnit Vintage, ce qui veut dire qu’il permet de lancer aussi bien des tests JUnit 3, 4 ou 5.

Il a aussi été prévu la possibilité d’exécuter les tests JUnit 5 « au travers de JUnit 4 » grâce à un Runner spécial de JUnit 4 (utilisation dans ce cas de l’annotation @RunWith(JUnitPlatform.class)).

Du côté des framework de mocks, des discussions sont en cours pour le framework Mockito (https://github.com/mockito/mockito/issues/390). Le framework JMockit a déjà ajouté le support de JUnit 5 depuis juillet 2016 (http://jmockit.org/changes.html#1.26) et continue de faire évoluer ce support en fonction des nouveaux milestones de JUnit 5.

Concernant Spring Test, la version 5 de Spring (prévue pour juin 2017) va apporter son support via le mécanisme d’extension (@ExtendWith(SpringExtension.class)) ainsi qu’une annotation spéciale @SpringJUnitConfig (grâce à l’héritage entre annotations) qui regroupe @ExtendWith(SpringExtension.class) et @ContextConfiguration. Il est même possible d’utiliser ce support avec Spring 4.3 (via des adaptations).

Quid de la compatibilité avec les outils d’intégration continue ? Même question pour les outils de couverture de code ? Pour Jenkins, un ticket a été saisi en novembre 2016 mais il n’a pas encore reçu de réponse (https://issues.jenkins-ci.org/browse/JENKINS-39931). Pour en avoir le cœur net j’ai testé moi-même la migration des tests unitaires en JUnit 5 sur une petite application prise en charge par un job Jenkins. J’ai pu constater que l’essentiel est déjà assuré car les rapports de tests produits sont cohérents. En effet, l’adaptateur Maven que j’ai évoqué plus haut permet de produire exactement les mêmes logs qu’avec JUnit 4. L’annotation @Disabled est traitée comme l’ancienne annotation @Ignore et les tests en lien avec une annotation @Nested sont bien visibles dans le rapport. Le seul problème que l’on rencontre est la non prise en charge des nouvelles annotations comme @Nested ou @DisplayName (qui ne concernent en fait que la « présentation » des résultats de tests).

Concernant les outils de couverture de code, au cours de mon test sur Jenkins, j’ai pu observer que le plugin SonarQube a effectué son travail sans incidents et en fournissant des résultats identiques à ce que j’avais avec JUnit 4.

Problématique de l’existant et démarche possible de migration

Comme on a pu le voir précédemment, le code de JUnit 5 a été implémenté de manière à ce qu’il n’ait aucune dépendance sur le code de JUnit 4, ce qui permet une utilisation telle quelle des tests JUnit 3 ou 4 via le module JUnit Vintage de JUnit 5.

Il est donc dès à présent possible de faire cohabiter dans un projet des tests unitaires JUnit 4 avec des tests JUnit 5. Ceci permettra aux développeurs de prendre tout leur temps pour migrer le code existant des tests unitaires.

Pour migrer les tests existants, on peut procéder par un refactoring en 7 points :

  • Changer le chemin de package des annotations qui sont toutes regroupées dans le package org.junit.jupiter.api
  • Remplacer la classe Assert de JUnit 3 ou 4 par org.junit.jupiter.api.Assertions
  • Remplacer la classe Assume par org.junit.jupiter.Assumptions
  • Remplacer les annotations @Before, @BeforeClass, @After et @AfterClass respectivement par @BeforeEach, @BeforeAll, @AfterEach et @AfterAll
  • Remplacer l’annotation @Ignored par @Disabled
  • Remplacer l’annotation @Category par @Tag
  • Remplacer les annotations @RunWith, @Rule et @ClassRule par l’usage de l’annotation @ExtendWith et les implémentations correspondantes de l’interface Extension.

On constatera que dans cette migration c’est la dernière étape qui est potentiellement délicate car certains projets peuvent avoir implémenté leurs propres runners ou rules. Des adaptateurs supplémentaires ont été fournis par l’équipe JUnit pour supporter temporairement certaines rules.

Conclusion

Comme nous pouvons le constater, le projet de refonte de JUnit est plus que bien avancé. L’évolution entre JUnit 4 et 5 est plus forte que le passage de JUnit 3 à 4, car il y a refonte à la fois de l’architecture et du jeu d’annotations. Des outils de build et d’analyse ainsi que des frameworks de mocks se sont déjà intéressés à l’intégration de JUnit 5. Néanmoins, il est difficile de dire s’il y a une rétrocompatibilité à proprement parler de JUnit 5 avec les anciennes versions de JUnit, mais un « pont » a été prévu pour éviter d’avoir à migrer tout de suite le code des tests unitaires existants dans les projets.

La dernière étape de développement de JUnit 5 porte sur la compatibilité avec Java 9. Souhaitons qu’elle se déroule sans encombre (https://github.com/junit-team/junit5/milestone/8).

Ressources