TP4 - Les tests unitaires⚓︎
Source : Wikipedia
Ce qui suit est fortement insipiré de cette page.
1. Présentation⚓︎
Un test unitaire (TU ou UT en anglais) consiste à isoler une partie du code et à vérifier qu'il fonctionne parfaitement. Il s'agit de petits tests qui valident l'attitude d'un objet et la logique du code.
Dans les applications non critiques, l'écriture des TU a longtemps été considérée comme une tâche secondaire. Cependant, les méthodes Extreme programming (XP) ou Test Driven Development (TDD) ont remis les tests unitaires, aussi appelés « tests du programmeur », au centre de l'activité de programmation.
Les TU sont encore considéré comme une perte de temps par certain développeurs. Mais ils permettent en fait un gain de temps, d'énergie et d'argent vraiment important. Ils sont un point central dans toutes les méthodes modernes de developpement.
2. Utilité⚓︎
On écrit un test pour confronter une réalisation à sa spécification. Il permet de vérifier que pour un certain nombre de données en entrée, le résultat en sortie est bien celui attendu. Il a principalement trois utilités :
- Trouver les erreurs rapidement
La méthode XP préconise d'écrire les tests en même temps, ou même avant la fonction à tester (Test Driven Development). Ceci permet de définir précisément l'interface du module à développer. Les tests sont exécutés durant tout le développement, permettant de visualiser si le code fraîchement écrit correspond au besoin. - Sécuriser la maintenance
Lors d'une modification d'un programme, les tests unitaires signalent les éventuelles régressions. En effet, certains tests peuvent échouer à la suite d'une modification, il faut donc soit réécrire le test pour le faire correspondre aux nouvelles attentes, soit corriger l'erreur se situant dans le code.
Ceci est un problème important dans le developpement de programme de grande taille où de nombreux développeurs interviennent. Il arrive fréquemment qu'une modification anodine du code existant, pour permettre l'ajout d'une nouvelle fonctionnalité par exemple, impacte sans qu'on s'en rende compte une ancienne fonctionnalité, qui présente alors des bugs.
L'absence de tests unitaires automatisés rend très difficile la découverte de ces régressions avant le déploiement de l'application. - Documenter le code
Les tests unitaires peuvent servir de complément à l'API, il est très utile de lire les tests pour comprendre comment s'utilise une méthode. De plus, il est possible que la documentation ne soit plus à jour, mais les tests eux correspondent nécessairement à la réalité de l'application.
3. Fonctionnement⚓︎
On définit généralement 4 phases dans l'exécution d'un test unitaire :
- Initialisation (fonction
setUp
) : définition d'un environnement de test complètement reproductible. - Exercice : le module à tester est exécuté.
- Vérification (utilisation de fonctions
assert
) : comparaison des résultats obtenus avec un vecteur de résultat défini. Ces tests définissent le résultat du test : succès ou échec. - Désactivation (fonction
tearDown
) : désinstallation de ce qui a éventuellement pu être mis en place dans l'initialisation, pour retrouver l'état initial du système, dans le but de ne pas polluer les tests suivants. Tous les tests doivent être indépendants et reproductibles unitairement (quand exécutés seuls).
Une classe de test comporte éventuellement une méthode setUp
, éventuellement une méthode tearDown
, et autant de méthodes test
(c'est-à-dire les phases 2 et 3 ci-dessus) que souhaité. Chacune des méthodes test
doit être indépendante des autres, et elles doivent pouvoir être exécutées dans n'importe quel ordre. Les méthodes setUp
et tearDown
sont appellées respectivement avant et après chaque appel d'une méthode test
. Les méthodes setUp
et tearDown
ne doivent donc être implémentées que si toutes les méthodes de test ont besoin d'exécuter un même traitement avant et après le test. On peut par exemple penser à la connexion à une base de données (dans la méthode setUp
) qui doit être fermée à la fin de chaque test (dans la méthode tearDown
).
Pour aller plus loin - Utilisation de mocks
Dans une application avec une architecture complexe, dans laquelle il y a de nombreuses dépendances entre les différents objets, il devient compliqué d'écrire des tests unitaires permettant de ne tester qu'une seule fonctionnalité, isolée des autres.
Il existe des outils permettant de simuler une dépendance (c'est-à-dire un autre objet) de manière contrôlée : les mocks.
Vous trouverez un début d'explication sur la page Wikipedia.
Le framework java mockito permet d'utiliser des mocks dans les TU.
4. Installation⚓︎
Nous utiliserons l'IDE eclipse pour les développements.
Nous allons continuer d'utiliser le projet Java TPCollections
créé dans le TP précédent. Dans ce projet, à la racine, créer un dossier 📂lib
, dans lequel nous placerons les dépendances du projet (les bibliothèques externes, sous forme de JAR).
Nous allons utiliser l'API JUnit pour mettre en place les tests unitaires en Java.
Deux versions de JUnit sont utilisées, la 4 et la 5. Nous utiliserons d'abord la 4, qui est plus simple à configurer manuellement.
Lorsqu'on doit utiliser une API, on peut (souvent) trouver les JAR correspondant dans le dépôt maven, présent ici. Une recherche de junit
affiche les différentes API disponibles. Celle qui nous interesse, qui correspond à JUnit4, est celle appelée JUnit
(le deuxième résultat normalement). Lorsqu'on clique sur ce résultat, on obtient la liste des différentes versions disponibles. On peut cliquer sur la dernière (la 4.13.2), et il est alors possible de cliquer sur jar pour le télécharger.
Mais l'utilisation d'un JAR nécessite parfois la présence d'autres JARs. Maven permet de trouver ces dépendances. En descendant un peu, dans la partie Compile Dependencies, on trouve :
Il faut donc cliquer sur ce lien, et télécharger la version 1.3 de cette API.
Ce JAR n'a pas de dépendance, on peut donc s'arrêter là.
Il ne reste maintenant plus qu'à associer ces deux bibliothèques au projet :
- Déplacer les deux JAR dans le dossier 📂
lib
créé juste précédemment. - Ajouter ces deux JAR dans le classpath du projet. Pour cela, accéder au propriétés du projet, aller dans
Java Build Path > Libraries > Classpath
. Cliquer sur le boutonAdd JARs... , sélectionner les 2 JAR, puis cliquer surOk etApply and Close .
5. Fonctionnement avec JUnit⚓︎
Avec JUnit3, une classe de test devait hériter de junit.framework.TestCase
, et il fallait redéfinir les méthodes setUp
et tearDown
pour définir des traitements exécutés systématiquement avant et après chaque cas de tests.
JUnit4 propose simplement d'annoter la méthode exécutée avant avec l'annotation @Before
et la méthode exécutée après avec l'annotation @After
. Chaque cas de test correspond à une méthode annotée avec @Test
. La classe de test n'a plus à hériter de le classe TestCase
.
6. Exercices⚓︎
Exercice 1 - Prise en main
Dans cet exercice, nous allons reprendre ce que nous avions développé dans le projet précédent, en automatisant les tests.
Nous allons donc créer une classe TestNotes
, qui contiendra nos jeux de tests sur la classe Notes
. Comme dans le TP précédent, cette classe sera dans le même package que les classes qu'elle permet de tester, c'est-à-dire fr.univtours.polytech.tpcollections
. Cependant, contrairement à ce que nous avons fait dans le TP précédent, il est préférable de ne pas mélanger la partie "développement de l'application" et la partie "test". Pour cela, nous allons créer un deuxième dossier de sources (Source Folder
), nommé test
, qui contiendra également le package fr.univtours.polytech.tpcollections
.
Voici donc à quoi doit maintenant ressembler l'arborescence du projet :
TPCollections/ ├── src/ │ └── fr/ │ └── univtours/ │ └── polytech/ │ └── tpcollections/ │ ├── Notes.java │ ├── Student.java │ ├── Subject.java │ └── YearGroup.java ├── test/ │ └── fr/ │ └── univtours/ │ └── polytech/ │ └── tpcollections/ │ └── TestNotes.java └── lib/ ├── hamcrest-core-1.3.jar/ └── junit-4.13.2.jar/
Nous allons tout d'abord tester la méthode addNotes()
. Nous allons vérifier que si nous appelons deux fois cette méthode, l'objet contient bien 2 notes ensuite.
Chaque test unitaire est indépendant des autres. Cela signifie que toutes les méthodes annotées @Test
sont exécutées dans n'importe quel ordre, et ne doivent pas avoir de dépendances entre elle.
Ici, il faut donc définir le contexte, c'est-à-dire les objets nécessaires au test, dans le test unitaire :
@Test
public void testAddNote() {
Notes notes = new Notes();
notes.addNote(15D);
notes.addNote(16D);
//(1)!
assertEquals(2, notes.getNotesList().size());
}
- Ici, on a défini le contexte de notre test.
Enfin, il ne nous reste plus qu'à exécuter le test. Pour cela, dans l'arborescence du projet, faire un clic droit sur la classe, puis Run as > JUnit Test
.
Nommage de tests unitaires
Il existe différentes règles pour nommer les méthodes de tests. Pour l'instant, on peut utiliser celle-ci : test[nomMéthode]_[leCasTesté]_[throwsException]
.
Un onglet Junit apparaît, indiquant le nombre de tests effectués, le nombre d'échecs (la méthode s'est exécutée sans erreur, mais n'a pas renvoyé ce qui était prévu) et le nombre d'erreurs (la méthode a levé une exception).
On peut ajouter autant de tests unitaires que souhaité, c'est-à-dire de méthode avec l'annotation @Test
. Par exemple, on peut également tester la méthode computeMean()
de la classe Notes
. A nouveau, il faut commencer par définir le contexte :
@Test
public void testComputeMean() {
Notes notes = new Notes();
notes.addNote(15D);
notes.addNote(16D);
assertEquals(15.5D, notes.computeMean());
}
Ambigous method
Dans le cas présent, il existe deux méthodes assertEquals
aux signatures proches qui peuvent être exécutées : assertEquals(double, double)
et assertEquals(Object, Object)
.
Le code assertEquals(notes.computeMean(), 15.5D)
va générer l'erreur suivante : The method assertEquals(Object, Object) is ambiguous for the type TestTP
. Cela signifie que le compilateur ne sait pas laquelle des deux méthodes utiliser. Il faut donc être plus précis. Pour comparer deux double
, il faut donc écrire, au choix :
assertEquals(Double.valueOf(15.5D), notes.computeMean())
: la méthodeassertEquals(Object, Object)
est utilisée.assertEquals(15.5D, notes.computeMean().doubleValue())
: la méthodeassertEquals(double, double)
est utilisée. Dans ce cas, on constate que la méthode est obsolète (deprecated), donc on utilisera plutôt la première solution.
Lorsqu'on exécute la classe de test, on observe qu'il y a maintenant deux tests.
Ici, on constate que le contexte est exactement le même pour les deux tests. Il est possible de le mutualiser. Pour cela, nous allons ajouter une méthode avec l'annotation @Before
, ce qui signifie qu'elle est appelée avant l'exécution de chaque test.
Notre classe de test devient donc :
private Notes notes;
@Before
public void setUp() {
this.notes = new Notes();
this.notes.addNote(15D);
this.notes.addNote(16D);
}
@Test
public void testAddNote() {
assertEquals(2, this.notes.getNotesList().size());
}
@Test
public void testComputeMean() {
assertEquals(Double.valueOf(15.5D), this.notes.computeMean());
}
Exercice 2 - Tester la survenue d'une exception
Lors d'une développement d'une application, on souhaite souvent tester qu'une exception est bien levé dans une situation donnée.
JUnit permet de faire cela.
Supposons par exemple qu'on souhaite lever une ArithmeticException
lorsqu'on souhaite calculer une moyenne alors qu'aucune note n'a été saisie (on fait en effet une division par 0 dans ce cas). Nous allons donc modifier la méthode computeMean()
de la classe fr.univtours.polytech.tpcollections.Notes
:
public Double computeMean() {
// S'il n'y a pas de note ...
if (this.notesList.size() == 0) {
// ... on lève une exception.
throw new ArithmeticException();
}
Double mean = 0D;
for (Double note : this.notesList) {
mean += note;
}
return mean / this.notesList.size();
}
Tester la survenue d'une exception
On peut tester que l'appel de la méthode doStuff
lève bien l'exception ExpectedException
en utilisant des expressions lambda. On utilise pour cela le code assertThrows(ExcpectedException.class, () -> doStuff());
.
En respectant le point ci-dessus, pour tester la survenue de l'exception, et en respectant la règle de nommage décrite plus haut, nous allons ajouter la méthode suivante à notre classe de test :
@Test
public void testComputeMean_pasDeNote_throwsArithmeticException() {//(1)!
this.notes = new Notes();
assertThrows(ArithmeticException.class, () -> this.notes.computeMean());
}
- Le nom de la méthode respecte ce qui a été précisé plus haut.
Tester son bon fonctionnement.
Exercice 3 - Exécution de tous les tests unitaires d'un projet
On peut créer autant de classes de test que souhaité.
Un des (nombreux) intérêts de JUnit est qu'elles peuvent facilement être toutes exécutées simultanément.
Nous allons créer une deuxième classe de test, pour tester la classe YearGroup
(ce sont en fait les tests que nous avions mis dans la classe TestNotesFinal
dans le TP3). Nous allons donc appeler cette classe TestYearGroup
.
Créons donc la classe fr.univtours.polytech.tpcollections.TestYearGroup
:
Le code de TestYearGroup
public class TestYearGroup {
private YearGroup yearGroup;
private Subject info;
private Subject maths;
private Student alice;
private Student bob;
private Student charlie;
@Before
public void setUp() {
// Création de deux matières :
this.info = new Subject("Informatique", 1D);
this.maths = new Subject("Mathématiques", 2D);
// Création de 3 étudiants :
this.alice = new Student("Alice");
this.bob = new Student("Bob");
this.charlie = new Student("Charlie");
List<Student> students = new ArrayList<Student>();
students.add(alice);
students.add(bob);
students.add(charlie);
// Création de la promotion :
this.yearGroup = new YearGroup();
yearGroup.setYear(2022);
yearGroup.setStudents(students);
// Alice a 14 en info et 20 en maths.
Notes aliceEnInfo = new Notes();
aliceEnInfo.addNote(14D);
alice.getNotesList().put(info, aliceEnInfo);
Notes aliceEnMaths = new Notes();
aliceEnMaths.addNote(20D);
alice.getNotesList().put(maths, aliceEnMaths);
// Bob a 16 en info et 10 en maths.
Notes bobEnInfo = new Notes();
bobEnInfo.addNote(16D);
bob.getNotesList().put(info, bobEnInfo);
Notes bobEnMaths = new Notes();
bobEnMaths.addNote(10D);
bob.getNotesList().put(maths, bobEnMaths);
// Charlie a 15 en info et 15 en maths.
Notes charlieEnInfo = new Notes();
charlieEnInfo.addNote(15D);
charlie.getNotesList().put(info, charlieEnInfo);
Notes charlieEnMaths = new Notes();
charlieEnMaths.addNote(15D);
charlie.getNotesList().put(maths, charlieEnMaths);
}
@Test
public void testComputeGroupMean() {
assertEquals(Double.valueOf(15D), this.yearGroup.computeGroupMean());
}
@Test
public void testComputeSubjectMean() {
assertEquals(Double.valueOf(15D), this.yearGroup.computeSubjectMean(this.maths));
assertEquals(Double.valueOf(15D), this.yearGroup.computeSubjectMean(this.info));
}
@Test
public void testGetGroupRanking_premier() {
assertEquals(this.alice, this.yearGroup.getGroupRanking().get(0));
}
@Test
public void testGetGroupRanking_dernier() {
int nbOfStudents = this.yearGroup.getStudents().size();
assertEquals(this.bob, this.yearGroup.getGroupRanking().get(nbOfStudents - 1));
}
@Test
public void testComputeGetBestNote() {
assertEquals(Double.valueOf(20D), this.yearGroup.getBestNote(this.maths));
assertEquals(Double.valueOf(16D), this.yearGroup.getBestNote(this.info));
}
@Test
public void testComputeGetWorseNote() {
assertEquals(Double.valueOf(10D), this.yearGroup.getWorseNote(this.maths));
assertEquals(Double.valueOf(14D), this.yearGroup.getWorseNote(this.info));
}
}
Pour exécuter tous les tests unitaires d'un projet, ll suffit alors de faire un clic droit sur le projet, et de l'exécuter en tant que "JUnit Test".
Exercice 4 - L'horloge
Le but est ici de simuler une horloge, qui indique donc une heure donnée (heure, minute et seconde), et à laquelle on peut ajouter autant de secondes que souhaité.
Voici le diagramme de cette classe :
classDiagram
Horloge
class Horloge{
-heures: Integer
-minutes: Integer
-secondes: Integer
+Horloge(heures: Integer, minutes: Integer, secondes: Integer)
+getHeures() Integer
+getMinutes() Integer
+getSecondes() Integer
+addSecondes(secondes: Integer)
}
Test Driven Development
Dans cet exercice, nous allons utiliser la méthode de développement Test Driven Development, ou développements pilotés par les tests en français.
Cela signifie que nous allons d'abord écrire les tests, avant d'écrire le code de l'horloge.
Ainsi, les tests échouent au début, et doivent tous réussir au fur et à mesure des développements.
- Implémenter la classe
fr.univtours.polytech.horloge.Horloge
, sauf la méthodeaddSeconde
qui pour l'instant ne fait rien. - Implémenter les tests avec JUnit, et vérifier qu'ils ne passent pas pour le moment.
- Implémenter la méthode
addSeconde
de la classefr.univtours.polytech.horloge.Horloge
. - Vérifier que tous les tests passent correctement.
Pour aller plus loin
L'appel de la méthode addSeconde
avec un argument négatif (par exemple -3600
) donne-t-il le résultat attendu ? Aviez-vous prévu ce test dès la question 2 ?
7. Introduction à Maven⚓︎
Nous allons maintenant utiliser JUnit5. Nous n'allons pour cela pas télécharger les JARs à la main, car il y a de nombreuses dépendances! Il faudrait en télécharger 12 rien que pour la version de base. Nous allons utiliser maven, qui va gérer automatiquement toutes ces dépendances pour nous.
Maven est un outil de gestion de projet développé par la fondation Apache. Il permet d'automatiser de nombreuses tâches.
Pour cela, dans eclipse, nous allons créer un projet maven (et non un projet java comme précédemment). Il faut cocher la case "Create à simple project (skip archetype selection)" :
Un projet maven est identifié par 3 informations :
- Un
groupId
: c'est l'identifiant de l'entité (entreprise, association, ...) propriétaire du projet. Ce sont souvent les 2 ou 3 premiers niveaux des packages Java.
NotregroupId
sera icifr.univtours.polytech
. - Un
artifactId
: c'est l'identifiant du projet. C'est souvent le niveau suivant des packages de l'application. C'est le nom du projet dans eclipse.
NotreartifactId
seratpjunit5
. - Un numéro de version. Une version en cours de développement est suffixée par
-SNAPSHOT
. Ce sont des versions intermédiaires de travail en local.
Le numéro de version par défaut à la cration d'un projet Maven est0.0.1-SNAPSHOT
.
À la création d'un projet Maven, un fichier 📄pom.xml
est créé. C'est ici que nous allons pouvoir tout paramétrer, notamment l'ajout des dépendances.
L'arborescence d'un projet Maven
Voici l'arborescence d'un projet Maven :
ProjetMaven/ ├── src/ │ ├── main/ │ │ ├── java/ │ │ │ └── ici, il y a les packages (qui sont des dossiers) et les classes Java. │ │ └── resources/ │ │ └── ici, il y a les les fichiers .properties, .xml ... │ ├── test/ │ │ ├── java/ │ │ │ └── ici, il y a les packages et les classes Java pour les tests unitaires. │ │ └── resources/ │ │ └── ici, il y a les fichiers .properties, .xml ... pour les tests unitaires. │ └── target/ │ └── ici, il y a les classes compilées et les ressources. └── pom.xml └── C'est le fichier qui permet de configurer la gestion du projet.
Cela permet d'avoir une classe et la classe de test correspondante dans le même package, mais stockées dans deux endroits différents dans l'application. La première est dans le dossier 📂main
, alors que la seconde est dans 📂test
.
À la création du projet, le 📄pom.xml
contient uniquement :
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>fr.univtours.polytech</groupId>
<artifactId>tpjunit5</artifactId>
<version>0.0.1-SNAPSHOT</version>
</project>
Par défaut, la version du compilateur Java utilisée par Maven est la 1.5. Comme nous souhaitons utiliser la 1.8, il faut l'indiquer. Pour cela, il faut ajouter une balise <properties>
(fille de la balise <project>
) :
<properties>
<maven.compiler.target>1.8</maven.compiler.target>
<maven.compiler.source>1.8</maven.compiler.source>
</properties>
La balise <maven.compiler.source>
permet de préciser la version de Java avec laquelle le code source est compatible. La balise <maven.compiler.target>
permet de préciser la version de Java qui sera utilisée pour générer le bytecode.
On peut maintenant ajouter les dépendances au projet, c'est-à-dire JUnit. On utilise pour cela une balise <dependencies>
(fille de la balise <project>
), contenant autant de <dependency>
que souhaité. Chaque dépendance est identifié par le groupId
, l'artifactId
et la version
. Il faut également ajouter une 4ème information, qui est la portée.
La portée d'un module avec maven
compile
: la dépendance est utilisable par toutes les phases et à l'exécution. C'est la portée par défautprovided
: la dépendance est utilisée pour la compilation mais elle ne sera pas déployée car elle est considérée comme étant fournie par l'environnement d'exécution. C'est par exemple le cas des API fournies par un serveur d'applicationsruntime
: la dépendance n'est pas utile pour la compilation mais elle est nécessaire à l'exécution. C'est par exemple le cas des pilotes JDBC (pour la connexion à une base de données).test
: la dépendance n'est utilisée que lors de la compilation et de l'exécution des tests. C'est le cas ici, pour JUnit.
Ajout de dependencies
Pour ajouter des dépendances, on peut utiliser le repository de maven. Pour cela, on recherche sur https://mvnrepository.com/ le module qui nous interesse.
Par exemple, en cherchant junit
et en sélectionnant le deuxième résultat (qui correspond à JUnit4), comme nous l'avons fait plus tôt, puis en sélectionnant la dernière version, il suffit de copier le code indiqué et de le coller dans le 📄pom.xml
à l'intérieur de la balise <dependencies>
, c'est-à-dire :
<!-- https://mvnrepository.com/artifact/junit/junit -->
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.13.2</version>
<scope>test</scope>
</dependency>
JUnit5
Pour utiliser JUnit5, il faut ajouter deux dépendances : junit-jupiter-engine
et junit-platform-runner
.
Finalement, le 📄pom.xml
ressemble à cela :
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>fr.univtours.polytech</groupId>
<artifactId>tpjunit5</artifactId>
<version>0.0.1-SNAPSHOT</version>
<properties>
<maven.compiler.target>1.8</maven.compiler.target>
<maven.compiler.source>1.8</maven.compiler.source>
</properties>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>5.8.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.platform</groupId>
<artifactId>junit-platform-runner</artifactId>
<version>1.8.2</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>
Exercice 4 - Maven & JUnit5
Refaire le projet horloge avec JUnit5. Le code de test sera le même, mais attention, les imports ont changés :
- L'annotation
Test
vient maintenant de la classeorg.junit.jupiter.api.Test
et non plus deorg.junit.Test
. - La méthode
assertEquals
vient de la classeorg.junit.jupiter.api.Assertions
et non plus de la classeorg.junit.Assert
.
L'annotation @Before
doit être remplacée par @BeforeEach
, et @After
doit être remplacée par @AfterEach
.
Pour mettre à jour les imports, il suffit de les supprimer, puis de faire Ctrl+Shift+O (dans eclipse), et de sélectionner les bonnes classes.