JUnit est un des frameworks les plus utilisés dans le monde Java, depuis très longtemps, pour l’écriture de tests automatisés. Chaque version majeure de JUnit est toujours un événement. La version 4, publiée en 2005, s’appuie par exemple sur les annotations pour faciliter l’écriture des tests.
12 ans après, la version 5 est publiée en septembre 2017. Au cours de ces 12 années, JUnit 4 a connu des évolutions mineures, essentiellement car JUnit est un framework mature. Pourtant, JUnit 5 est une réécriture intégrale notamment pour mettre en oeuvre une nouvelle architecture, utiliser de nouvelles fonctionnalités de Java 8 et proposer une révision du modèle d’extensions. JUnit 5 ne peux donc être utilisé qu’avec une version 8 minimum de Java.
L’architecture
JUnit 5 est composé de 3 sous-projets dont le but est de séparer les rôles :
- JUnit Jupiter : API pour écrire les tests
- JUnit Platform : API pour découvrir et exécuter les tests
- JUnit Vintage : pour assurer la compatibilité ascendante en fournissant un moteur d’exécution pour test JUnit 3 et 4
JUnit 5 est livré sous la forme de plusieurs jars qu’il faut ajouter au classpath en fonction des besoins.
L’écritures des tests
L’écriture de tests avec JUnit 5 est similaire à celle avec JUnit 4 : définir une classe utilisant des annotations. Certaines de ces annotations ont été renommées. Toutes les classes et annotations de JUnit 5 sont dans des packages différents de ceux de JUnit 4 : org.junit.jupiter.api.
Les classes et méthodes de tests n’ont plus l’obligation d’être public (attention cependant à certaines utilisations dans les suites de tests).
L’annotation @DisplayName permet de donner un libellé qui sera affiché lors de la restitution des résultats : elle peut être utilisée sur une classe ou une méthode.
@DisplayName("Description de la classe de test JUnit 5")
public class MonPremierTest {
@Test
@DisplayName("Description du cas de test")
void unTest() {
// ...
}
}
La nouvelle annotation @Disabled permet de désactiver un test ou l’ensemble des tests d’une classe. Il est optionnellement possible de lui fournir en paramètre une description de la raison de la désactivation. C’est pratique d’autant que cette situation ne devrait être que temporaire.
Quatre annotations permettent toujours de gérer le cycle de vie des tests mais elles ont été renommées : @BeforeAll, @BeforeEach, @AfterEach et @AfterAll.
Les assertions
De nombreuses assertions sont similaires à celles proposées par JUnit 4, comme par exemple assertTrue(), asserEquals(), assertNull(), assertSame() et leur pendant négatif.
Une grande différence se situe dans l’ordre des paramètres : le message qui sera affiché si l’assertion échoue est situé en premier en JUnit 4 mais il est en dernier en JUnit 5. Le changement de la position du message dans les paramètres imposera donc un peu de refactoring lors de la migration des tests écrits en JUnit 4 vers JUnit 5.
Ce message peut être fourni sous la forme d’une chaîne de caractères ou d’un Supplier<String>. Si la construction du message peut être coûteuse, la surcharge qui attend le message sous la forme d’un Supplier<String> est préférable car son évaluation est lazy.
Particulièrement intéressante, la nouvelle assertion assertAll() permet d’exécuter plusieurs assertions qui seront toutes évaluées pour déterminer le résultat final : échec si au moins une des assertions incluses échoue.
@Test
void testPersonne() {
Personne personne = new Personne("nom1", "prenom1", 175);
Assertions.assertAll("La personne est incorrecte",
() -> Assertions.assertEquals("nom2", personne.getNom()),
() -> Assertions.assertEquals("prenom1", personne.getPrenom()),
() -> Assertions.assertEquals(176, personne.getTaille())
);
}
org.opentest4j.MultipleFailuresError: La personne est incorrecte (2 failures)
expected: but was:
expected: but was:
La manière de vérifier qu’une exception est levée durant l’exécution d’un test change : l’annotation @Test n’a plus d’attribut et il faut utiliser l’assertion assertThrows().
@Test
void should_traiter_throws_exception() {
IllegalArgumentException exception = Assertions.assertThrows(IllegalArgumentException.class, this::traiter);
Assertions.assertEquals("Argument invalide", exception.getMessage());
}
private void traiter() {
throw new IllegalArgumentException("Argument invalide");
}
Les suppositions
Comme avec JUnit 4, les suppositions permettent de rendre conditionnel tout ou partie d’un cas de test en interrompant son exécution sans le faire échouer si une condition est satisfaite.
Les suppositions assumeTrue() et assumeFalse() proposent plusieurs surcharges pour conditionner l’exécution de la suite du test, si l’évaluation est respectivement vrai ou fausse.
@Test
void testAvecSupposition() {
Assumptions.assumeTrue("integration".equals(System.getenv("ENV")));
Assertions.assertTrue(new File("C:/test").exists(), "Répertoire inexistant");
}
La supposition assumeThat() fonctionne différemment : elle n’exécute le traitement fourni en paramètre sous la forme d’une interface fonctionnelle de type Executable que si l’évaluation de la condition est vrai.
@Test
void testAvecSupposition() {
Assumptions.assumingThat("integration".equals(System.getenv("ENV")), () -> {
Assertions.assertTrue(new File("C:/test").exists(), "Répertoire inexistant");
});
}
Les tags
L’annotation @Tag permet de tagguer un test pour permettre de filtrer ceux à exécuter. Le libellé du tag possède quelques contraintes notamment de ne pas pouvoir contenir d’espaces ou certains caractères notamment , ( ) & | et !
@Tag("lot1")
class MonTest {
@Test
@Tag("fonctionnel")
void testCalculer() {
// ...
}
}
Les tests imbriqués
Les tests imbriqués (nested tests) permettent de regrouper des cas de tests dans une classe interne non statique annotée avec @Nested.
Les cas de tests de la classe interne sont exécutés en même temps que ceux de la classe englobante.
Les tests répétés
Les tests répétés permettent d’exécuter plusieurs fois le même cas de test. Il suffit d’utiliser l’annotation @RepeatedTest sur la méthode de test qui ne doit pas être static ni private et doit obligatoirement retourner void.
L’annotation @RepeatedTest attend obligatoirement comme valeur le nombre de répétitions à réaliser. L’attribut name permet de préciser un libellé spécifique à chaque exécution.
@RepeatedTest(5)
void testRepete() {
// ...
}
La méthode de test peut se faire injecter en paramètre un objet de Type RepetitionInfo qui permet d’obtenir des informations sur l’exécution notamment l’itération courante et le nombre total de répétitions.
@DisplayName("Test repete")
@RepeatedTest(value = 3, name = "{displayName} cas {currentRepetition} / {totalRepetitions}")
void repeatedTestWithRepetitionInfo(RepetitionInfo repetitionInfo) {
Assertions.assertEquals(3, repetitionInfo.getTotalRepetitions());
}
Les tests paramétrés
Les tests paramétrés permettent d’exécuter plusieurs fois le même cas de test avec des valeurs différentes. Ces différentes valeurs peuvent être fournies par différentes sources qu’il faut configurer grâce à plusieurs annotations :
- @ValueSource : tableau de valeurs numériques primitives ou chaînes de caractères
- @EnumSource : une énumération
- @MethodSource : les valeurs sont fournies par l’invocation d’une méthode
- @CsvSource : une chaîne de caractères dont chaque argument est séparé par une virgule
- @CsvSourceFile : les valeurs sont fournies par un ou plusieurs fichiers CSV
- @ArgumentSource : les valeurs sont fournies par l’invocation d’une méthode d’une instance de type ArgumentProvider
@ParameterizedTest
@ValueSource(ints = { 1, 2, 3, 4 })
void testParametre(int valeur) {
assertEquals(valeur + 1, valeur + 1);
}
Le libellé de chaque exécution peut être personnalisé grâce à l’attribut name de l’annotation @ParameterizedTest.
@DisplayName("Incrementation")
@ParameterizedTest(name = "{index} : incrementation de {0} ")
@ValueSource(ints = { 1, 2, 3, 4 })
void testParametre(int valeur) {
assertEquals(valeur + 1, valeur + 1);
}
Certaines valeurs peuvent être converties automatiquement sinon il faut écrire une classe qui implémente l’interface ArgumentConverter et demander son utilisation avec l’annotation @ConvertWith.
Les tests dynamiques
Une méthode annotée avec @TestFactory permet d’indiquer une fabrique renvoyant des cas de tests dynamiques. La méthode peut retourner :
- Stream<DynamicTest>
- Collection<DynamicTest>
- Iterable<DynamicTest>
- Iterator<DynamicTest>
Chaque cas de tests est une instance de type DynamicTest dont la fabrique statique dynamicTest() facilite la création. Elle attend en paramètre le nom du test et le code sous la forme d’une interface fonctionnelle de type Executable.
@TestFactory
Stream testIncrementer() {
return Stream.of(1, 2, 3).map(val -> DynamicTest.dynamicTest("test dynamique " + val, () -> {
int attendu = val + 1;
Assertions.assertTrue(attendu == val + 1);
}));
}
Les tests dans les interfaces
Les annotations @Test, @RepeatedTest, @ParameterizedTest, @TestFactory, @TestTemplate, @BeforeEach et @AfterEach peuvent être utilisées sur des méthodes par défaut d’une interface.
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
public interface TestsDansInterface {
@Test
public default void testCalculer() {
Assertions.assertTrue(true);
}
@Test
public default void testTraiter() {
Assertions.assertTrue(false);
}
}
Pour exécuter les tests définis dans l’interface, il est nécessaire de créer une classe qui implémente l’interface.
public class MesTests implements TestsDansInterface {
}
Les suites de tests
JUnit 5 propose plusieurs annotations pour définir une suite de tests en sélectionnant les packages, les classes et les méthodes à inclure mais aussi filtrer certains de ces éléments :
- @ExcludeClassNamePatterns : une ou plusieurs expressions régulières que le nom pleinement qualifié des classes à exclure de la suite doit respecter
- @ExcludePackages : des packages dont les tests doivent être ignorés
- @ExcludeTags : des tags dont les tests doivent être ignorés
- @IncludeClassNamePatterns : une ou plusieurs expressions régulières que le nom pleinement qualifié des classes à inclure dans la suite doit respecter
- @IncludePackages : des packages et leursb sous−packages dont les tests doivent être utilisés
- @IncludeTags : des tags dont les tests doivent être utilisés
- @SelectClasses : un ensemble de classes à utiliser
- @SelectPackages : des packages dont les tests doivent être utilisés
@RunWith(JUnitPlatform.class)
@SelectPackages({"com.oxiane.app.service","com.oxiane.app.util"})
public class MaSuiteDeTests {
// ...
}
La compatibilité
JUnit 5 propose un moteur d’exécution des tests écrits en JUnit 3 et 4 dans le module Vintage.
Pour migrer des tests existants vers JUnit 5, plusieurs points sont à prendre en compte notamment :
- Le nom des packages a changé : org.junit -> org.junit.jupiter.api
- Les messages des assertions ne sont plus en première position mais en dernière avec JUnit 5
- Certaines annotations sont renommées notamment @BeforeAll, @AfterAll, @BeforeEach, @AfterEach, @Disabled, @Tag, @ExtendWith
- Certaines suppositions ne sont plus disponibles
- Hamcrest n’est plus une dépendance de JUnit 5, il faut donc l’ajouter au classpath pour pouvoir l’utiliser
- Les tests des exceptions se font avec l’assertion assertThrows
La migration nécessite donc un ensemble d’actions à réaliser.
Conclusion
JUnit 5 apporte des fonctionnalités intéressantes :
- une nouvelle architecture plus modulaire pour séparer l’API, le moteur d’exécution et les fonctionnalités d’exécution et d’intégration
- le support de Java 8 (les expressions Lambdas, les annotations répétées, …)
- le mécanisme d’extension
- de nouveaux types de tests : tests imbriqués, tests dynamiques, tests paramétrés (avec différentes sources)
- comme il permet une compatibilité d’exécution avec JUnit 4, la migration vers la version 5 peut se faire en douceur
Cet article n’est qu’une introduction à ces fonctionnalités qui sont détaillées sur le site de JUnit 5
L’utilisation de JUnit 5 requiert d’utiliser une version 8 minimum de Java : si c’est le cas, sa mise en oeuvre est un must.