đ 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. |
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 :
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.).
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.
âââ 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@DisplayNamepour dĂ©crire l’objectif du test.
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
|
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 :
-
Via un appel manuel dans un terminal de la méthode
mainde la classeorg.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.xmll’usage du pluginmaven-surefire-pluginsur une version rĂ©cente. On ajoute donc aupom.xmlceci :<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):
-
Toutes les classes dont le nom se termine par
Testet contenant des scripts de test (@Test) sont détectées dans le classpath Java et sont mémorisées. -
Pour chaque classe de test détectée (dans un ordre indéterminé) :
-
Pour chaque script de test (méthode annotée avec
@Test) de la classe de test, et dont le nom commence partest(dans un ordre indéterminé) :-
Une instance de la classe de test est créée,
-
La méthode annotée avec
@Testest exécutée, -
L’instance de la classe de test est dĂ©truite.
-
-
-
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. |
Toujours avec la mĂȘme suite de tests, voyons les trois maniĂšres diffĂ©rentes d’exĂ©cuter les scripts de test :
-
AprÚs compilation du logiciel (dans un répertoire
target/classes) et des tests (dans un rĂ©pertoiretarget/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 msOn voit qu’il nous indique que
testCanDetectPalindromeettestCanDetectNotPalindromeont été exécutés et sont des réussites, en affichant chaque script via sa description disponible dans le champ@DisplayName. -
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.
-
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 :
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
@BeforeAllsera exécutée une seule fois avant tous les scripts de test de sa classe. -
Une méthode statique annotée avec
@AfterAllsera exécutée une seule fois aprÚs tous les scripts de test de sa classe. -
Une méthode annotée avec
@BeforeEachsera exécutée une seule fois avant chaque script de test de sa classe. -
Une méthode annotée avec
@AfterEachsera 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 :
-
Toutes les classes dont le nom se termine par
Testet contenant des scripts de test (@Test) sont détectées dans le classpath Java et sont mémorisées. -
Pour chaque classe de test détectée (dans un ordre indéterminé) :
-
Toutes les méthodes statiques annotées avec
@BeforeAllsont exécutées (dans un ordre indéterminé). -
Pour chaque méthode annotée avec
@Testde la classe de test, et dont le nom commence partest(dans un ordre indéterminé) :-
Une instance de la classe de test est créée.
-
Toutes les méthodes annotées avec
@BeforeEachsont exécutées (dans un ordre indéterminé). -
La méthode annotée avec
@Testest exécutée -
Toutes les méthodes annotées avec
@AfterEachsont exécutées (dans un ordre indéterminé). -
L’instance de la classe de test est dĂ©truite.
-
-
Toutes les méthodes statiques annotées avec
@AfterAllsont exécutées (dans un ordre indéterminé).
-
-
Une synthÚse des résultats est produite et est affichée ou écrite dans un fichier.
|
Si vous utilisez Ă©galement l’annotation |
@BeforeEachpackage 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.
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 :
-
Des donnĂ©es d’entrĂ©e,
-
Une procĂ©dure d’exĂ©cution,
-
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 :
-
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()); -
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
assertTrueouassertFalsepour 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.
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 motnull, -
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
|
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.