Activez le simulateur C'est parti !
Actualités

JUnit : Il serait temps de passer la 5ème

Juil 22, 2022 · 16 minutes read

Saviez-vous que JUnit 5 a déjà plus de 5 ans ? Pourtant, un grand nombre de projets Java sont encore testés avec JUnit 4, qui est sorti… il y a une bonne quinzaine d’années ! Énormément de choses ont évolué depuis 2006, Java a pris plus de 9 versions ! Ne serait-il donc pas temps de remettre nos tests au goût du jour ? L’équipe de JUnit a profité de cette 5ème version pour restructurer complètement le framework. De nombreuses features ont été ajoutées ou retravaillées afin de s’adapter aux nouveaux paradigmes de l’écosystème Java.

Une plus grande modularité

Si vous êtes familiers de JUnit 4, vous connaissez ce bon vieux junit.jar à rajouter dans votre projet. Dans sa version 5, JUnit est composé de 3 modules: JUnit Jupiter, JUnit Vintage et JUnit Platform.

JUnit Jupiter est le moteur de test de JUnit 5. Pourquoi Jupiter ? Car cette 5ème version a été baptisée selon la 5ème planète du système solaire. En ajoutant org.junit.jupiter:junit-jupiter dans votre classpath de test, vous allez pouvoir profiter du nouveau modèle de programmation de test. On le verra tout au long de cet article, le mot d’ordre de cette version majeure est l’extensibilité. JUnit Jupiter offre une multitude de moyens d’étendre et de customiser le comportement du moteur de test selon vos besoins grâce au nouveau mécanisme d’extensions.

La rétrocompatibilité n’a pas été oubliée dans cette nouvelle version. Grâce à JUnit Vintage, pas besoin de migrer toute son code d’un coup de JUnit 4 à JUnit5. Le jar org.junit.vintage:junit-vintage-engine vous assurera la cohabitation des tests écrits dans l’ancien et le nouveau paradigme afin d’opérer une migration itérative de vos tests. Il vous permettra aussi de continuer à utiliser des frameworks de tests basés sur JUnit qui n’auraient pas encore migrés vers JUnit Jupiter.

Comme son nom l’indique, JUnit Platform représente l’infrastructure du framework. Il contient l’exécuteur des tests sur la console i.e. ConsoleLauncher, ainsi que les fondations permettant le lancement de framework de tests dans la JVM. De ce fait, il offre une API de TestEngine permettant à n’importe quel développeur de développer son propre moteur de tests JUnit 5. C’est bien-sûr sur ces APIs que JUnit Jupiter s’appuie. JUnit Platform est intégré par défaut dans bon nombre d’IDE et est supporté par les outils de build tels que Maven, Gradle et Ant. Il n’est pas nécessaire de rajouter son jar org.junit.platform:junit-platform-launcher dans vos dépendances, sauf si vous souhaitez utiliser une version plus récente de JUnit que celle intégrée dans votre IDE. A noter que JUnit Platform ne suit pas le même versioning que JUnit Jupiter et JUnit Vintage, étant actuellement en 1.7.x contre 5.7.x pour le reste des modules.

Il faut savoir toutefois que tous ces modules de JUnit 5 requièrent un runtime en Java 8 minimum.

Une gestion du cycle de vie des tests bien plus claire

L’équipe de JUnit a souhaité offrir une version plus souple, moins verbeuse et plus simple d’utilisation de son framework. Dans cette philosophie, il n’est plus nécessaire de déclarer ses classes et ses méthodes de test avec le scope public, la visibilité package suffit (sans modifier).

Les annotations de cycle de vie ont été complètement repensées pour plus de clarté. Vous pouvez toujours initialiser ou détruire le contexte de vos tests avant et après chacun d’entre eux et/ou autour de l’exécution de tous les tests de la classe. Cependant, exit les @Before, @After, @BeforeClass et @AfterClass qui étaient orientées structure de code, pour accueillir maintenant @BeforeEach, @AfterEach et @BeforeAll et @AfterAll (…tests) beaucoup plus sémantiques. Les méthodes annotées par @BeforeAll et @AfterAll devront toujours être statiques sauf si vous changez la politique d’instanciation des tests en la définissant par classe.

Par défaut, JUnit 5 crée une instance de votre classe de test pour chaque méthode de tests décrites dans celle-ci. La raison est d’assurer l’isolation totale de vos tests en évitant les effets de bord potentiels sur vos données de tests qui seraient provoqués par les autres tests de votre classe. Ce comportement est hérité des précédentes versions de JUnit. Cependant, il est possible de demander au framework de créer une seule instance de votre classe pour toutes les méthodes qui la compose au moyen de @TestInstance(Lifecycle.PER_CLASS) à placer au-dessus de la définition de votre classe.

Pour spécifier à JUnit qu’une méthode est un test, l’annotation n’a pas changée: @Test, mais le piège est dans le package ! Il faut faire bien attention à prendre maintenant cette annotation du nouveau package org.junit.jupiter.api (et non plus org.junit) sous peine de ne pas voir ses tests s’exécuter.

Les tests imbriqués

JUnit Jupiter supporte maintenant nativement les tests imbriqués. Ils permettent une organisation plus fine de vos tests, en créant des sous-groupes. Ces groupes se matérialisent sous la forme de classes imbriquées annotées par @Nested. Cette imbrication permet de créer une hiérarchie entre les tests, offrant une plus grande granularité dans la gestion de leur cycle de vie. Chaque @Nested classe peut avoir ses propres méthodes d’initialisation et de destruction de contexte, tout en héritant de celles de leurs classes parentes. C’est-à-dire que les méthodes annoncées par @BeforeEach dans les classes parentes, s’exécutent avant celles de la classe imbriquée et ceux pour tous les tests de cette dernière. Il en va de même pour @BeforeAll et de manière symétrique pour @AfterEach et @AfterAll.

Pour l’exemple ci-dessus, l’exécution des tests aura le résultat suivant dans la console :

(A 1) (A 2) (A B 3) (A B 4)

Quelques nouvelles assertions

Au niveau des assertions, on retrouve ce qui existait dans JUnit 4, ainsi que quelques nouveautés. Les assertions classiques anciennement présentes dans org.junit.Assert (telles que assertEquals, assertTrue, assertNull…) sont disponibles dans la nouvelle classe org.junit.jupiter.api.Assertions.

Pour vérifier qu’une exception est bien levée dans un test, on peut maintenant utiliser assertThrows au lieu de devoir passer par une @Rule ou par la syntaxe @Test(expected = …). Ces deux méthodes n’existent d’ailleurs plus dans JUnit Jupiter.

La nouvelle méthode assertAll peut également être utile, notamment lors de l’exécution des tests. Elle permet de grouper plusieurs assertions, et de forcer leur vérification quel que soit le résultat des précédentes. On a donc un rapport complet de toutes les assertions du test, au lieu de s’arrêter au premier échec. Dans l’exemple suivant, on aura donc deux assertions en échec dans le même test.

Le but de JUnit est de fournir les assertions essentielles pour pouvoir écrire des tests simples sans importer de dépendances supplémentaires. Pour des besoins plus complexes en terme de vérification, ou pour avoir des assertions plus lisibles, il est recommandé d’utiliser un framework dédié comme AssertJ.

Des tests paramétrés beaucoup plus flexibles

Pour ce qui est des tests paramétrés, on peut dire que Jupiter a révolutionné la façon dont ils peuvent être écrits. Cette fonctionnalité — en général beaucoup appréciée des développeurs — est maintenant beaucoup plus flexible et moins verbeuse qu’avec JUnit 4. Il y a aussi de nouvelles façons de créer les jeux de données, pour s’adapter à n’importe quel besoin de test.

Pour pouvoir utiliser cette fonctionnalité, il faut avoir org.junit.jupiter:junit-jupiter-params dans le classpath (il sera déjà présent si vous utilisez l’agrégat org.junit.jupiter:junit-jupiter qui l’inclut). Ensuite, plus besoin d’utiliser des runners ni d’annoter des attributs de classe ! Une seule annotation suffit : @ParameterizedTest. Quant aux paramètres, ils sont directement passés en tant qu’arguments de la méthode de test.

Dans l’exemple précédent, les valeurs sont fournies via @ValueSource. Cette annotation est pratique dans le cas d’un test avec un seul argument de type primitif. Pour ajouter une valeur de test nulle ou vide, on peut également la combiner avec @NullSource, @EmptySource ou @NullAndEmptySource.

Dans le cas d’un test avec plusieurs paramètres, on peut écrire les jeux de données dans un format semblable au CSV grâce à @CsvSource. Si les données sont présentes dans un fichier CSV, on peut également utiliser @CsvFileSource en donnant le chemin du fichier.

On remarque dans cet exemple que le paramètre expectedLength est un entier, alors que @CsvSource ne prend que des chaînes de caractère en argument. Cette transformation se fait grâce au mécanisme des “implicit converters” qui peuvent interpréter un String comme un autre type (ici en un entier). Ces convertisseurs fonctionnent nativement avec des types plus complexes comme File, Path, BigDecimal, URL, ou encore avec des énumérations. Les classes de l’API date de Java sont également convertibles, comme le montre l’exemple suivant. Cela permet d’écrire des tests concis, tout en ayant des jeux de données très lisibles.

Ces convertisseurs sont implicites, c’est-à-dire qu’ils sont implémentés et déclarés directement par JUnit 5 (la liste exhaustive de ceux-ci est disponible dans le guide d’utilisateur). Ils aident à réduire le code nécessaire à la préparation des données de test, et à se concentrer sur la logique métier testée.

Si besoin, il est possible d’implémenter ses propres convertisseurs grâce à SimpleArgumentConverter. Il faut ensuite le déclarer sur le paramètre à l’aide de @ConvertWith (contrairement aux convertisseurs implicites qui sont présents par défaut).

Une dernière façon de créer des valeurs de paramètres, qui peut s’avérer utile quand on manipule des objets plus complexes : @MethodSource. On va ici créer programmatiquement les cas de tests dans une méthode statique dédiée (qui peut se situer dans la classe de test ou dans une classe externe), chaque cas étant symbolisé par un objet Arguments.

Plus besoin de dupliquer les tests pour vérifier des contrats !

Il vous est peut-être déjà arrivé de devoir dupliquer des tests pour vérifier que toutes les implémentations d’une de vos interfaces observent toutes le même comportement ? Ou bien qu’il était très fastidieux de rajouter les tests sur les equals et les hashcodes ?

Grâce à cette nouvelle version, vous pouvez dorénavant écrire des interfaces qui comportent des tests. Si on reprend l’exemple de nos equals et hashcodes, vous pouvez créer une interface qui va tester les relations d’égalité pour tous vos objets.

L’interface EqualityTest est générique, ce qui permettra de l’appliquer à la classe que l’on désire tester. Elle contient deux méthodes, createValue et createAnotherValue, qui ne sont pas implémentées ici. Elles forceront le test concret qui implémente cette interface à fournir des valeurs différentes pour l’objet que l’on souhaite tester. On retrouve ensuite deux implémentations de méthodes par défaut qui sont en réalité nos tests génériques, testant l’égalité et l’inégalité au moyen des méthodes précédentes.

Il suffit ensuite de créer un test qui étend cette interface.

A l’exécution de ce test, vous verrez que les tests décrits dans EqualityTest seront joués au même titre que ceux présents dans BookmarkTest. Vous pouvez alors tester les relations d’égalité d’une autre classe, simplement en étendant EqualityTest dans un nouveau test et ceci sans surcoût !

Plus de lisibilité avec les display names

Si vous cherchez à rendre vos rapports de tests plus lisibles dans la console ou dans votre IDE, vous pouvez utiliser l’annotation @DisplayName. Se positionnant sur vos classes et méthodes de tests imbriquées ou non, elle vous permet de remplacer l’affichage par une phrase de votre choix.  

Avec le code précédent, le rapport de tests ressemblera à ceci :

Il est aussi possible de personnaliser un test paramétré. Par défaut, ces tests vont générer une ligne dans le rapport de test en listant les valeurs des paramètres qui seront utilisés lors de chaque exécution. L’attribut name de @ParameterizedTest vous permet de spécifier un template pour la génération de ses lignes. Vous pouvez y référencer la valeur de vos paramètres en utilisant {N}N est la position du paramètre désiré dans votre méthode de test.

Cependant, si vous ne désirez pas positionner @DisplayName sur chaque test, vous pouvez utiliser les DisplayNameGenerator. JUnit fournit des générateurs qui vont se baser sur le nom des méthodes de test pour générer des rapports plus digestes. Par exemple, beaucoup de développeurs utilisent la nomenclature should + undescores pour nommer leur test (e.g. should_do_something). L’utilisation du générateur ReplaceUnderscores a pour effet de remplacer automatiquement les underscores par des espaces lors de la génération du rapport. Pour l’utiliser, il suffit de positionner l’annotation @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) au-dessus de sa classe de test.

JUnit met aussi à disposition 3 autres générateurs:

  • Standard : réutilise tel quel le nom des classes et des méthodes, et liste les paramètres d’un test paramétré (c’est le générateur par défaut du framework
  • Simple : enlève les parenthèses du nom d’un test si celui si ne prend pas de paramètre
  • IndicativeSentences : permet de générer des noms qui ressemblent à des phrases, en concaténant le nom de la classe et le nom de la méthode de test (avec des séparateurs customisables)

Si vous n’êtes pas satisfait pour les générateurs fournis par JUnit, vous pouvez créer le vôtre en implémentant l’interface DisplayNameGenerator. Vous pourrez alors définir votre propre stratégie de génération pour les classes, classes imbriquées ainsi que les méthodes de test.

Vous pouvez aussi renseigner le générateur de votre choix comme générateur par défaut, si vous ne désirez pas le spécifier dans chacune de vos classes de test. Pour cela, il faut créer un fichier src/test/resources/junit-platform.properties dans votre projet. Il suffit ensuite d’affecter à la propriété junit.jupiter.displayname.generator.default le nom complet qualifié du générateur.

Les extensions

Une des principales volontés de cette nouvelle version a été de rendre le framework ouvert à l’extension, et de permettre à chacun de l’adapter à ses propres besoins de test. C’est pour cela qu’une nouvelle API a été conçue : celle des extensions. Il s’agit d’un ensemble d’interfaces implémentables par les utilisateurs du framework, qui vont ensuite être appelées par le moteur de test lors de l’exécution. Certaines fonctionnalités natives de JUnit 5, comme les tests paramétrés, sont en réalité implémentées à l’aide de ces extensions. Elles sont également utilisées par les frameworks tiers (Spring, Mockito, etc) pour intégrer leurs outils de test à JUnit.

Il existe actuellement plus d’une dizaine d’extensions disponibles dans JUnit 5. Elles peuvent également être combinées entre elles, ce qui offre vraiment de multiples possibilités pour améliorer ses tests. Une fois implémentées, ces extensions se déclarent sur les classes ou méthodes de test à l’aide de l’annotation @ExtendWith.

Un type d’extension assez simple à prendre en main est celui qui permet de modifier les étapes du cycle de vie du test. Par exemple, prenons une application Spring qui nécessite le démarrage d’un faux serveur lors des tests d’intégration. Pour éviter d’implémenter cette initialisation pour chaque classe de test, on pourrait créer cette extension :

Ici, on a créé une extension qui combine BeforeAllCallback et AfterAllCallback : elle créera donc des méthodes @BeforeAll et @AfterAll pour chaque classe où elle sera déclarée. De façon similaire, il existe BeforeEachCallback et AfterEachCallback.

Notons qu’il est possible de déclarer plusieurs extensions sur une même classe de test (que ce soit une implémentation personnelle ou fournie par un framework tiers). On peut même maintenant créer une méta-annotation pour rendre le tout encore plus concis et lisible :

Une autre extension très utile qui permet d’injecter des objets dans ses tests : le ParameterResolver. On est parfois obligés de construire des objets complexes dans ses tests, sans que ceux-ci ne portent forcément sur les valeurs. Cela crée des classes encombrées où l’on perd le sens fonctionnel du test. Grâce aux parameter resolvers, on peut externaliser ces créations et les injecter directement comme arguments dans les méthodes de la classe de test.

L’objet à injecter est construit dans resolveParameter. Elle peut être fixe, ou dépendre du contexte du paramètre (voir l’exemple ci-dessus où on utilise une annotation pour influer sur la valeur injectée).

Pour en apprendre plus sur les autres extensions disponibles, vous pouvez vous rendre sur le guide d’utilisateur de JUnit où elles sont toutes répertoriées et documentées (https://junit.org/junit5/docs/current/user-guide/#extensions).

Aller plus loin

Si vous voulez découvrir plus de fonctionnalités, rendez-vous sur le site de JUnit 5 ! La documentation est très claire et complète : https://junit.org/junit5/docs/current/user-guide.

Pour avoir un exemple concret de migration d’une application de JUnit 4 vers JUnit 5, notre conférence sur le sujet est disponible sur Youtube :

Le code présenté est disponible ici : https://gitlab.com/crafts-records/remember-me.

Intéressée par les tests et l’open source, j’ai été amenée à contribuer sur JUnit 5. Faisant désormais partie de l’équipe qui maintient le framework depuis plus d’un an, j’ai pu découvrir les coulisses d’un projet open source de grande ampleur. Je travaille également en tant que développeuse full-stack chez Shodo, où j’essaye avant tout de construire du code maintenable et testable.

Tech Coach chez Shodo, j’accompagne le développement de logiciels à forte valeur métier en usant de techniques issues du Domain-Driven Design, le tout propulsé en Extreme Programming dans la philosophie Kanban #NoEstimates. Membre de la fondation OWASP, j’évangélise sur les techniques de sécurité applicative afin d’éviter de se faire hacker bien comme il faut.

Shodo
Shodo