📘 DĂ©velopper des tests automatisĂ©s

1. RĂŽle et usage des frameworks de test

1.1. Difficultés du test automatisé

Comme Ă©voquĂ© prĂ©cĂ©demment, le test automatisĂ© nĂ©cessite d’implĂ©menter une suite de scĂ©narios de test sous la forme d’un ensemble de plusieurs scripts de test (aussi appelĂ©s programmes de test). Lorsqu’il est lui-mĂȘme exĂ©cutĂ©, un script de test est capable de jouer un scĂ©nario de test sans intervention humaine.

Le terme script ici ne fait pas référence à la notion (approximative) de langage de script utilisée communément dans le monde logiciel pour désigner des langages comme Bash ou Perl. Dans ce chapitre, un script de test désigne plus généralement un morceau de code exécutable qui permet de jouer un scénario de test, ce qui peut donc désigner aussi bien à un script Bash, une fonction Python, ou une méthode Java.
Exemple 1. Scripts de test en Java implémentant une suite de tests unitaires pour PALINDROME

On se propose de dĂ©sormais tester PALINDROME de maniĂšre unitaire, en s’intĂ©ressant uniquement Ă  son composant interne devant implĂ©menter l’interface Java Palindrome suivante :

package app;

interface Palindrome {
    /**
     * Verifies that a word is a palindrome.
     * Ignores the character case.
     * 
     * @return true if word is a palindrome, false otherwise.
     * @throws NullPointerException if word is null
     * @throws IllegalArgumentException if word is empty 
     *                                  or contains a digit, 
     *                                  or contains a special character.
     **/
    boolean isPalindrome(String word) throws IllegalArgumentException, NullPointerException;
}

Pour l’implĂ©mentation, on a choisi d’implĂ©menter cette interface Java Ă  l’aide d’une classe appelĂ©e BasicPalindrome. On dĂ©veloppe donc un ensemble de scripts de test pour cette classe, Ă©tant donnĂ© la spĂ©cification de son interface Palindrome :

package app;

public class ManualBasicPalindromeTest {

    public static void main(String[] args) throws IllegalArgumentException {

        // Variables pour le résultat total
        int passedTest = 0;
        int totalTests = 0;

        // Préparation d'un objet BasicPalindrome
        Palindrome palindrome = new BasicPalindrome();

        // Execution du 1er scénario de test
        System.out.print("Test [\"" + "anna" + "\"," + "true" + "] ");
        boolean result = palindrome.isPalindrome("anna");

        // Oracle du 1er scénario de test
        if (result == true) {
            System.out.println("succeeded");
            passedTest++;
        } else {
            System.out.println("failed");
        }
        totalTests++;

        // Execution du 2e scénario de test
        System.out.print("Test [\"" + "john" + "\"," + "false" + "] ");
        boolean result2 = palindrome.isPalindrome("john");

        // Oracle du 2e scénario de test
        if (result2 == false) {
            System.out.println("succeeded");
            passedTest++;
        } else {
            System.out.println("failed");
        }
        totalTests++;

        // Execution du 3e scénario de test
        System.out.print("Test [\"" + "ann4" + "\"," + "IllegalArgumentException" + "] ");
        boolean exceptionThrown = false;
        try {
            palindrome.isPalindrome("ann4");
        } catch (IllegalArgumentException e) {
            exceptionThrown = true;
        }

        // Oracle du 3e scénario de test
        if (exceptionThrown == true) {
            System.out.println("succeeded");
            passedTest++;
        } else {
            System.out.println("failed");
        }
        totalTests++;

        // SynthÚse des résultats
        System.out.println("\nResult: " + passedTest + "/" + totalTests);
    }

}

ExĂ©cution des scripts de test (une fois compilĂ©s) :

$ java -jar palindrome-test.jar

Test ["anna",true] succeeded
Test ["john",false] succeeded
Test ["ann4",IllegalArgumentException] succeeded

Result: 3/3

Cependant, comme on peut le voir sur l’exemple ci-dessus, ce travail d’implĂ©mentation fait face Ă  un certain nombre d’obstacles, notamment :

  • Gestion des oracles : chaque oracle doit ĂȘtre programmĂ© manuellement, gĂ©nĂ©ralement Ă  l’aide de conditionnelles (if) et d’opĂ©rateurs de comparaison.

  • Gestion des erreurs : une suite de tests devrait se poursuivre mĂȘme si l’un de ses scripts de test rencontre une erreur, ce qui n’est pas le cas ici.

  • Gestion des rĂ©sultats : tous les rĂ©sultats obtenus par l’exĂ©cution des scripts de test (rĂ©ussite, Ă©chec, ou erreur − ainsi que les causes des Ă©checs et erreurs) doivent ĂȘtre stockĂ©s et fournis au dĂ©veloppeur sous une forme lisible et exploitable.

1.2. Notion de framework de test

Afin de faciliter la tĂąche d’implĂ©mentation des scripts de test, il est possible et fortement conseillĂ© d’utiliser des outils dĂ©diĂ©s Ă  cette tĂąche, Ă  savoir des frameworks de test.

Un framework de test est gĂ©nĂ©ralement composĂ© de deux parties :

  • Une bibliothĂšque logicielle qui facilite l’Ă©criture des scripts de test, avec notamment :

    • la possibilitĂ© de dĂ©clarer explicitement quelle partie du code correspond Ă  un script de test (et donc Ă  un scĂ©nario de test),

    • des fonctions utilitaires appelĂ©es des assertions aidant Ă  la dĂ©finition d’oracles,

    • des fonctionnalitĂ©s pour Ă©viter trop de redondances dans le code de test, notamment en ce qui concerne la prĂ©paration des donnĂ©es de test.

  • Un exĂ©cuteur qui est capable d’exĂ©cuter un ensemble de scripts de test Ă©crits avec le framework, et avec les propriĂ©tĂ©s suivantes :

    • chaque script de test est exĂ©cutĂ© sĂ©parĂ©ment des autres,

    • si un script de test ne passe pas (erreur ou Ă©chec), les autres scripts de test sont tout de mĂȘme exĂ©cutĂ©s,

    • les rĂ©sultats sont collectĂ©s et rassemblĂ©s dans un rapport lisible et exploitable.

Si on rĂ©sume en un schĂ©ma d’ensemble, avec en orange tout ce qui est dĂ©rivĂ© de la spĂ©cification, et en rouge ce qu’on souhaite tester :

framework.drawio
Figure 1. RĂŽle d’un framework de test

2. Zoom sur JUnit

2.1. Présentation

Le framework de test JUnit, aujourd’hui dans sa version 6, est l’un des principaux outils de test logiciel utilisĂ©s dans le monde. Il permet d’Ă©crire des scripts de test Ă  l’aide du langage Java, et de nombreux environnements s’intĂšgrent avec son exĂ©cuteur (Maven, Eclipse, IntelliJ, etc.).

junit logo
Figure 2. Logo de JUnit

Bien qu’il puisse ĂȘtre utilisĂ© pour toutes sortes de scĂ©narios de test, JUnit est particuliĂšrement utile pour la programmation de scĂ©narios de tests unitaires dĂ©diĂ©s Ă  des logiciels implĂ©mentĂ©s en Java. En effet, comme les scripts de test sont eux-mĂȘme Ă©crits en Java, il est alors possible de directement appeler des mĂ©thodes Java importĂ©es depuis le logiciel testĂ©.

MalgrĂ© son nom, JUnit n’est pas restreint au test unitaire, et peut ĂȘtre utilisĂ© pour tout type de tests − y compris systĂšme !
Cette partie du cours ne fait qu’effleurer une partie des possibilitĂ©s offertes par JUnit. Pour plus d’informations, la documentation officielle de JUnit est trĂšs fournie et bien Ă©crite.

2.2. Utiliser Jupiter, la bibliothĂšque JUnit

Avant toute chose, afin d’ĂȘtre compilable, le projet contenant le scripts de test doit possĂ©der une dĂ©pendance vers la bibliothĂšque JUnit, aussi appelĂ©e Jupiter.

Nous allons voir un aperçu de comment gĂ©rer cette dĂ©pendance dans un projet Java  via un projet Maven.

2.2.1. Dépendance via un projet Maven

Un projet Maven impose par dĂ©faut une structure prĂ©cise qui sĂ©pare le code source du logiciel de celui des scripts de test. Cette structure distingue le rĂ©pertoire src/main/java oĂč se trouve le code source du logiciel, et src/test/java oĂč se trouve le code source des script de test.

Exemple 2. Exemple de structure de projet pour le logiciel Palindrome
├── pom.xml
└── src
    ├── main
    │   └── java
    │       └── app
    │           └── Palindrome.java
    │           └── BasicPalindrome.java
    └── test
        └── java
            └── app
                └── BasicPalindromeTest.java

Une fois ce point pris en compte, pour ce qui est de la dĂ©pendance Ă  JUnit, il faut aller dans le pom.xml et ajouter une dependency vers junit-jupiter :

<dependencies>
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter</artifactId>
        <version>6.1.0</version>
        <scope>test</scope>
    </dependency>
</dependencies>

On pourra ensuite compiler le projet avec Ă  la fois le logiciel et ses tests Ă  l’aide de la commande :

$ mvn test-compile

2.2.2. Déclarer une suite de tests et un scénario de test

Comme un trĂšs grand nombre de frameworks de test, JUnit repose sur l’idĂ©e que chaque suite de tests prendra la forme d’une classe, et que chaque scĂ©nario de test sera implĂ©mentĂ© sous la forme d’une mĂ©thode de cette classe. Cette mĂ©thode sera donc ici notre script de test.

En outre, pour que les scripts de test soient reconnus par l’exĂ©cuteur :

  • Chaque classe de test doit avoir un nom qui se termine par le suffixe Test.

  • Chaque script de test doit ĂȘtre une mĂ©thode Java possĂ©dant l’annotation @Test, ainsi que optionnellement l’annotation @DisplayName pour dĂ©crire l’objectif du test.

Exemple 3. DĂ©but de réécriture de la mĂȘme suite de tests avec JUnit 5
package app;

import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

public class EmptyBasicPalindromeTest {

    @Test
    @DisplayName("Can detect that a word is a palindrome")
    public void testCanDetectPalindrome() throws IllegalArgumentException {
        // TODO
    }

    @Test
    @DisplayName("Can detect that a word is not a palindrome")
    public void testCanDetectNotPalindrome() throws IllegalArgumentException {
        // TODO
    }

    @Test
    @DisplayName("Reject a word with a special character")
    public void testRejectSpecialCharacter() throws IllegalArgumentException {
        // TODO
    }

}
On peut remarquer que ce programme Java n’a pas de main ! L’explication se trouve dans l’exĂ©cuteur JUnit qui possĂšde son propre main qui va se charger de trouver et d’exĂ©cuter par lui-mĂȘme toutes les mĂ©thodes dĂ©corĂ©es avec @Test.

2.2.3. Programmer un script de test

On peut ensuite remplir un script de test avec toutes les parties du scĂ©nario de test que l’on souhaite implĂ©menter, Ă  savoir : la prĂ©paration (si nĂ©cessaire), l’exĂ©cution, et l’oracle.

  • Pour la dĂ©finition de la prĂ©paration et de l’exĂ©cution, on utilise du pur Java.

  • Pour la dĂ©finition de l’oracle, un mĂ©canisme est nĂ©cessaire pour vĂ©rifier que le comportement observĂ© est bien le bon, et pour stocker le rĂ©sultat obtenu (erreur, rĂ©ussite, ou Ă©chec). Avec JUnit, cela passe par l’appel Ă  des mĂ©thodes statiques d'assertion fournies par la classe org.junit.jupiter.api.Assertions. Chacune de ces mĂ©thodes prend en entrĂ©e une condition − ou simplement les donnĂ©es nĂ©cessaires Ă  la vĂ©rification d’une condition − ainsi qu’un message Ă  afficher en cas d’Ă©chec (optionnel). Lors de l’appel d’une mĂ©thode d’assertion, une condition est vĂ©rifiĂ©e, et :

    • soit la condition est vraie, et le test se poursuit,

    • soit la condition est fausse, et le scĂ©nario de test est interrompu et marquĂ© comme Ă©tant un Ă©chec.

    Ainsi, un scĂ©nario de test sera marquĂ© comme une rĂ©ussite s’il arrive Ă  son terme sans avoir rencontrĂ© d'erreur − Ă  savoir une exception Java non attrapĂ©e − et sans avoir rencontrĂ© un Ă©chec − Ă  savoir une condition non vĂ©rifiĂ©e par un appel d’assertion effectuĂ© par l’oracle.

Attention Ă  ne pas confondre les mĂ©thodes assertXXX fournies par JUnit, avec le mot-clĂ© assert fourni au sein mĂȘme du langage Java ! Il est trĂšs recommandĂ© de toujours utliser les mĂ©thodes assertXXX fournies par JUnit car :

  1. Le mot-clĂ© assert n’est par dĂ©faut jamais exĂ©cutĂ© en Java, et n’est activĂ© que si l’option -ea est donnĂ©e Ă  la JVM exĂ©cutante.

  2. Les mĂ©thodes assertXXX produisent de bien meilleurs messages en cas d’Ă©chec. Par exemple, assertEquals(1, 2) donnera java.lang.AssertionError: expected:<1> but was:<2>, tandis que assert(1 == 2); donnera juste java.lang.AssertionError.

  3. L’usage du mot-clĂ© assert au sein de scĂ©narios de tests JUnit n’est pas supportĂ© par certains outils, comme par exemple PIT (voir chapitre sur la approche par mutation).

L’essentiel des mĂ©thodes d’assertion de JUnit
Méthode Description

assertTrue​(boolean condition)

VĂ©rifie qu’une condition est vraie.

assertFalse​(boolean condition)

VĂ©rifie qu’une condition est fausse.

assertEquals​

Vérifie que deux éléments sont égaux (note: la méthode equals sera appelée si elle existe).

assertNotEquals​

Vérifie que deux éléments ne sont pas égaux (note: la méthode equals sera appelée si elle existe).

assertArrayEquals

Vérifie que deux tableaux contiennent des valeurs égales.

assertIterableEquals​(Iterable<?> expected, Iterable<?> actual)

Vérifie que deux itérables (ex. des collections) contiennent des valeurs égales.

assertSame​(Object expected, Object actual)

VĂ©rifie que deux objets sont bien le mĂȘme objet (avec l’opĂ©rateur ==).

assertNotSame​(Object unexpected, Object actual)

VĂ©rifie que deux objets ne sont pas le mĂȘme objet (avec l’opĂ©rateur !=).

assertLinesMatch​(List<String> expectedLines, List<String> actualLines)

VĂ©rifie qu’un texte correspond Ă  un texte modĂšle pouvant contenir des expressions rĂ©guliĂšres. Peut servir Ă  analyser qu’une sortie console ou qu’un fichier produit respecte bien un format attendu.

assertNull​(Object actual)

VĂ©rifie que l’objet est null.

assertNotNull​(Object actual)

VĂ©rifie que l’objet n’est pas null.

assertTimeout​(Duration timeout, Executable executable)

VĂ©rifie que l’exĂ©cution d’un Executable (ex. une lambda expression) se termine avant un temps donnĂ©.

assertTimeoutPreemptively​(Duration timeout, Executable executable)

VĂ©rifie que l’exĂ©cution d’un Executable (ex. une lambda expression) se termine avant un temps donnĂ©, et interrompt l’exĂ©cution si le temps est dĂ©passĂ©.

assertThrows​(Class<T> expectedType, Executable executable)

VĂ©rifie que l’exĂ©cution d’un Executable (ex. une lambda expression) produit bien une exception d’un certain type.

assertDoesNotThrow​(Executable executable)

VĂ©rifie que l’exĂ©cution d’un Executable (ex. une lambda expression) ne produit pas d’exceptions.

fail()

Met le test en échec sans vérifier de condition.

Toutes ces mĂ©thodes existent en plusieurs variantes Ă  l’aide de surcharge d’opĂ©rateur (ex. assertEquals possĂšde une variante par type de donnĂ©es). Notamment, chaque mĂ©thode possĂšde une variante permettant d’ajout d’un message Ă  afficher en cas d’Ă©chec.
VĂ©rification des exceptions Java avec l’assertion assertThrows

Il est courant que la spĂ©cification indique qu’une opĂ©ration Java doit, dans des conditions bien prĂ©cises, produire une exception d’un certain type. Il devient alors nĂ©cessaire de tester cette partie de la spĂ©cification, autrement dit de tester qu’une exception est bien produite dans les conditions spĂ©cifiĂ©es.

Mais comment faire pour vĂ©rifier la bonne prĂ©sence d’une exception, Ă©tant donnĂ© qu’une exception est censĂ©e interrompre l’exĂ©cution ?

Supposons qu’on teste une mĂ©thode statique imaginaire shouldThrowExceptionWithNegativeNumbers(int number), dont la spĂ©cification indique qu’elle doit renvoyer une exception Java de type IllegalArgumentException lorsqu’on l’appelle sur un nombre nĂ©gatif. On souhaite donc tester que cette exception est bien produite dans cette situation.

Une solution naive serait de procĂ©der ainsi :

@Test
public void testExceptionNegativeNumber() {

    try {
        shouldThrowExceptionWithNegativeNumbers(-1); (1)
    } catch (IllegalArgumentException e) {
        return; (2)
    }

    fail(); (3)
}
1 Exécution qui devrait produire une IllegalArgumentException.
2 Si on atteint ce point, c’est qu’une IllegalArgumentException a Ă©tĂ© produite, donc tout est correct, donc on sort du test (qui est par dĂ©faut une rĂ©ussite).
3 Si on atteint ce point, c’est que le return n’a pas Ă©tĂ© exĂ©cutĂ©, donc pas d’objet IllegalArgumentException de produit, donc on Ă©choue le test.

Heureusement, il est possible d’Ă©viter cette Ă©criture alambiquĂ©e en reposant sur l’assertion assertThrows fournie par JUnit 5. Cette assertion a de particulier qu’elle prend en premier paramĂštre le type de l’exception attendue (par exemple ici IllegalArgumentException), et en second paramĂštre une expression lambda contenant le morceau de code devant produire l’exception du type spĂ©cifiĂ©e.

  • Le premier paramĂštre de assertThrows s’Ă©crit avec le suffixe .class, qui est nĂ©cessaire en Java pour transformer un type (comme une interface ou classe) en valeur pouvant ĂȘtre utilisĂ©e comme paramĂštre.

  • Le second paramĂštre de assertThrows s’Ă©crit avec la notation () → {} propre aux expressions lambda, oĂč les parenthĂšses contiennent les paramĂštres de l’expression lambda — dans notre cas, une liste vide — et oĂč les accolades contiennent le code Ă  exĂ©cuter — dans notre cas, le code devant produire l’exception

Ainsi, si on réécrit notre exemple avec assertThrows, on peut Ă©crire notre script de test en une seule ligne bien plus lisible :

@Test
public void testExceptionNegativeNumber() {
    assertThrows(IllegalArgumentException.class, () -> {
        shouldThrowExceptionWithNegativeNumbers(-1);
    });
}
Exemple 4. ImplĂ©mentation des scripts de test pour Palindrome (suite de l’exemple prĂ©cĂ©dent)
package app;

import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

public class BasicPalindromeTest {

    @Test
    @DisplayName("Can detect that a word is a palindrome")
    public void testCanDetectPalindrome() throws IllegalArgumentException {
        // Given
        Palindrome palindrome = new BasicPalindrome();

        // When
        boolean result = palindrome.isPalindrome("anna");

        // Then
        assertTrue(result);
    }

    @Test
    @DisplayName("Can detect that a word is not a palindrome")
    public void testCanDetectNotPalindrome() throws IllegalArgumentException {
        // Given
        Palindrome palindrome = new BasicPalindrome();

        // When
        boolean result = palindrome.isPalindrome("john");

        // Then
        assertFalse(result);
    }

    @Test
    @DisplayName("Reject a word with a special character")
    public void testRejectSpecialCharacter() throws IllegalArgumentException {
        // Given
        Palindrome palindrome = new BasicPalindrome();

        // When + Then
        assertThrows(IllegalArgumentException.class, () -> {
            palindrome.isPalindrome("ann4");
        });
    }

}

2.3. Utiliser l’exĂ©cuteur

Une fois les scripts de test codĂ©s et compilĂ©s il faut les confier Ă  l’exĂ©cuteur JUnit. Cela peut se faire de diffĂ©rentes maniĂšres, notamment :

  • Via l’intĂ©gration JUnit au sein d’un IDE comme IntelliJ, qui fournit gĂ©nĂ©ralement un type de lancement particulier associĂ© Ă  une vue graphique qui synthĂ©tise les rĂ©sultats d’exĂ©cution des tests :

    intellij run
  • Via un appel manuel dans un terminal de la mĂ©thode main de la classe org.junit.platform.console.ConsoleLauncher. Pour cela, on peut reposer sur une distribution standalone de JUnit qui prend la forme d’un unique jar que l’on peut appeler de la maniĂšre suivante :

    $ java -jar junit-platform-console-standalone-1.6.0.jar \
        --class-path=<répertoire avec le code> \
        --scan-class-path
  • Ou bien via le plugin Maven SureFire qui se charge de lancer l’exĂ©cuteur JUnit Ă  notre place. Ce plugin est toujours chargĂ© par Maven par dĂ©faut, mais dans une version aujourd’hui dĂ©suĂšte qui dĂ©tecte mal les tests Ă  exĂ©cuter. Il est donc indispensable de tout de mĂȘme dĂ©clarer dans le pom.xml l’usage du plugin maven-surefire-plugin sur une version rĂ©cente. On ajoute donc au pom.xml ceci :

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>3.5.6</version>
            </plugin>
        </plugins>
    </build>

    Ensuite il suffit de lancer :

    mvn test
Si vous utilisez une version de Maven supĂ©rieure ou Ă©gale Ă  3.9.0, alors vous n’avez pas besoin de dĂ©clarer une dĂ©pendance Ă  maven-surefire-plugin, car ce dernier sera automatiquement chargĂ© Ă  la bonne version.

Une fois lancĂ©, le dĂ©roulement d’un appel Ă  l’exĂ©cuteur est le suivant (version simplifiĂ©e):

  1. Toutes les classes dont le nom se termine par Test et contenant des scripts de test (@Test) sont détectées dans le classpath Java et sont mémorisées.

  2. Pour chaque classe de test dĂ©tectĂ©e (dans un ordre indĂ©terminĂ©) :

    1. Pour chaque script de test (mĂ©thode annotĂ©e avec @Test) de la classe de test, et dont le nom commence par test (dans un ordre indĂ©terminĂ©) :

      1. Une instance de la classe de test est créée,

      2. La méthode annotée avec @Test est exécutée,

      3. L’instance de la classe de test est dĂ©truite.

  3. Une synthÚse des résultats est produite et est affichée ou écrite dans un fichier.

Comme une instance de la classe de test est créée pour chaque exĂ©cution d’un test, on peut dĂ©cider d’y stocker des attributs sans risque d’effet de bord entre les diffĂ©rents tests.
Exemple 5. La mĂȘme suite de tests exĂ©cutĂ©e de diffĂ©rentes maniĂšres

Toujours avec la mĂȘme suite de tests, voyons les trois maniĂšres diffĂ©rentes d’exĂ©cuter les scripts de test :

  1. AprĂšs compilation du logiciel (dans un rĂ©pertoire target/classes) et des tests (dans un rĂ©pertoire target/test-classes), on peut appeler l’exĂ©cuteur JUnit manuellement :

    $ java -jar junit-platform-console-standalone-6.1.0.jar \
        execute \
        --class-path=target/classes:target/test-classes \
        --scan-class-path
    
    💚 Thanks for using JUnit! Support its development at https://junit.org/sponsoring
    
    ╷
    ├─ JUnit Platform Suite ✔
    ├─ JUnit Jupiter ✔
    │  ├─ BasicPalindromeTest ✔
    │  │  ├─ Reject a word with a special character ✔
    │  │  ├─ Can detect that a word is a palindrome ✔
    │  │  └─ Can detect that a word is not a palindrome ✔
    └─ JUnit Vintage ✔
    
    Test run finished after 106 ms

    On voit qu’il nous indique que testCanDetectPalindrome et testCanDetectNotPalindrome ont Ă©tĂ© exĂ©cutĂ©s et sont des rĂ©ussites, en affichant chaque script via sa description disponible dans le champ @DisplayName.

  2. Plus couramment, on peut Ă©galement lancer les tests Ă  l’aide du plugin Maven JUnit, qui se charge de lancer l’exĂ©cuteur Ă  notre place :

    $ mvn test
    [INFO] Scanning for projects...
    [INFO]
    [INFO] ----------------------< fr.univnantes:palindrome >----------------------
    [INFO] Building palindrome 1.0-SNAPSHOT
    [INFO] --------------------------------[ jar ]---------------------------------
    
    ... plein de détails sur la compilation ...
    
    [INFO] -------------------------------------------------------
    [INFO]  T E S T S
    [INFO] -------------------------------------------------------
    [INFO] Running app.BasicPalindromeTest
    [INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.034 s - in app.PalindromeTest
    [INFO]
    [INFO] Results:
    [INFO]
    [INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
    [INFO]
    [INFO] ------------------------------------------------------------------------
    [INFO] BUILD SUCCESS
    [INFO] ------------------------------------------------------------------------
    [INFO] Total time: 1.783 s
    [INFO] Finished at: 2020-02-21T10:23:12+01:00
    [INFO] ------------------------------------------------------------------------

    Cette fois on ne voit pas les noms des scripts de test, mais on voit que deux tests ont été exécutés avec succÚs.

  3. Enfin, troisiĂšme option, on peut appeler l’exĂ©cuteur JUnit depuis un IDE comme IntelliJ, et on obtient le rĂ©sultat suivant sous forme graphique :

    intellij junit output

2.3.1. Entrelacer du code au sein des tests pour éviter les redondances

Il arrive frĂ©quemment que les mĂȘme Ă©tapes de prĂ©parations aient lieu d’un scĂ©nario de test Ă  un autre (ex. prĂ©parer des donnĂ©es de test), ou bien qu’une prĂ©paration doive ĂȘtre faite une seule et unique fois avant tous les tests (ex. lancer une base de donnĂ©es).

Pour Ă©viter que de telles redondances apparaissent dans le code des scripts de test, JUnit fournit diffĂ©rentes annotations supplĂ©mentaires permettant d'entrelacer du code entre les exĂ©cutions des scripts de test :

  • Une mĂ©thode statique annotĂ©e avec @BeforeAll sera exĂ©cutĂ©e une seule fois avant tous les scripts de test de sa classe.

  • Une mĂ©thode statique annotĂ©e avec @AfterAll sera exĂ©cutĂ©e une seule fois aprĂšs tous les scripts de test de sa classe.

  • Une mĂ©thode annotĂ©e avec @BeforeEach sera exĂ©cutĂ©e une seule fois avant chaque script de test de sa classe.

  • Une mĂ©thode annotĂ©e avec @AfterEach sera exĂ©cutĂ©e une seule fois aprĂšs chaque script de test de sa classe.

En prenant en compte ces nouvelles annotations, on obtient alors le dĂ©roulement complet lors d’un appel Ă  l’exĂ©cuteur :

  1. Toutes les classes dont le nom se termine par Test et contenant des scripts de test (@Test) sont détectées dans le classpath Java et sont mémorisées.

  2. Pour chaque classe de test dĂ©tectĂ©e (dans un ordre indĂ©terminĂ©) :

    1. Toutes les méthodes statiques annotées avec @BeforeAll sont exécutées (dans un ordre indéterminé).

    2. Pour chaque mĂ©thode annotĂ©e avec @Test de la classe de test, et dont le nom commence par test (dans un ordre indĂ©terminĂ©) :

      1. Une instance de la classe de test est créée.

      2. Toutes les méthodes annotées avec @BeforeEach sont exécutées (dans un ordre indéterminé).

      3. La méthode annotée avec @Test est exécutée

      4. Toutes les méthodes annotées avec @AfterEach sont exécutées (dans un ordre indéterminé).

      5. L’instance de la classe de test est dĂ©truite.

    3. Toutes les méthodes statiques annotées avec @AfterAll sont exécutées (dans un ordre indéterminé).

  3. Une synthÚse des résultats est produite et est affichée ou écrite dans un fichier.

Si vous utilisez Ă©galement l’annotation @Nested pour structurer votre code de test (voir Section 3.1.1), le comportement diffĂšre de la maniĂšre suivante : lors de l’exĂ©cution d’une mĂ©thode situĂ©e dans une classe @Nested, d’abord le @BeforeEach de la classe parente est exĂ©cutĂ©, puis le @BeforeEach de la classe @Nested est exĂ©cutĂ©. Cela peut permettre d’avoir un @BeforeEach spĂ©cifique Ă  un sous-ensemble de mĂ©thodes.

Exemple 6. AmĂ©lioration des scripts de test de l’exemple prĂ©cĂ©dent avec @BeforeEach
package app;

import static org.junit.jupiter.api.Assertions.*;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

public class ImprovedBasicPalindromeTest {

    Palindrome palindrome;

    @BeforeEach
    public void setUp() {
        // Given
        palindrome = new BasicPalindrome();
    }

    @Test
    @DisplayName("Can detect that a word is a palindrome")
    public void testCanDetectPalindrome() throws IllegalArgumentException {
        // When
        boolean result = palindrome.isPalindrome("anna");

        // Then
        assertTrue(result);
    }

    @Test
    @DisplayName("Can detect that a word is not a palindrome")
    public void testCanDetectNotPalindrome() throws IllegalArgumentException {
        // When
        boolean result = palindrome.isPalindrome("john");

        // Then
        assertFalse(result);
    }

    @Test
    @DisplayName("Reject a word with a special character")
    public void testRejectSpecialCharacter() throws IllegalArgumentException {
        // When + Then
        assertThrows(IllegalArgumentException.class, () -> {
            palindrome.isPalindrome("ann4");
        });
    }
}

3. Conseils et bonnes pratiques de développement de tests

3.1. Séparation du code de test

Le code de test d’un logiciel doit ĂȘtre complĂštement sĂ©parĂ© du code du logiciel , soit dans le mĂȘme projet mais dans un rĂ©pertoire Ă  part (comme avec Maven par dĂ©faut) ou bien dans un dĂ©pĂŽt de code dissociĂ© du dĂ©pĂŽt du projet initial. Cette sĂ©paration est nĂ©cessaire pour faciliter l’ajout de nouveaux tests, pour dĂ©coupler totalement le code du logiciel de ses tests et pour faciliter le futur dĂ©ploiement ou conditionnement du projet.

Par défaut, Maven exige déjà que vos tests soit dans le répertoire src/test/java, qui est séparé du répertoire src/main/java contenant les sources du logiciel testé.

En outre, lorsqu’on teste un mĂȘme projet Ă  diffĂ©rents niveaux (unitaire, intĂ©gration, systĂšme, sĂ©curitĂ©, etc.), il est recommandĂ© d’organiser les diffĂ©rents types de test dans des dossiers distincts et explicitement nommĂ©s pour amĂ©liorer la lisibilitĂ© des scĂ©narios de test du logiciel. Ce compartimentage permet Ă©galement de faciliter l’exĂ©cution d’un seul type de test dans le cas de suites de tests trĂšs complĂštes et parfois longues Ă  exĂ©cuter en totalitĂ©.

Pour rappel, on ne s’intĂ©ressera dans ce cours qu’au cas du test unitaire, ce qui signifie que cette sĂ©paration en niveaux ne sera pas demandĂ©e en travaux pratiques.

3.1.1. Organisation du code de test unitaire

Lorsqu’on fait du test unitaire , on prĂ©pare gĂ©nĂ©ralement une suite de tests (elle-mĂȘme sous la forme d’une classe) par classe Ă  tester. Puis, au sein d’une suite de tests, il est recommandĂ© qu’un script de test ne couvre qu'un seul scĂ©nario de test pour faciliter la localisation d’une erreur. Cela signifie que si on a identifiĂ© 7 scĂ©narios de tests pour une mĂ©thode, on produira 7 scripts de test unitaires distincts.

De plus, on rappelle que l’on souhaite tester l’intĂ©gralitĂ© des mĂ©thodes utilisables depuis l’extĂ©rieur d’une classe, donc toutes les mĂ©thodes avec une visibilitĂ© public ou package pour s’assurer de la stabilitĂ© de la spĂ©cification de notre logiciel.

Enfin, pour amĂ©liorer la lisibilitĂ© d’un fichier de test, une bonne pratique consiste Ă  organiser ensemble toutes les mĂ©thodes qui testent la mĂȘme unitĂ© de travail (Ă  avoir une mĂ©thode, dans le contexte du test unitaire). JUnit propose le dĂ©corateur @Nested pour crĂ©er des classes de test internes qui peuvent ĂȘtre utilisĂ©es pour ranger ensemble les tests autour d’une mĂȘme unitĂ© de travail.

Exemple 7. Exemple de proposition d’organisation d’un fichier de test unitaire
public class BaseAppUnitTest {

    @Nested
    @DisplayName("Test method 'incredibleMethod'")
    class TestIncredibleMethod {

        @Test
        @DisplayName("When condition1 expect result1")
        public void TestIncredibleMethodWhenCondition1() {
            ...
        }

        @Test
        @DisplayName("When condition2 expect Exception")
        public void TestIncredibleMethodWhenCondition2() {
            ...
        }
    }

    @Nested
    @DisplayName("Test method 'amazingMethod'")
    class TestAmazingMethod {

        @Test
        @DisplayName("When condition1 and condition2 expect result1")
        public void TestAmazingMethodWhenCondition1AndCondition2() {
            ...
        }

    }
}
Comme sur les scripts de test, il est Ă©galement possible d’ajouter le dĂ©corateur @DisplayName sur une classe interne annotĂ©e avec @Nested pour spĂ©cifier plus en dĂ©tail son contenu.
L’usage de @Nested va influencer le fonctionnement et l’usage de @BeforeEach et de @BeforeAll, voir Section 2.3.1.

3.2. Structurer un script de test en Given-When-Then

Comme mentionnĂ© dans le point prĂ©cĂ©dent, une bonne pratique est de s’assurer que chaque script de test couvre exactement un seul scĂ©nario de test. Cela signifie qu’il est possible de s’aligner directement sur la dĂ©finition d’un scĂ©nario de test pour structurer le contenu d’un script de test.

Pour rappel un scénario de test doit comporter :

  1. Des donnĂ©es d’entrĂ©e,

  2. Une procĂ©dure d’exĂ©cution,

  3. Un oracle.

Une maniĂšre bien connue de reproduire cette structure au sein d’un script de test est le motif Given-When-Then :

  • La partie given (qui peut se traduire en "Ă©tant donnĂ©") dĂ©crit l’environnement avant l’exĂ©cution du comportement vĂ©rifiĂ© par le test − autrement dit les donnĂ©es d’entrĂ©e du scĂ©nario.

  • La partie when (qui peut se traduire en "lorsque") dĂ©crit le dĂ©clenchement du comportement vĂ©rifiĂ© − autrement dit la procĂ©dure d’exĂ©cution du scĂ©nario.

  • La partie then (qui peut se traduire en "alors") dĂ©crit ce qui doit ĂȘtre observĂ© suite Ă  l’exĂ©cution du comportement vĂ©rifiĂ© − autrement dit l'oracle du scĂ©nario.

Pour rendre cette structure explicite, on peut faire apparaßtre ces trois mots dans des commentaires comme dans différents exemples JUnit déjà montrés dans ce chapitre.

3.3. Bien implémenter un oracle de test

Nous avons vu que l’oracle d’un scĂ©nario peut ĂȘtre implĂ©mentĂ©e Ă  l’aide de mĂ©thodes d’assertions fournies par un framework de test. Voici deux conseils supplĂ©mentaires concernant l’implĂ©mentation d’un oracle de test :

  1. Si l’oracle d’un scĂ©nario de test nĂ©cessite de vĂ©rifier des choses diffĂ©rentes au sein des donnĂ©es de sortie, il est tout Ă  fait possible d’utiliser plusieurs assertions au sein d’un mĂȘme script de test − c’est mĂȘme prĂ©fĂ©rable plutĂŽt que d’Ă©crire une seule grande expression avec assertTrue. Par exemple, plutĂŽt que d’Ă©crire :

    assertTrue(list.contains(x) && list.size() == 3);

    il est prĂ©fĂ©rable d’Ă©crire :

    assertTrue(list.contains(x));
    assertEquals(3, list.size());
  2. Afin de permettre au framework de test de produire des rapports de test lisibles et des messages exploitables, il est important de prendre soin d'utiliser la bonne mĂ©thode d’assertion pour une tĂąche donnĂ©e, et d’Ă©viter notamment de reposer trop sur assertTrue ou assertFalse pour des expressions complexes. Par exemple on Ă©vite d’Ă©crire :

    assertTrue(x.equals(y))

    et on préfÚre écrire :

    assertEquals(x, y)

    Et de mĂȘme on Ă©vite d’Ă©crire :

    assertEquals(list1.size(), list2.size());
    for(int i = 0, i< list1.size(); i++) {
        assertEquals(list1.get(i), list2.get(i));
    }

    et on préfÚre écrire :

    assertIterableEquals(list1, list2)

3.4. Bien nommer les variables contenant les donnĂ©es d’entrĂ©e

Un script de test Ă©tant du code (qui devra ĂȘtre lu et maintenu), il est tout Ă  fait pertinent de viser une bonne qualitĂ© de code Ă©galement pour le code de test. L’une des bonnes pratiques rĂ©currentes dans la qualitĂ© de code est de bien nommer les diffĂ©rents Ă©lĂ©ment d’un programme, notamment les variables et attributs.

Il et courant de prĂ©parer des donnĂ©es d’entrĂ©e soit dans dans de variables dans la partie given du script, soit dans de attribut de la classe de test initialisĂ©s au sein d’une mĂ©thode @BeforeEach. Dans les deux cas, un bon nommage sera essentiel pour bien clarifier l’intention de la donnĂ©e de test.

Exemple 8. Nommage de donnĂ©es d’entrĂ©e de PALINDROME

Pour tester la méthode isPalindrome, on peut préparer des variables nommées ainsi :

  • wordWithDigit : un mot avec un chiffre,

  • wordWithSpecialCharacter : un mot avec un caractĂšre spĂ©cial,

  • emptyWord : un mot vide,

  • nullWord : un mot null,

  • validPalindromeWord : un mot valide qui est un palindrome,

  • validNotPalindromeWord : un mot valide qui n’est pas un palindrome.

3.5. Ne jamais utiliser try/catch dans un test

En Java, le compilateur est trĂšs exigeant en ce qui concerne la dĂ©claration et la gestion de exceptions dans le code. Un appel d’une mĂ©thode qui est susceptible d’envoyer une exception doit :

  • soit ĂȘtre Ă  l’intĂ©rieur d’un bloc try/catch,

  • soit se trouver au sein d’une mĂ©thode qui annonce la possibilitĂ© qu’elle envoie une exception, Ă  l’aide du le mot-clĂ© throws.

Lorsqu’on implĂ©mente un script de test, il arrivera le moment oĂč on appelle la mĂ©thode testĂ©e (partie when du test). Or, il est trĂšs courant que la mĂ©thode testĂ©e soit capable d’envoyer des exceptions, et possĂšde donc un throws dans sa signature. Dans ce cas, le compilateur Java refusera de compiler le script de test, mĂȘme si aucune exception n’est censĂ©e se produire dans le scĂ©nario de test souhaitĂ©.

Supposons qu’on souhaite tester la mĂ©thode methodWithException suivante :

public class MyClass {
    public boolean methodWithException throws ExampleException {
        

    }
}

On implĂ©mente ensuite le script de test suivant, dont le code ne compile pas car la rĂšgle Java rappelĂ©e plus haut n’est pas respectĂ©e :

@Test
public void testException_doesNotCompile() {
    // Given
    MyClass myObject = new MyClass();
    // When
    boolean result = myObject.methodWithException(); // Cette ligne ne compile pas !
    // Then
    assertTrue(result);
}

Pour satisfaire le compilateur, une premiÚre solution est de mettre notre appel de méthode dans un bloc try/catch de la sorte, en prenant soin de faire échouer le test avec fail() si une exception apparaßt :

@Test
public void testException_bad() {
    // Given
    MyClass myObject = new MyClass();
    // When
    try {
        boolean result = myObject.methodWithException();
        // Then
        assertTrue(result);
    } catch (ExampleException e) {
        fail();
    }
}

Bien que dĂ©sormais cela compile et fonctionne, le code source du script s’alourdit et perd en lisibilitĂ©. C’est donc une solution Ă  Ă©viter autant que possible.

Une alternative beaucoup plus simple est d'ajouter un throws Ă  la signature du script de test afin de dĂ©clarer que le script de test est susceptible d’envoyer une exception :

@Test
public void testException_good() throws ExampleException {
    // Given
    MyClass myObject = new MyClass();
    // When
    boolean result = myObject.methodWithException();
    // Then
    assertTrue(result);
}

Cette solution est largement prĂ©fĂ©rable car permet au code d’ĂȘtre plus lĂ©ger et lisible. C’est Ă©galement cohĂ©rent avec ce qui est souhaitĂ©, car si il s’avĂšre que methodWithException produise bel et bien une exception durant l’exĂ©cution du script (alors que le scĂ©nario stipule qu’il ne devrait pas y avoir d’exception), JUnit va recevoir cette exception et automatiquement considĂ©rer que le rĂ©sultat du test est une erreur, et donc que le test ne passe pas. On aura donc avec succĂšs observĂ© une dĂ©faillance.

3.6. Isolation et indépendance des tests

Tous les scripts de test (unitaire et autres) doivent pouvoir ĂȘtre exĂ©cutĂ©es dans n’importe quel ordre, de maniĂšre indĂ©pendante et fournir un rĂ©sultat constant (toujours le mĂȘme, quel que soit l’environnement d’exĂ©cution).

Pour s’assurer de l’isolation et de l’indĂ©pendance des scripts de test d’une suite, il est possible de modifier les paramĂštres de l’exĂ©cuteur pour rendre l’ordre de lancement des tests alĂ©atoire. Avec le framework JUnit, on peut modifier l’ordre d’exĂ©cution des tests en utilisant le dĂ©corateur @TestMethodOrder. Pour rendre l’exĂ©cution alĂ©atoire sur une classe de test :

@TestMethodOrder(MethodOrderer.Random.class)
public class BaseAppUnitTest {
    ...
}

3.7. Le test : un outil de documentation du code

Au mĂȘme titre que la documentation interne des mĂ©thodes d’une classe ou d’une interface, les fichiers de test d’un projet peuvent ĂȘtre considĂ©rĂ©s comme une source de documentation technique fiable pour les dĂ©veloppeur⋅euse⋅s. Il est parfois intĂ©ressant d’aller consulter les cas de tests d’une bibliothĂšque ou d’un projet pour s’imprĂ©gner de l’implĂ©mentation de ses mĂ©thodes ou pour avoir plus d’informations sur l’utilisation de ses fonctionnalitĂ©s.

En consĂ©quence, la lisibilitĂ©, la clartĂ© et la simplicitĂ© des tests sont des critĂšres importants Ă  prendre en compte lors de l’implĂ©mentation d’une suite de tests sur un projet.

3.8. Pour aller plus loin : intégration continue et dépendances externes

Pour terminer ce chapitre, quelques points supplémentaires avancés et optionnels pour aller plus loin :

  • Si un processus d’intĂ©gration continue est en place dans un projet logiciel, alors en principe tous les tests unitaires seront rĂ©-exĂ©cutĂ©s automatiquement dans un environnement d’intĂ©gration au fur et Ă  mesure des changements enregistrĂ©s sur le rĂ©fĂ©rentiel de code (Git, gĂ©nĂ©ralement). Cela permet d’assurer de maniĂšre trĂšs lisible la qualitĂ© et la non-rĂ©gression du code, et in fine la qualitĂ© de la livraison du logiciel dĂ©veloppĂ©.

  • Dans la plupart des cas, il est recommandĂ© de ne pas livrer le code des tests avec le code de l’applicatif lors de son dĂ©ploiement par gain de place, sĂ©curitĂ© et obfuscation du fonctionnement d’un projet. Il arrive cependant que certaines bibliothĂšques externes livrent leurs tests dans leurs paquets par soucis de documentation Ă  destination de leurs utilisateurs et pour leur permettre de relancer une suite de test complĂšte en cas de modifications de la bibliothĂšque.

  • On peut considĂ©rer que les fonctionnalitĂ©s du langage de programmation utilisĂ© (bibliothĂšque standard, types primitifs, etc.) comme dĂ©jĂ  testĂ©es et ne devant pas ĂȘtre re-testĂ©es dans un projet. On considĂšre Ă©galement les bibliothĂšques externes utilisĂ©es dans un projet logiciel comme stables et testĂ©es dans leurs dĂ©pĂŽts respectifs. Par consĂ©quent, seul le code du projet en dĂ©veloppement doit ĂȘtre testĂ©. À ce titre, il est prĂ©fĂ©rable de choisir des bibliothĂšques externes dĂ©jĂ  Ă©prouvĂ©es et dĂ©jĂ  testĂ©es avec une qualitĂ© des tests satisfaisante pour assurer la stabilitĂ© d’un projet.

Le taux de couverture de test d’une bibliothĂšque externe est dans la plupart des cas consultable directement dans le README du dĂ©pĂŽt Git de la lib ou sur la page descriptive hĂ©bergĂ©e sur les bases de donnĂ©es de paquets publiques (npmjs, pypi, …​).