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;
    //http://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 Rule org.junit.rules.ExpectedException, pour laquelle le Statement ExpectedExceptionStatement 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.

Nombre de vue : 668

COMMENTAIRES 4 commentaires

  1. Patouche dit :

    Bonjour Pattern,

    Ton article est intéressant.

    Je ne connaissais pas bien les theory dans JUnit et l’article dessus (des theories pour debusquer des bugs est bien fait et très intéressant).

    Tu expliques assez bien ce que sont des tests paramétriques. Par contre, dans ton article comme dans celui de Babon, vous ne parlez pas de l’annotation @Parameter. Donc, en fait, désormais, dans la dernière version de JUnit, on n’est pas non plus obligé d’implémenter un constructeur.

    Par ailleurs, tu le dis mais sans vraiment le dire en parlant de variable d’instance mais tu as un peu raté l’intérêt principale de JunitParams. Les tests paramétriques de JUnit sont là pour appliquer des cas de test sur une méthode et non sur une classe. Ainsi, au sein d’une classe de test et avec cette lib, tu peux mixer des jeux de tests différents au sein d’une même classe (via paramsFor ou method = dans ton annotation).

    En fait, j’avoue être assez mitigé sur cette librairie. AMHA, je ne crois pas que l’intérêt des paramétriques soit l’unitaire mais plus dans l’intégration afin de lancer des batteries de tests sur une méthode. Et si ce n’est pas dans l’unitaire, pourquoi alors mixer TU et test d’intégration ? Si tu en arrive au point de devoir lancer des paramétriques pour essayer de tester “unitairement”, c’est que tu que tu dois probablement réécrire ta méthode testée et non tenté de comblé la complexité de ton algorithme au sein de ta méthode avec cela. Ainsi, ce que plusieurs personnes qualifie de “lourdeur” dans les paramétriques JUnit est, il me semble, assez justifier par ce que l’on cherche à faire. Seulement, voilà, avec cette “nouvelle” annotation, on cherche déjà à mixer TU et test d’intégration…

    Petit autre détail mais, dans les annotations, tu peux mettre un tout petit peu plus que des type primitifs et des String… D’ailleurs, tu le fais au sein même de l’article avec @InRange. En fait, en te lisant, je me suis rendu compte que je ne savais pas non plus ce que l’on pouvait y mettre exactement (@nnotation, des Class comme dans @Runner, String, type primitif ou un tableaux à 1 dim comme dans @ExceptionHandler).

    Enfin, un Sysout, même si c’est dans du test, c’est assez moche… Au moins, tu aurais pu faire un Assert.assertThat(“Ton message : début ” + taRule.getMethodName() + ” la fin”, , ) ou un as() (description() ??) dans les fluent.

    Cordialement,
    Patouche

  2. Paterne Gaye-Guingnido dit :

    @Patouche Merci pour beaucoup pour ton message et tes remarques. Mes réponses sont ci-dessous.

    ===Sysout dans les exemples de code.
    Je suis tout à faire d’accord avec la remarque sur le sysout. Je plaide coupable. Je corrigerai la version sur Github.

    ===Concernant l’intérêt des tests paramétrés
    Effectivement, on a l’annotation @Parameter pour éviter d’avoir à écrire un constructeur.Cependant, l’annotation @Parameter oblige à :
    -définir une donnée membre annoté @Parameter
    -la donné membre doit être publique !!!.
    C’est pour ces raisons que je prefere me passer de @Parameter. Au final il est plus simple de rajouter un parametre à une méthode. Cependant, j’aurais sans doute pu présenter cette alternative. Merci pour ta remarque

    Quant à l’usage des tests paramétrés, le contexte, tests unitaires ou tests d’intégration, ne me semble pas que celà vraiment important. C’est un outil, on pourrait les utiliser dans un cas comme dans l’autre. Dans le cas particulier des tests unitaires, ça évite de devoir répéter le meme tests plusieurs fois, ou pire à mettre tous les tests dans une même méthode

    Concrètement, dans l’exemple présenté, comme je l’indiquais, j’ai une seule alternative aux tests paramétrés :
    écrire autant de méthodes de tests que de jeux de données. Dans mon cas, ça revient à autant de méthode de tests que d’année que je veux tester. Ça fonctionne, mais ce n’est pas très DRY. Il me semble que cela n’a rien à voir avec l’implémentation de la méthode elle-même. Mais pour ce use case que je rencontre souvent, je suis ouvert à d’autres propositions, si il y a.

    ===Concernant les “Theory”
    Je reforumule avec davantage de précisions. Il n’est pas possible de manipuler autre chose que des primitifs ou des String (ou des tableaux) ou des “class” ou des “enum” dans les annotations.
    Par exemple, prenons le code suivant

    public @interface JavaBlog {
    Object something() default null;
    }

    Nous devrions avoir à la compilation l’erreur :
    “Invalid type Object for the annotation attribute JavaBlog.something; only primitive type, String, Class, annotation, enumeration are permitted or 1-dimensional arrays thereof”

    Dans le cas de InRange, on peut écrire @InRange(minInt = -500 parce que dans l’annotation InRange, on a une méthode minInt() définie comme suit :
    int minInt() default Integer.MIN_VALUE;
    Il n’est pas possible, en tout cas, je ne suis pas arrivé à le faire, de rajouter autre chose que des types primitifs ou une String dans une annotation.
    A ce sujet, en regardant les sources de InRange ici : http://goo.gl/NHOVjY, on peut voir que toutes les méthodes renvoient un primitif ou un String.

    Pour revenir à mon use case, imaginons que j’ai une méthode qui prend en entrée un Objet complexe qui soit autre chose qu’un String ou un primitif. J’aimerai pouvoir utiliser l’annotation
    org.junit.experimental.theories.suppliers.TestOn afin de passer directement mes jeux de données à ma méthode de test. En l’état, je ne peux pas car le ints de @TestOn correspond à un tableau d’entier.

    La solution consiste donc :
    -soit ne pas avoir d’objets complexe à tester 🙂
    -soit générer les données atomiques permettant de créer ses objets, faire une méthode builder qui sera appelée dans la méthode de test

  3. Patouche dit :

    @Pattern :

    Cool pour le sysout 🙂 !! Merci !! En fait, j’avoue que bien souvent ça ne me choque pas trop de voir ça dans un code d’exemple. Juste qu’ensuite, on peut le retrouver dans des applis qui arrivent en production… Et j’ai un mal fou à faire comprendre que c’est pas bien à certaine personne.

    ==== Paramétrique :

    > Effectivement, on a l’annotation @Parameter pour éviter d’avoir à écrire un constructeur.Cependant, l’annotation @Parameter oblige à :
    > -définir une donnée membre annoté @Parameter
    Oui, c’est vrai. Ca ajoute un peu de complexité au test mais pas tant que ça je trouve car c’est finalement assez parlant. Bon, après, il faut juste savoir que la value sera la position de l’index dans le tableau

    > -la donné membre doit être publique !!!.
    Oui, c’est aussi vrai. Pourtant, ça ne choque personne sur les Rule. Et pire que ça, les rule, on y accède toujours ? Bah, non… La propriété @Parameter, oui (ou alors, la personne qui écrit le test ne sais pas faire des tests paramétriques). Ex d’une rule que l’on “utilise” pas sauf pour le @Autowired : Jira spring 7731 ;-). Ou celle de PowerMock.

    > C’est pour ces raisons que je préfère me passer de @Parameter. Au final il est plus simple de rajouter un parametre à une méthode. Cependant, j’aurais sans doute pu présenter cette alternative. Merci pour ta remarque
    > Quant à l’usage des tests paramétrés, le contexte, tests unitaires ou tests d’intégration, ne me semble pas que celà vraiment important. C’est un outil, on pourrait les utiliser dans un cas comme dans l’autre. Dans le cas particulier des tests unitaires, ça évite de devoir répéter le meme tests plusieurs fois, ou pire à mettre tous les tests dans une même méthode
    Justement, c’est relativemement mal cela. Faire du TU avec le même code à l’intérieur mais des données d’entrées différente… Il ne s’agit plus de TU mais d’une course au branche coverage. J’en ai déjà discuté avec certains collègues en cherchant à leur expliquer pourquoi je n’étais pas forcément fan des paramétriques au sein d’un TU. Le seul cas d’utilisation possible d’un paramétrique est quand on n’a rien à mocker (enfin, on peux faire des stubs mais dans ce cas là, l’article sur développez.com qui explique la différentes entre mocks et stubs). Donc, au final, le test se limite à assez peu de type de méthode.

    Prenons un Utils pour faire un simple check d’un mail (dans le cas des Utils, oui, tu as raison, le paramétrique s’applique plutôt bien). Et dans le test, du code ultra délirant en mode super dirty :

    public static boolean isMail(String mail) {
    int count = 0;
    for (int i=0; i 1) {
    return false;
    }

    // De manière générale, les multiples return par méthode sont juste chiant à lire et debugger car cela casse le flux d’excution
    // Le SESE (single entry, single exit) a du bon même si, à cause de cela, on peut avoir un peu trop de if imbriqué (deep statement).
    return true;
    }

    Dans ce cas, le test paramétrique serait parfait pour faire un TU avec 100% de branch coverage. Cependant, je crois qu’un brin de refactoring (ou de recherche) aurais été préférable avant de se lancer à corps perdu dans du code et de tester (éventuellement a posteriori) avec des tests paramétriques. Mais souvent, dans ce genre de cas, il faut chercher plus loin (quelque chose dans les commons-validator aurait très bien fait l’affaire)

    Autre problème d’ailleurs de ton test paramétrique est le cas avec des exceptions (utilisation du @Expected). Mais dans ce cas là, dans ton paramétrique, tu seras obligé d’avoir de try / catch + un boolean (ou un Assert.fail() dans ton catch + un Assert.fail() après ton try)…

    > Concrètement, dans l’exemple présenté, comme je l’indiquais, j’ai une seule alternative aux tests paramétrés :
    > écrire autant de méthodes de tests que de jeux de données. Dans mon cas, ça revient à autant de méthode de tests que d’année que je veux tester. Ça fonctionne, mais ce n’est pas très DRY. Il me semble que cela n’a rien à voir avec l’implémentation de la méthode elle-même. Mais pour ce use case que je rencontre souvent, je suis ouvert à d’autres propositions, si il y a.

    Oui, c’est loin d’être bien la répétition de code. C’est même un fléau !! Cependant, le fait que tu parles de jeux de données montre bien qu’il s’agit plus de “test d’intégration” – like que d’un pur TU. Et oui, tu as raison, il n’y a pas de solution magique à cela. Après, les test paramétriques pour les TU, je ne suis pas encore convaincu…

    === Theory :

    […]
    C’est rigolo, l’annotation que j’ai créé, elle s’appellait BidonAnnot 😉 !!

    > A ce sujet, en regardant les sources de InRange ici : http://goo.gl/NHOVjY, on peut voir que toutes les méthodes renvoient un primitif ou un String.
    > Pour revenir à mon use case, imaginons que j’ai une méthode qui prend en entrée un Objet complexe qui soit autre chose qu’un String ou un primitif. J’aimerai pouvoir utiliser l’annotation
    > org.junit.experimental.theories.suppliers.TestOn afin de passer directement mes jeux de données à ma méthode de test. En l’état, je ne peux pas car le ints de @TestOn correspond à un tableau d’entier.
    > La solution consiste donc :
    > -soit ne pas avoir d’objets complexe à tester 🙂
    > -soit générer les données atomiques permettant de créer ses objets, faire une méthode builder qui sera appelée dans la méthode de test

    Oui, il semblerait en effet que ce soit l’idée. Elle me semble bonne mais après je ne sais pas trop car jamais tester 😉

  4. odenier dit :

    Merci pour cet excellent tuto !!
    Petite remarque concernant les dates de pâques :
    En 2012, c’est le 8 Avril
    En 2013, c’est le 31 Mars

AJOUTER UN COMMENTAIRE