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ètreexpect
de l’annotation@Test
dans JUnit 4) :
Throwable exception = assertThrows(IllegalArgumentException.class, () -> {
throw new IllegalArgumentException("a message");
});
assertEquals("a message", exception.getMessage());
assertTimeout
ouassertTimeoutPreemptively
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) |
static void |
assertArrayEquals(byte[] expected, byte[] actual, String message) |
static void |
assertArrayEquals(byte[] expected, byte[] actual, Supplier<String> messageSupplier) |
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écutionTestReporterInfo
: possibilité d’ajouter des messages dans la log du rapport des testsRepetitionInfo
: 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 :
|
|
|
|
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 parorg.junit.jupiter.api.Assertions
- Remplacer la classe
Assume
parorg.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’interfaceExtension
.
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
- http://junit.org/junit5/
- http://junit.org/junit5/docs/current/user-guide/
- https://github.com/junit-team/junit5/milestones
- https://github.com/junit-team/junit5-samples/blob/master/junit5-mockito-extension/src/main/java/com/
- https://www.indiegogo.com/projects/junit-lambda#/
- example/mockito/MockitoExtension.java
- https://blog.jetbrains.com/idea/2016/08/using-junit-5-in-intellij-idea/
- https://wiki.eclipse.org/JDT_UI/JUnit_5
- https://issues.apache.org/jira/browse/SUREFIRE-1206
- http://jmockit.org/changes.html#1.26
- https://blog.xebia.fr/2016/03/29/junit-5-rencontre-lambda/
- https://spring.io/blog/2016/12/06/springone-platform-2016-replay-testing-with-spring-framework-4-3-junit-5-and-beyond
- https://fr.slideshare.net/SpringCentral/testing-with-spring-43-junit-5-and-beyond
- https://github.com/sbrannen/spring-test-junit5