Accueil Nos publications Blog Du bon usage de JUnit 2/2

Du bon usage de JUnit 2/2

frog_shit_junitJUnit est le principal framework de test dans l’univers Java. Malheureusement, victime de sa longévité et de l’importance du tests existants, ses dernières fonctionnalités sont souvent méconnues. Le but de ce tutoriel est de partir de trois besoins récurrents rencontrés lors de l’écriture des tests et de présenter pour chaque besoin la solution proposée par JUnit.

Dans la première partie, nous nous étions intéressés à l’organisation des tests en catégories. Dans cette seconde partie, nous nous pencherons sur deux autres besoins récurrents : l’injection de jeux de données dans un test et la création d’intercepteurs de tests. A chaque fois, nous critiquerons la solution JUnit mais nous éviterons volontairement de la comparer avec ce qui est offert par les frameworks concurrents. Enfin tous les exemples de code sont sur Github.

Jeux de données

Le besoin

Je reprends la méthode identifyEasterDay, dont j’ai parlé dans la section Prérequis. Cette méthode de la classe HolidaysHelper me renvoie, pour une année fournie en entrée, la date du dimanche de Pâques. Pour valider mon algorithme, il me faut un jeu de test suffisamment large. Concrètement, j’ai les couples (année, date de pâques) suivants :

    {2008,  23 Mars)},
    {2009,  12 Avril)},
    {2010,  4 Avril)},
    {2011,  24 Avril)},
    {2012,  8 Avril)},
    {2013,  31 Avril)},
    {2014,  20 Avril)},
    {2015,  5 Avril)},
    {2016,  27 Mars)}
    

La solution JUnit

Les tests paramétrés

Les tests paramétrés me permettent à partir d’une liste finie de jeux de données, d’exécuter un même test en boucle. Cela évite d’avoir autant de méthodes de tests que de jeux de données.

En pratique, un test paramétré c’est un ensemble d’éléments :

Un nouveau runner
    @RunWith(Parameterized.class)
    public class HolidaysHelperTest {
    
Un builder

C’est la méthode qui va générer mon jeu de données. Cette méthode doit :

  • être statique
  • être annotée @Parameterized.Parameters
  • renvoyer un tableau de tableaux : chacun des tableaux doit avoir comme taille le nombre d’arguments dont a besoin notre test pour tourner.
    @Parameterized.Parameters
    public static Collection<Object[]> data() {
    return Arrays.asList(new Object[][]{
    {2008, LocalDate.of(2008, Month.MARCH, 23)},
    {2009, LocalDate.of(2009, Month.APRIL, 12)},
    {2010, LocalDate.of(2010, Month.APRIL, 4)},
    {2011, LocalDate.of(2011, Month.APRIL, 24)},
    {2012, LocalDate.of(2012, Month.APRIL, 29)},
    {2013, LocalDate.of(2013, Month.MARCH, 31)},
    {2014, LocalDate.of(2014, Month.APRIL, 20)},
    {2015, LocalDate.of(2015, Month.APRIL, 5)},
    {2016, LocalDate.of(2016, Month.MARCH, 27)}
    });
    }
    
Des paramètres

Il s’agit à la fois des données en entrée et des résultats attendus. Le but est de ne pas avoir à faire des “if entrée == x then assert “.
Dans notre cas, on a deux paramètres : l’année et la date de pâque correspondante.

    private int year;
    private LocalDate easterDayExpected; 
    

Il faut noter que c’est la première fois que nous allons passer des paramètres à une méthode de test. Ceci vient contredire la définition que j’avais donnée d’un test JUnit dans la section Pré-requis

Un constructeur

En pratique, JUnit va instancier notre classe de test pour chaque jeu de données et définir les paramètres afin que le test puisse y avoir accès. Il nous faut donc un constructeur avec tous nos paramètres en arguments.

    public HolidaysHelperTest(int year, LocalDate easterDayExpected) {
    this.year = year;
    this.easterDayExpected = easterDayExpected;
    }
    
et…. un test
    private HolidaysHelper holidaysHelper = new HolidaysHelper();
    @Test
    public void should_assert_easterDay_is_correct() {
    // Given
    // When
    LocalDate easterDayActual = holidaysHelper.identifyEasterDay(year);
    // Then
    assertThat(easterDayActual, is(easterDayExpected));
    }
    
Reporting

L’exécution de notre test défini comme ci-dessus donnera :

tests_parametres_sans_name

On peut voir que tout ceci n’est pas très explicite. En effet, si le jeu de données N°3 provoque une erreur, comment deviner, depuis cette information dans la console, ce à quoi correspond le jeu de données N°3.

C’est à ce niveau que l’attribut “name” de l’annotation @Parameterized.Parameters peut nous être très utile. En effet, on peut définir un label qui apparaîtra pour chaque jeu de test. Voici notre builder modifié :

    @Parameterized.Parameters(name = "[Jeu de tests #{index}] En {0}, la fete  de Paques doit etre le {1}")
    public static Collection<Object[]> data() {
    return Arrays.asList(new Object[][]{
    {2008, LocalDate.of(2008, Month.MARCH, 23)},
    {2009, LocalDate.of(2009, Month.APRIL, 12)},
    {2010, LocalDate.of(2010, Month.APRIL, 4)},
    {2011, LocalDate.of(2011, Month.APRIL, 24)},
    {2012, LocalDate.of(2012, Month.APRIL, 8)},
    {2013, LocalDate.of(2013, Month.MARCH, 31)},
    {2014, LocalDate.of(2014, Month.APRIL, 20)},
    {2015, LocalDate.of(2015, Month.APRIL, 5)},
    {2016, LocalDate.of(2016, Month.MARCH, 27)},
    });
    }
    

Dans le fragment ci-dessus l’expression {0} nous permet d’accéder au premier paramètre (ici l’année) et l’expression {1} nous permet d’accéder au second paramètre (ici le nombre de jours fériés).

Une nouvelle exécution de nos tests avec le name ainsi modifié donnera :
tests_parametres_avec_name

Dans notre jeu de données, nous modifions la date de Pâques pour 2012 en mettant le 29 Avril (au lieu du 8 Avril) puis relançons le test.

tests_parametres_avec_un_echec

Comme on peut le constater, grâce à l’attribut “name” de l’annotation @Parameter, nous pouvons identifier immédiatement le jeu de données qui met le test en échec.

Critique sur les tests paramétrés

Les “Parameterized tests” répondent à un besoin réel cependant, on est vraiment déçu par la lourdeur de la mise en oeuvre : le constructeur, la méthode statique pour créer les données, les données membres… Enfin, ce qui me semble être le principal défaut de ce type de tests : les jeux de tests sont systématiquement appliqués à tous les tests de classe. Il est donc impossible d’avoir dans la même classe deux tests avec chacun son jeu de données ou même de mélanger les tests paramétrés avec des tests ordinaires. On est donc obligé de redécouper nos tests en tenant compte de la technique (JUnit) et non du fonctionnel, ce qui est regrettable.

JUnitParams

L’implémentation des tests paramétrés peut être facilitée grâce à la bibliothèque JUnitParams.

Cette bibliothèque permet de contourner les insuffisances des tests paramétrés cités ci-dessus. En particulier :

  • On peut mixer les tests non paramétrés avec les tests paramétrés
  • On n’est plus obligé d’implémenter un constructeur
  • On n’est plus obligé de déclarer les paramètres en tant que variables d’instance mais on doit juste les déclarer comme paramètres de la méthode de test.

JUnitParams offre quelques fonctionnalités supplémentaires mais qui ne me paraissent pas essentielles. Je n’en parlerai donc pas davantage.

Pour utiliser JUnitParams, il faut d’abord ajouter une dépendance comme suit (ou encore télécharger directement le JAR si vous préférez)

    <dependency>
    <groupId>pl.pragmatists</groupId>
    <artifactId>JUnitParams</artifactId>
    <version>1.0.2</version>
    <scope>test</scope>
    </dependency>
    

Ensuite, nous pouvons modifier notre test comme suit :

    import junitparams.JUnitParamsRunner;
    import junitparams.Parameters;
    import org.junit.Test;
    import org.junit.runner.RunWith;
    import java.time.LocalDate;
    import java.time.Month;
    import java.util.Arrays;
    import java.util.Collection;
    import static org.hamcrest.CoreMatchers.is;
    import static org.junit.Assert.assertThat;
    //https://www.recreomath.qc.ca/dict_paques_d.htm
    @RunWith(JUnitParamsRunner.class)
    public class HolidaysHelperTest {
    public static Collection<Object[]> parametersForShould_assert_easterDay_is_correct() {
    return Arrays.asList(new Object[][]{
    {2008, LocalDate.of(2008, Month.MARCH, 23)},
    {2009, LocalDate.of(2009, Month.APRIL, 12)},
    {2010, LocalDate.of(2010, Month.APRIL, 4)},
    {2011, LocalDate.of(2011, Month.APRIL, 24)},
    {2012, LocalDate.of(2012, Month.APRIL, 8)},
    {2013, LocalDate.of(2013, Month.MARCH, 31)},
    {2014, LocalDate.of(2014, Month.APRIL, 20)},
    {2015, LocalDate.of(2015, Month.APRIL, 5)},
    {2016, LocalDate.of(2016, Month.MARCH, 27)},
    });
    }
    private HolidaysHelper holidaysHelper = new HolidaysHelper();
    @Test
    @Parameters
    public void should_assert_easterDay_is_correct(int year, LocalDate easterDayExpected) {
    // Given
    // When
    LocalDate easterDayActual = holidaysHelper.identifyEasterDay(year);
    // Then
    assertThat(easterDayActual, is(easterDayExpected));
    }
    @Test
    public void should_assert_a_behaviour() {
    // Given
    // When
    // Then
    System.out.println("[Une methode de test qui n'est pas une Theory mais un test ordiinire !!!]");
    }
    }
    

Quelques remarques

  • le runner a changé : avec JUnitParams on utilise JUnitParamsRunner.
  • la balise @Parameter au dessus du builder a disparu et avec elle son attribut name. On retrouve donc un problème qu’on pensait résolu : impossible de produire un label métier pour chaque jeu de tests.
  • si la méthode “builder” respecte la nomenclature parametersFor, alors JUnitParamsRunner arrive à associer le test aux jeux de données. Si, on ne souhaite pas respecter cette convention, on peut toujours utiliser l’attribut method de l’annotation @Parameter au dessus de la méthode de tests. On peut ainsi signifier à JUnitParamsRunner* quelle est la méthode qui fournit les jeux de données.

Les théories

Une première Theory

Une Theory au sens JUnit, c’est un test paramétré un peu particulier:

  • C’est le runner Theories qui est utilisé en lieu et place du runner Parameterized
    @RunWith(Theories.class)
    
  • les jeux de données ne sont pas annotés @Parameter mais plutôt @DataPoint
    @DataPoint
    public static Object[] year2008Dataset = {2008, LocalDate.of(2008, Month.MARCH, 23)};
    @DataPoint
    public static Object[] year2009Dataset = {2009, LocalDate.of(2009, Month.APRIL, 12)};
    @DataPoint
    public static Object[] year2010Dataset = {2010, LocalDate.of(2010, Month.APRIL, 4)};
    @DataPoint
    public static Object[] year2011Dataset = {2011, LocalDate.of(2011, Month.APRIL, 24)};
    @DataPoint
    public static Object[] year2012Dataset = {2012, LocalDate.of(2012, Month.APRIL, 8)};
    @DataPoint
    public static Object[] year2013Dataset = {2013, LocalDate.of(2013, Month.MARCH, 31)};
    @DataPoint
    public static Object[] year2014Dataset = {2014, LocalDate.of(2014, Month.APRIL, 20)};
    @DataPoint
    public static Object[] year2015Dataset = {2015, LocalDate.of(2015, Month.APRIL, 5)};
    @DataPoint
    public static Object[] year2016Dataset = {2016, LocalDate.of(2016, Month.MARCH, 27)};
    

IL existe une annotation @DataPoints qui aurait dû permettre de définir une méthode renvoyant tous les jeux de données comme c’est le cas pour les Parameterized tests. Cependant, quand on jette un coup d’œil dans les sources du runner org.junit.experimental.theories.Theories, on se rend compte que seule l’annotation @DataPoint est supportée. C’est pour cette raison qu’on est obligé de déclarer les jeux de données de cette manière.

Une autre alternative est l’annotation @TestOn, permettant de passer directement la série de DataPoint dans la signature de la méthode de test, comme le montre l’exemple suivant, issu de la documentation officielle de JUnit

    @Theory
    public void multiplyIsInverseOfDivideWithInlineDataPoints(
    @TestedOn(ints = {0, 5, 10}) int amount, 
    

Le problème avec cette façon de faire, c’est que l’expression ints = {0, 5, 10} revient à redéfinir la méthode ints de l’interface @TestOn. Cela signifie que nos jeux de données ne peuvent qu’être des types primitifs. En effet, dans une annotation, on ne peut déclarer que des méthodes renvoyant un type primitif ou un String. Si, par exemple, je veux tester une méthode prenant en entrée un objet, je ne pourrais pas le faire.

  • la méthode de test possède autant d’arguments que chaque jeu de données a d’éléments
    @Theory
    public void should_assert_easterDay_is_correct(Object[] dataset) {
    // Given
    int year = (Integer) dataset[0];
    LocalDate easterDayExpected = (LocalDate) dataset[1];
    //System.out.println("year="+year);
    // When
    LocalDate easterDayActual = holidaysHelper.identifyEasterDay(year);
    // Then
    assertThat(easterDayActual, is(easterDayExpected));
    }
    
  • on peut faire coexister, au sein d’une même classe, des théories et des tests ordinaires.
  • Une théorie, à la différence des Parameterized tests, ne renvoie pas de statut d’exécution pour chaque jeu de données. En lieu et place, nous avons un résultat global : la théorie se vérifie ou ne se vérifie pas. Cela signifie que dès qu’un jeu de données réfute la théorie, alors le test s’arrête tandis que dans le cas des Parameterized tests , tous les jeux de données sont systématiquement joués.

Si nous exécutons notre théorie comme définie ci-dessus, nous aurons en pratique un comportement analogue à celui des Parameterized tests. Nous serions alors tentés de conclure que les théories n’apportent pas grand-chose. En effet, pourquoi complexifier le jargon si c’est pour arriver à un résultat identique aux tests paramétrés?

En réalité, notre théorie que nous venons de définir est analogue au premier test unitaire que nous avions créé dans la section « Pré-requis » : c’était techniquement un test, car il y avait l’annotation @Test, mais concrètement il ne faisait rien. Il en va de même pour notre théorie. Techniquement c’est une théorie, même si en vérité elle n’en est pas une. Si vous connaissez la notion de Theory, vous vous en êtes sûrement rendus compte sinon, je vous propose de lire cet article.

Les données de test

Les données sont au centre de la notion de théories. En effet, il est important que :

  • les données soient issues d’un univers de taille relativement grande
  • à chaque test, un échantillon soit prélevé parmi cet univers

Il nous faut donc générer des données de test et pouvoir en sélectionner un échantillon de façon aléatoire. En natif, JUnit ne propose aucune solution. Par contre, c’est la raison d’être du projet junit-quickcheck. Il s’agit d’une implémentation Java du projet QuickCheck. Il existe deux autres implémentation de cette même librairie QuickCheck, JCheck et ScalaCheck respectivement en Java et en Scala. J’ai opté pour junit-quickcheck parce qu’elle est en Java et utilise (redéfinit plutôt) le runner Theories de Junit tandis que JCheck utilise son propre runner.

Installation de junit-quickcheck

Si vous utilisez Maven, vous pourrez ajouter la dépendance à votre projet comme suit :

    <dependency>
    <groupId>com.pholser</groupId>
    <artifactId>junit-quickcheck-core</artifactId>
    <version>0.3</version>
    <scope>test</scope>
    </dependency>
    <dependency>
    <groupId>com.pholser</groupId>
    <artifactId>junit-quickcheck-generators</artifactId>
    <version>0.3</version>
    <scope>test</scope>
    </dependency>
    
Une Theory avec junit-quickcheck

A partir de notre use case calcul de la date de Pâques, j’ai déduit la théorie suivante :

En supposant que j’ai une année strictement positive et inférieure à 2500

La date de Pâques tombe un dimanche entre le 22 Mars et le 25 Avril inclus.

Voici cette théorie traduite à la sauce JUnit-Quickcheck

    import com.pholser.junit.quickcheck.ForAll;
    import com.pholser.junit.quickcheck.generator.InRange;
    import com.ptngaye.junittutorial.dataseries.HolidaysHelper;
    import org.hamcrest.Matchers;
    import org.junit.Test;
    import org.junit.contrib.theories.Theories;
    import org.junit.contrib.theories.Theory;
    import org.junit.runner.RunWith;
    import java.time.DayOfWeek;
    import java.time.LocalDate;
    import java.time.Month;
    import static junit.framework.Assert.assertTrue;
    import static org.hamcrest.Matchers.*;
    import static org.junit.Assert.assertEquals;
    import static org.junit.Assert.assertThat;
    import static org.junit.Assume.assumeThat;
    @RunWith(Theories.class)
    public class HolidaysHelperTest {
    private HolidaysHelper holidaysHelper = new HolidaysHelper();
    @Theory
    public void should_assert_easterDay_is_sunday_and_between_22_march_and_25_april(
    @ForAll @InRange(minInt = -500, maxInt = 3000) int year) {
    // Given
    assumeThat(year, allOf(greaterThanOrEqualTo(0), lessThanOrEqualTo(2500)));
    LocalDate earlierEasterDay = LocalDate.of(year, Month.MARCH, 22);
    LocalDate latestEasterDay = LocalDate.of(year, Month.APRIL, 25);
    DayOfWeek easterDayInWeekExpected = DayOfWeek.SUNDAY;
    // When
    LocalDate easterDayActual = holidaysHelper.identifyEasterDay(year);
    // Then
    assertThat(easterDayActual.getDayOfWeek(), is(easterDayInWeekExpected));
    assertTrue(easterDayActual.isBefore(latestEasterDay) && easterDayActual.isAfter(earlierEasterDay));
    }
    

Attardons-nous un peu sur le bloc suivant :

    @ForAll @InRange(minInt = -500, maxInt = 3000) int year) {
    

L’annotation @ForAll indique à junit-quickcheck qu’on souhaite avoir toutes les valeurs possibles. Ici notre paramètre year est de type int et donc on pourra potentiellement avoir toutes les valeurs, y compris celles qui sont négatives.

Vous pourrez faire un test en enlevant l’annotation @InRange. Vous devriez (forte probabilité mais pas certain) avoir une erreur de la forme
theory_faile_too_large_universe

Ce qui s’est passé, c’est que le générateur nous a généré des valeurs hors des limites des hypothèses contenues dans la méthode de test. Ceci était prévisible : on a très peu de chance d’avoir un nombre entre 0 et 2500 lors d’un tirage de 100 nombres sur l’univers infini des entiers signés.

Avec les annotations @InRange, je peux rétrécir mon univers de données de test. Dans l’exemple ci-dessus, je ne génère que des années entre -500 et 3000.

Au final, on a donc deux possibilités d’agir sur les données sélectionnées :

  • via les hypothèses au niveau du générateur : c’est ce que nous avons fait avec @InRange
  • via les hypothèses au niveau de la méthode de test : c’est ce que nous avons fait avec assumeThat et assumeTrue

Tant que possible, il faudra agir sur le générateur plutôt que dans la méthode. En effet, à chaque test un échantillon de taille fixe est sélectionné. A noter que la taille, par défaut à 100, peut être surchargée grâce à l’attribut sampleSize de l’annotation @ForAll. Si aucun élément de l’échantillon ne passe l’hypothèse intra-méthode de test (assumeXXX), alors on a une exception avec ce type de message Never found parameters that satisfied method assumptions. Il faut donc s’assurer que le générateur génère des données réalistes.

Les hypothèses ont pour rôle de circoncire l’univers des jeux de données. Il convient de les mettre le plus tôt en amont afin que l’échantillon sélectionné soit le plus représentatif possible de données *réalistes*.

Dans l’écriture des *Theory*, il faut donc éviter tant que possible l’usage des méthodes assume* (assumeThat, assumeTrue) car cela signifiera que nous n’avons pas réussi à mettre nos hypothèses au niveau du générateur

junit-quickcheck offre d’autres possibilités, cependant l’objet de ce tutoriel étant JUnit, nous n’en parlerons pas davantage.

Critique de la solution JUnit

Avec les Parameterized Tests et les Theory, JUnit offre un panel sans équivalent dans le monde Java pour injecter des données dans nos tests. Grâce à JUnitParams, on peut pallier les principaux défauts du runner Parameterized. De même, avec l’usage de junit-quickcheck, on peut écrire des tests qui vont être exécutés à l’infini avec des jeux de données différents à chaque fois.

Cependant, dans les deux cas, c’est dommage de devoir passer par une librairie tierce.

Les intercepteurs

Le besoin

Dans le cadre d’un test, Il est souvent nécessaire d’effectuer certaines tâches transversales. Ces tâches sont multiples et très variées :

  • initialiser les ressource (base de données, fichier, répertoire etc.) juste avant l’exécution du test
  • Juste après l’exécution du test, remettre la ressource dans son état initial
  • Modifier le résultat d’un test sous certaines conditions. Par exemple, on peut souhaiter mettre le test en échec si le temps d’exécution dépasse un timeout fixé dans le test
  • etc.

On voit clairement que ce dont nous avons besoin, c’est d’intercepter l’exécution des tests afin de :

  • réaliser certaines tâches d’initialisation
  • modifier le résultat du test sous certaines conditions
  • réaliser certaines tâches à la fin du test

La solution JUnit

Les anciennes annotations

Le premier type de solutions proposé par JUnit, est l’utilisation des annotations :

  • @Before qu’il faut placer sur une méthode destinée à être lancée juste avant l’exécution de chaque test de la classe
  • @BeforeClass qu’il faut placer sur une méthode destinée à être lancée une seule fois avant l’exécution du premier test de la classe.
  • @After qu’il faut placer sur une méthode destinée à être lancée juste après l’exécution de tous les tests
  • @AfterClass qu’il faut placer sur une méthode destinée à être lancée une seule fois juste avant l’exécution du test

Cette solution répond globalement à notre besoin. Cependant, elle a deux défauts majeurs :

  • la non-réutilisation du code.
    En effet, d’un projet à un autre, voire d’un test à un autre, on a souvent tendance à réécrire les mêmes fonctions utilitaires. Avec les annotations énumérées ci-dessus, on est souvent obligé de réécrire les mêmes fonctions.
  • la pollution de la classe test
    Le test, comme on l’a déjà dit, est une spécification et a un important rôle documentaire. C’est pour cela que la priorité lorsque l’on écrit un test doit demeurer la lisibilité et la maintenabilité. Pour cette raison, il est dommage de polluer la classe de test avec des traitements transverses.

Cette première solution étant désormais obsolète, je n’en ai parlerai pas davantage.

Les Rule

La gestion des intercepteurs avec JUnit s’articule autour de deux concepts : le Statment et la Rule.

Le Statement est une classe abstraite qui définit une unique méthode evaluate représentant l’ensemble des instructions qui constituent le test.

    public abstract void evaluate() throws Throwable;
    

La Rule en JUnit, c’est une classe qui implémente l’interface TestRule. Cette interface définit une unique méthode :

    Statement apply(Statement base, Description description);
    

Comme on peut le voir, finalement, une Rule, reçoit un ensemble d’instructions (le Statement) et renvoie un ensemble d’instructions en retour.

Une première Rule

Une Rule basique pourrait donc être

    import org.junit.rules.TestRule;
    import org.junit.runner.Description;
    import org.junit.runners.model.Statement;
    public class NothingRule implements TestRule {
    @Override
    public Statement apply(Statement base, Description description) {
    return base;
    }
    }
    

Cette Rule, comme son nom l’indique, ne fait rien de particulier.

Pattern d’implémentation de Rule

En pratique, écrire une Rule reviendra à renvoyer un nouveau lot d’instructions à la place de ce qui est en entrée de la méthode apply. Pour cela, on a deux possibilités :

  • créer une classe anonyme qui étend Statement
  • créer une classe concrète qui étend Statement

Par exemple, dans org.junit.rules.TestWatcher, c’est une classe anonyme qui est créée

    public Statement apply(final Statement base, final Description description) {
    return new Statement() {
    @Override
    public void evaluate() throws Throwable {
    //ici les traitement de type "Before"
    base.evaluate();  
    //ici les traitement de type "After"
    }
    };
    

Dans l’exemple ci-dessous, on va plutôt créer une classe concrète qui étend Statement comme suit :

    class MyStatement extends Statement {
    private final Statement fNext;
    public MyStatement(Statement base) {
    fNext = base;
    }
    @Override
    public void evaluate() throws Throwable {
    //ici les traitement de type "Before"
    base.evaluate();  
    //ici les traitement de type "After"
    }
    }
    

C’est ce qui a été fait pour la Ruleorg.junit.rules.ExpectedException, pour laquelle le StatementExpectedExceptionStatement a été créé dans une classe interne.

Utilisation des Rule
Déclaration d’une Rule

Une fois notre Rule créée, il nous reste à l’utiliser dans notre test. Dans le cas de TestName, la Rule qui permet de récupérer le nom du test est utilisée juste avant son exécution.

D’abord, on déclare la Rule

    @Rule
    public TestName testNameRule = new TestName();   
    

Ensuite, on peut l’utiliser

    @Test
    public void test_something() throws Exception {
    //Given
    //When
    //Then
    System.out.println("         -"+testNameRule.getMethodName()+"-");
    }
    
Chaînage de Rule

Dans le cas où on a plusieurs Rule déclarées dans une même classe, il peut être intéressant de définir un ordre d’exécution. Pour illustrer cette fonctionnalité, nous allons reprendre l’exemple issu du wiki de JUnit. Nous allons d’abord créer une Rule qui va se contenter de tracer un message reçu en paramètre : LoggingRule.

    public class LoggingRule implements TestRule {
    private String message;
    public LoggingRule(String message) {
    this.message = message;
    }
    @Override
    public Statement apply(Statement base, Description description) {
    System.out.println(message);
    return base;
    }
    }
    

Ensuite nous allons chaîner trois instances de cette Rule comme suit

    import org.junit.Rule;
    import org.junit.Test;
    import org.junit.rules.RuleChain;
    import org.junit.rules.TestName;
    import org.junit.rules.TestRule;
    public class InterceptorsTest {
    @Rule
    public TestName testNameRule = new TestName();
    @Rule
    public TestRule chain = RuleChain
    .outerRule(new LoggingRule("first rule"))
    .around(new LoggingRule("second rule"))
    .around(new LoggingRule("third rule"));
    
Quelques Rule natives

Certaines Rule sont incluses nativement dans le framework au niveau du package org.junit.rules . Ces Rule répondent à des besoins récurrents :

  • TestWatcher pour rajouter des traitements avant et après le test. Cette Rule est tout à fait apte à remplir le contrat des anciennes annotations @Before et @After
  • Timeout pour définir un timeout pour le test
  • ExternalResource qui est une classe abstraite permettant de manipuler des ressources. Par exemple, TemporaryFolder est une Rule basée sur ExternalResource et qui facilite la manipulation de fichiers et répertoires temporaires durant le test.
  • ExpectedException est une Rule qui va vérifier si l’exécution du test génère l’exception attendue. Cette Rule est destinée à remplacer l’ancien attribut expected de l’annotation @Test qui permettait de définir l’exception attendue.

Critique de la solution JUnit

Au-delà du périmètre fonctionnel des anciennes annotations @Before et @After, les Rule sont un formidable outil dont les possibilités vont bien au-delà des quelques exemples que j’ai donné dans cet article. Par exemple, Jens Schauder dans ce projet montre comment générer des données de test grâce à une Rule.

Enfin, l’usage des Rule permet de mutualiser les codes transversaux et d’avoir des tests non pollués par des traitements secondaires.

Conclusion

La question “JUnit or not” est plus que jamais d’actualité. Cependant, avant d’aborder cette question, il me semblait important de faire un rappel sur l’usage qui devrait être fait de JUnit. En effet, on a vite fait de lire un comparatif orienté (TestNG en l’occurrence) et tirer des conclusions hâtives. Il faut savoir qu’un test JUnit aujourd’hui (JUnit 4.11), est très différent d’un test JUnit 3 ou antérieur. Voici ce que permet de faire JUnit aujourd’hui :

  • le regroupement des tests grâce aux Category, aux Suite et à la bibliothèque ClasspathSuite
  • les tests paramétrés avec la bibliothèque JUnitParams
  • les théories à l’aide des Theory et de la bibliothèque QuickCheck
  • les intercepteurs avec les Rule

Tout comparatif de JUnit avec un éventuel challenger devrait prendre en compte les fonctionnalités ci-dessus, en particulier la gestion des intercepteurs.

Même si JUnit a beaucoup changé, à cause de la rétro-compatibilité il est toujours possible d’écrire des tests old school. C’est ce qui a motivé le titre de ce tutoriel du bon usage de JUnit. En effet, JUnit, ce n’est pas uniquement l’annotation @Test et assertEquals, mais il nous appartient d’utiliser les fonctionnalités offertes. Si nous utilisons correctement les possibilités offertes par JUnit, nous pourrions écrire des tests plus faciles à maintenir et améliorer notre productivité.

Dans la même optique, mais dans une perspective plus générale, je présenterai dans un prochain article un panorama des outils de tests unitaires incluant JUnit et TestNG évidemment mais aussi des frameworks de mock et des API d’assertion.