Blog

JUnit 5 adopte Java 8

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: <nom2> but was: <nom1>
	expected: <176> but was: <175>

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<DynamicTest> 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.
 

Jean-Michel Doudoux

Written by

CTO OXiane

  • bitshifter

    Bonjour et merci pour cette présentation passionnante.
    Si je peux signaler un problème de traduction : ‘assumption’ se traduit par ‘supposition’. Rien à voir avec l’élévation au ciel de la Sainte Vierge.

  • Oui effectivement, c’est corrigé et merci pour la correction