Les tests unitaires avec JUnit sous NetBeans
1. Sensibilisation sur l'intérêt des tests unitaires
Très souvent en java une erreur dans un programme subsiste et il est difficile de déterminer sa provenance. L'algorithme est juste, la cascade d'appels de méthodes, d'instanciation d'objet marchent, il n'y a pas d'exception qui apparaît. Et pourtant ça ne marche pas. Le code fait mille lignes ou plus, il est complexe et une méthode au moins est erronée. Difficile de déterminer laquelle.
Les tests sont faits pour cela. Ils nous aident à définir où est le problème.
Il existe plusieurs types de tests :
Ø Les tests d'intégration : le programme créé s'intègre-t-il bien dans son environnement d'exécution ?
Ø Les tests d'acceptation : l'utilisateur final accepte-t-il le logiciel ?
Ø Les tests unitaires : destinés à tester une unité du logiciel.
Ce sont ces derniers qui nous intéresseront et les unités que nous allons tester seront les méthodes de nos classes.
Exemple sur une méthode simple :
String concatene(String a, String b)
{
...
}
Nous voulons tester si elle concatène bien les deux chaînes a et b. La première méthode est de la tester dans notre programme (test perso), on appelle cette méthode dans le main avec deux chaînes et on affiche le résultat. Cette méthode est peu efficace et à bannir (voir tableau suivant)
L'autre méthode consiste à créer une classe dédiée à ce test, c'est le test unitaire.
|
Test unitaire |
Test perso |
reproductible |
oui |
non |
compréhensible |
oui |
non |
documenté |
oui |
non |
conclusion |
bon pour le service |
à bannir |
Toutefois pour être intéressant et utile le test unitaire doit être simple et facile à écrire.
On va créer une classe de test par classe à tester. Dans chaque classe de test, il y aura une méthode par méthode à tester. Donc avec JUnit pour chaque classe du logiciel il y aura une classe de test.
L'objectif est de trouver un maximum d'erreurs, il est utopique d'envisager avec ces tests de les trouver toutes,
Voici un exemple de test. Cet exemple ne respecte pas pour l'instant les notations que nous allons utiliser plus loin dans ce chapitre :
public boolean concateneTest()
{
MyString classATester = new MyString();
String a = "salut les ";
String b = "zeros";
String resultatAttendu = "salut les zeros";
String resultatObtenu = classATester.concatene(a, b);
if (resultatAttendu.compareTo(resultatObtenu) == 0)
{
return true;
}
else
{
return false;
}
}
Insuffisances de ce type de test :
· Le test ne dit pas quelle est l'erreur, il dit seulement qu'il y en a une;
· Le test ne corrige pas l'erreur;
· Ce n'est pas parce que le test passe qu'il n'y a pas d'erreur;
· Ce n'est pas parce que l'on corrige l'erreur qu'il n'y en a plus.
2. Mise en pratique avec JUnit
IDE : NetBeans 7.2
Organisation :
Comme indiqué dans la première partie chaque classe à tester aura sa "classe sœur".
Pour simplifier la maintenance du code, nous allons créer deux packages principaux : main et test.
Dans main, il y aura toutes nos classes et dans test, nos classes de test.
Exemple support :
Nous allons développer une classe qui permettra de calculer le résultat d'opérations mathématiques de base. Voici l'interface de la classe :
package main;
public interface Calculator
{
int multiply(int a, int b);
int divide(int a, int b);
int add(int a, int b);
int substract(int a, int b);
}
Cette interface est simple, on opère seulement sur des entiers. Pas d'autres restrictions.
Certaines techniques de développement préconisent d'écrire tous les tests avant de commencer le logiciel. Au fur et à mesure du développement, de plus en plus de tests vont réussir et lorsqu'ils réussissent tous, le logiciel est terminé. C'est la méthode de développement dite "test-driven".
2.1 Générer les tests
Procédure à suivre :
· créer un nouveau projet,
· copier l'interface Calculator (page précédente) dans le package main.
· créer la classe CalculatorImpl qui implémente Calculator, générer les méthodes (vides pour l'instant).
Ecriture des tests :
· Clic droit sur le package ou se trouve la classe à tester et faire new > Other > Choisir la catégorie Unit Tests puis Test for Existing Class. <Suivant>
· Choisir la classe à tester.
Démarche sous NetBeans :
Voici ensuite les informations pour NetBeans : ne pas créer les méthodes Test Initializer, Test Finalizer, Test Class Initializer et Test Class Finalizer. Ces méthodes servent à générer le contexte dans lequel la classe doit s'exécuter, établir les connexions à la base de données par exemple (vu dans un prochain chapitre)
Quatre cases restent donc décochées. Choisir la dernière version de JUnit.
Cliquer sur Terminé et la classe de test est générée (dans un dossier Test Packages), sensiblement de la façon suivante :
Ajouter la librairie JUnit 4.12 dans Libraries et la librairie Hamcrest 1.3 dans Test Libraries pour obtenir :
import org.junit.Test;
import static org.junit.Assert.*;
public class CalculatorImplTest {
public CalculatorImplTest() {
}
/**
* Test of multiply method, of class CalculatorImpl.
*/
@Test
public void testMultiply() {
System.out.println("multiply");
int a = 0;
int b = 0;
CalculatorImpl instance = new CalculatorImpl();
int expResult = 0;
int result = instance.multiply(a, b);
assertEquals(expResult, result);
// TODO review the generated test code and remove the default call to fail.
fail("The test case is a prototype.");
}
/**
* Test of divide method, of class CalculatorImpl.
*/
@Test
public void testDivide() {
System.out.println("divide");
int a = 0;
int b = 0;
CalculatorImpl instance = new CalculatorImpl();
int expResult = 0;
int result = instance.divide(a, b);
assertEquals(expResult, result);
// TODO review the generated test code and remove the default call to fail.
fail("The test case is a prototype.");
}
/**
* Test of add method, of class CalculatorImpl.
*/
@Test
public void testAdd() {
System.out.println("add");
int a = 0;
int b = 0;
CalculatorImpl instance = new CalculatorImpl();
int expResult = 0;
int result = instance.add(a, b);
assertEquals(expResult, result);
// TODO review the generated test code and remove the default call to fail.
fail("The test case is a prototype.");
}
/**
* Test of substract method, of class CalculatorImpl.
*/
@Test
public void testSubstract() {
System.out.println("substract");
int a = 0;
int b = 0;
CalculatorImpl instance = new CalculatorImpl();
int expResult = 0;
int result = instance.substract(a, b);
assertEquals(expResult, result);
// TODO review the generated test code and remove the default call to fail.
fail("The test case is a prototype.");
}
}
NetBeans a donc généré quatre méthodes destinées à tester les quatre méthodes de la classe CalculatorImpl. NetBeans ne peut évidemment pas générer le contenu des tests, le code est donc seulement fail("The test case is a prototype "); // TODO afin de faire échouer un test non écrit.
Remarque :
La classe de test indique import static org.junit.Assert.*; è Un import statique permet de faire appel à des méthodes statiques sans préciser le nom de la classe qui définit ces méthodes.
Exemple avec la classe Math :
import static java.lang.Math.cos;
import static java.lang.Math.PI;
Alors ceci est valide :
double d = cos(2 * PI);
Sans les imports statiques il aurait fallu faire :
double d = Math.cos(2*Math.PI);
On peut maintenant lancer le test en cliquant droit sur la classe nouvellement créée, puis run file. Un nouvel onglet apparaît alors, indiquant que les quatre tests ont échoué. Une raison est affichée, c'est celle qui est donnée en paramètre de la méthode fail() :
Implémentation de la classe CalculatorImpl
Contraintes à respecter :
Pour implémenter cette interface on n'utilisera pas les opérateurs +, -, * et /. De cette façon, les méthodes à tester ne seront pas trop simples et auront donc plus de chance de contenir des erreurs.
Par contre, on pourra utiliser ++ et --, et les tests (==, <, >, ...), on pourra aussi utiliser l'opérateur - en tant qu'opérateur unaire, c'est à dire pour transformer 2 en -2 par exemple.
Méthode add à écrire :
@Override
public int add(int a, int b)
{
int res = a ;
if (b > 0)
{
while(b-- != 0)
{
res++ ;
}
}
else
{
if (b < 0)
{
while(b++ != 0)
{
res-- ;
}
}
}
return res ;
}
2.2 Remplir les méthodes de tests
Principe :
· On utilise la méthode fail() à chaque fois qu'un test échoue.
· Pour écrire un test correct, on ne teste que quelques valeurs puis on généralise. mais les valeurs ne doivent pas être choisies au hasard et c'est là que réside l'art d'écrire un test.
Pour les tests on pourra utiliser tous les opérateurs souhaités.
Les arguments a et b doivent être additionnés, ce qui ne pose pas de problème. Mais les cas "spéciaux" devront être testés :
· si a ou b ou les deux est (sont) négatif(s) ou nul (on testera aussi s'ils sont positifs),
· d'une manière générale, lorsque l'on écrit un test, il faut tester avec quelques valeurs standards, qui n'ont pas de signification particulière. Puis il faut tester avec les cas limites : nombres négatifs, nuls... Si vous prenez des objets il faut savoir s'ils étaient nuls, s'ils était une sous-classe du type demandé ? Ou bien si l'objet était mal initialisé ? …
·
Cependant, les tests doivent restés très simples, s'ils deviennent compliqués, on risque d'introduire des bugs dans les tests, ce qui serait un comble.
Voici un squelette de test :
1. Instancier la classe à tester T,
2. Initialiser T,
3. Générer les arguments pour la méthode à tester,
4. Générer le résultat,
5. Tester la méthode avec les arguments,
6. Vérifier le résultat,
7. Recommencer depuis 3 tant qu'il y a des cas à tester.
Voici un test possible :
public final void testAdd()
{
Calculator calc = new CalculatorImpl();
int a, b, res;
a = 5; b = 5;
res = a + b;
if (calc.add(a, b) != res)
{
fail("a et b positifs");
}
a = 0; b = 5;
res = a + b;
if (calc.add(a, b) != res)
{
fail("a nul");
}
a = 5; b = 0;
res = a + b;
if (calc.add(a, b) != res)
{
fail("b nul");
}
a = 0; b = 0;
res = a + b;
if (calc.add(a, b) != res)
{
fail("a et b nuls");
}
a = -5; b = 5;
res = a + b;
if (calc.add(a, b) != res)
{
fail("a negatif");
}
a = 5; b = -5;
res = a + b;
if (calc.add(a, b) != res)
{
fail("b negatif");
}
a = -5; b = -5;
res = a + b;
if (calc.add(a, b) != res)
{
fail("a et b negatif");
}
}
Il y a donc 7 cas de tests avec à chaque fois a et b qui varient. On laisse un message pour savoir quel cas échoue lorsqu'il y a un échec. On calcule le résultat théorique d'une manière aussi sûr que possible puis on le compare grâce à un test d'égalité (ou de différence lorsqu'on veut trouver l'échec).
Exécution du test :
Deux choix possibles :
o Le test passe au vert è implémenter la méthode suivante et son test;
o Le test reste rouge è trouver le bug et le corriger.
On peut aussi avoir à gérer les exceptions. C'est le cas dans la méthode de division : une exception est générée si b vaut 0 :
Méthode divide à écrire :
@Override
public int divide(int a, int b)
{
if (b == 0)
{
throw new ArithmeticException();
}
boolean resEstNegatif = false;
int res = 0;
if ( a < 0)
{
resEstNegatif = !resEstNegatif;
a = -a;
}
if ( b < 0)
{
resEstNegatif = !resEstNegatif;
b = -b;
}
while (a > 0)
{
a = substract(a, b);
res++;
}
if (resEstNegatif)
{
res = -res;
}
return res;
}
1°) Test pour les cas où aucune exception ne devrait être jetée :
public final void testDivide()
{
Calculator calc = new CalculatorImpl();
int a, b, res;
a = 5;
b = 5;
res = a / b;
if (calc.divide(a, b) != res)
{
fail("a et b positif");
}
a = 0;
b = 5;
res = a / b;
if (calc.divide(a, b) != res)
{
fail("a nul");
}
a = -5;
b = 5;
res = a / b;
if (calc.divide(a, b) != res)
{
fail("a negatif");
}
a = 5;
b = -5;
res = a / b;
if (calc.divide(a, b) != res)
{
fail("b negatif");
}
a = -5;
b = -5;
res = a / b;
if (calc.divide(a, b) != res)
{
fail("a et b negatif");
}
}
2°) Test pour les cas où une exception devrait être jetée :
(Le bout de code doit jeter une exception. S'il ne le fait pas c'est que la méthode ne réagit pas comme elle devrait. Le test doit donc échouer si aucune exception n'est levée)
Pour indiquer à JUnit que le test attend une exception il faut écrire l'annotation @Test (expected = LaClassDeNotreException) (expected signifie s'attend à ou attend une) :
@Test (expected = ArithmeticException.class)
public final void testDivideByZero()
{
Calculator calc = new CalculatorImpl();
int a, b, res;
a = 5;
b = 0;
res = 0;
if (calc.divide(a, b) != res)
{
fail("b nul");
}
a = 0;
b = 0;
res = 0;
if (calc.divide(a, b) != res)
{
fail("a et b nuls");
}
}
Les "if(...)...fail()" sont à éviter dans les tests unitaires;. JUnit possèdent des méthodes pour les éviter :
assertTrue(message, condition);
assertFalse(message, condition);
assertEquals(message, expected, actual); //pour des objets ou des longs
assertNotNull(message, object);
...
La liste exhaustive des assertions disponibles est visible à cette adresse : http://junit.sourceforge.net/javadoc/org/junit/Assert.html.
Tests avec ces méthodes :
public final void testAdd()
{
Calculator calc = new CalculatorImpl();
int a, b, res;
a = 5;
b = 5;
res = a + b;
assertTrue("a et b positif", calc.add(a, b) == res);
a = 0;
b = 5;
res = a + b;
assertTrue("a nul", calc.add(a, b) == res);
a = 5;
b = 0;
res = a + b;
assertTrue("b nul", calc.add(a, b) == res);
a = 0;
b = 0;
res = a + b;
assertTrue("a et b nuls", calc.add(a, b) == res);
a = -5;
b = 5;
res = a+ b;
assertTrue("a negatif", calc.add(a, b) == res);
a = 5;
b = -5;
res = a + b;
assertTrue("b negatif", calc.add(a, b) == res);
a = -5;
b = -5;
res = a + b;
assertTrue("a et b negatif", calc.add(a, b) == res);
}
Onglet JUnit une fois que quelques tests passent au vert (ici 3 sur 5):
Tester proprement : la gestion du contexte
Les tests précédents étaient conçus pour une "classe basique".
La classe CalculatorImpl est basique parce que :
Elle n'a pas d'état interne;
Toutes ses méthodes retournent directement le résultat.
Dans ce chapitre les tests vont porter sur des méthodes qui ne retournent rien avec une classe qui a un état interne bien défini. D'ailleurs, la fonction de cette classe sera d'avoir un état bien défini puisque nous allons recréer une classe de liste qui va étendre la classe ArrayList.
Interface de la classe à tester :
package main;
public interface MonArrayList
{
void ajoute(String pnom);
String enleveA(int pos);
boolean enleveNom(String pnom);
void ajouteA(int pos, String pnom);
String obtientA(int pos);
int taille();
void effaceTout();
}
Cette classe est toujours située dans le package main et sa classe de test sera dans le package test.
Certaines méthodes sont void et ce sont ces dernières qui seront testées.
Le nom des méthodes devrait permettre de comprendre ce qu'elles font
Non affiché pour les élèves
public class MonArrayListImpl extends ArrayList implements MonArrayList
{
private int taille;
public MonArrayListImpl()
{
super();
taille=0;
}
@Override
public void ajoute(String pnom)
{
this.add(pnom);
taille++;
}
@Override
public String enleveA(int pos)
{
if (pos >= taille)
{
throw newArrayIndexOutOfBoundsException("La taille est " + taille + " l'élément " + pos + " n'existe donc pas");
}
String nom = (String) this.remove(pos);
taille--;
return nom;
}
@Override
public booleanenleveNom(String pnom)
{
int pos = 1;
boolean trouve = this.remove(pnom);
if (trouve)
{
taille--;
}
return trouve;
}
@Override
public void ajouteA(int pos, String pnom)
{
this.add(pos, pnom);
}
@Override
public String obtientA(int pos)
{
String nom = (String) this.get(pos);
return nom;
}
@Override
public inttaille()
{
return taille;
}
@Override
public voideffaceTout()
{
for (int i = (taille - 1); i >= 0; i--)
{
enleveA(i);
}
}
}
Classe de test avec cette fois-ci les 4 méthodes proposées par NetBeans cochées (Test Initializer, Test Finalizer, Test Class Initializer et Test Class Finalizer) :
public classMonArrayListImplTest {
@BeforeClass
public static voidsetUpBeforeClass() throws Exception {
}
@AfterClass
public static voidtearDownAfterClass() throws Exception {
}
@Before
public voidsetUp() throws Exception {
}
@After
public voidtearDown() throws Exception {
}
@Test
public voidtestMonArrayListImpl() {
fail("The test case is a prototype.");
}
@Test
public voidtestAjoute() {
fail("The test case is a prototype.");
}
@Test
public voidtestEnleveA() {
fail("The test case is a prototype.");
}
@Test
public voidtestEnleveNom() {
fail("The test case is a prototype.");
}
@Test
public voidtestAjouteA() {
fail("The test case is a prototype.");
}
@Test
public voidtestObtientA() {
fail("The test case is a prototype.");
}
@Test
public voidtestTaille() {
fail("The test case is a prototype.");
}
@Test
public voidtestEffaceTout() {
fail("The test case is a prototype.");
}
}
Les méthodes de test qui contrôlent les méthodes renvoyant un résultat ont déjà été étudiées précédemment et ne vont donc pas être traitées ici.
Quatre nouvelles méthodes sont apparues. Chaque méthode est précédée d'une annotation qui a une utilité bien précise :
@BeforeClass La méthode annotée sera lancée avant le premier test.
@AfterClass La méthode annotée sera lancée après le dernier test.
@Before La méthode annotée sera lancée avant chaque test.
@After La méthode annotée sera lancée après chaque test.
Un test simple le montre :
@BeforeClass
public static void setUpBeforeClass() throws Exception {
System.out.println("avant tout");
}
@AfterClass
public static void tearDownAfterClass() throws Exception {
System.out.println("après tout");
}
@Before
public void setUp() throws Exception {
System.out.println("avant un test");
}
@After
public void tearDown() throws Exception {
System.out.println("après un test");
}
Et voici ce qui devrait s'afficher dans la console :
avant tout
avant un test
après un test
avant un test
après un test
avant un test
après un test
avant un test
après un test
avant un test
après un test
après tout
Les méthodes appelées avant et après tous les test serviront donc à initialiser des variables et ressources communes à tous les tests et à les nettoyer à la fin.
Les méthodes appelées avant et après chaque test serviront à initialiser la liste avant et à la remettre à zéro après.
Remarque : Pour cet exercice un fichier de propriétés sera utilisé pour configurer le test.
Ce fichier contiendra seulement 2 lignes : la taille de la liste et les noms à mettre dans la liste :
taille=8
nom=dupont;durant;lambert;sarget;tampin;le jean;charlet;le junon
Pour lire le fichier de propriétés, on utilisera un FileInputStream qui sera l'un des attributs de la classe de test.
On utilisera aussi une instance de la classe Properties et on chargera les noms dans une liste et il faudra aussi un attribut pour la liste à tester. Enfin, il faudra un dernier attribut pour la taille de la liste lors de l'initialisation.
Code de la classe de test :
package main;
import java.util.ArrayList;
public class MonArrayListImpl extends ArrayList implements MonArrayList
{
private int taille;
public MonArrayListImpl()
{
super();
taille=0;
}
@Override
public void ajoute(String pnom)
{
this.add(pnom);
taille++;
}
@Override
public String enleveA(int pos)
{
if (pos >= taille)
{
throw newArrayIndexOutOfBoundsException("La taille est " +
taille + " l'élément " + pos + " n'existe donc pas");
}
String nom = (String) this.remove(pos);
taille--;
return nom;
}
@Override
public booleanenleveNom(String pnom)
{
int pos = 1;
boolean trouve = this.remove(pnom);
if (trouve)
{
taille--;
}
return trouve;
}
@Override
public void ajouteA(int pos, String pnom)
{
this.add(pos, pnom);
}
@Override
public String obtientA(int pos)
{
String nom = (String) this.get(pos);
return nom;
}
@Override
public inttaille()
{
return taille;
}
@Override
public voideffaceTout()
{
for (int i = (taille - 1); i >= 0; i--)
{
enleveA(i);
}
}
}
Test d'une méthode qui ne retourne rien
Les méthodes qui ne retournent rien, en fonction du contexte et des arguments qu'on leur passe, modifient le contexte. Une fonction peut également, en plus de retourner un résultat, modifier le contexte.
On testera donc ici les méthodes qui changent le contexte. Exemple, la méthode void ajoute(String pnom) de la classe de liste. Son seul effet est d'ajouter un élément à la liste, on vérifiera donc que l'élément a bien été ajouté et que la taille de la liste est passé à la taille initiale + 1.
Classe de test complété :
package test;
import static org.junit.Assert.*;
import java.io.FileInputStream;
import java.util.ArrayList;
import java.util.Properties;
import main.MonArrayListImpl;
import org.junit.After;
import org.junit.AfterClass;
import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.Test;
public class MonArrayListImplTest
{
// déclaration en static car les 2 premières classes sont statiques
private static MonArrayListImpl monArrayListImpl; // la classe a tester
private static int tailleInitiale; // la taille à l'origine
private static Properties prop; // les propriétés
private static ArrayList ar; // les noms à mettre dans la liste
private static FileInputStream fichierProprietes; // le fichier des propriétés
@BeforeClass
public static void setUpBeforeClass() throws Exception
{
System.out.println("début classe de test");
prop = new Properties();
ar = new ArrayList();
// chargement du fichier des propriétés
fichierProprietes = new FileInputStream("src/monArrayList.properties");
prop.load(fichierProprietes);
tailleInitiale = Integer.parseInt(prop.getProperty("taille")); // récupère la taille de la liste
String listeNoms = prop.getProperty("nom");
String[] noms = listeNoms.split(";");
for (int i = 0 ; i< noms.length ; i++)
{
ar.add(noms[i]); // renseigne l'ArrayList
}
monArrayListImpl = new MonArrayListImpl(); // on instancie la classe à tester
}
@AfterClass
public static void tearDownAfterClass() throws Exception
{
fichierProprietes.close(); // à la fin du test on ferme le fichier
}
@Before
public void setUp() throws Exception
{
System.out.println("début chaque test");
// au début de chaque test
// on renseigne monArrayList à partir du fichier propriétés
// monArrayListImpl.effaceTout();
for (int i = 0 ; i< ar.size() ; i++)
{
monArrayListImpl.ajoute((String)ar.get(i));
}
}
@After
public void tearDown() throws Exception
{
monArrayListImpl.effaceTout(); // à la fin de chaque test, on vide monArrayList
}
// On ne garde que le test des procédure car
// le test des fonctions a été vu précédemment
@Test
public void testMonArrayListImpl()
{
assertTrue("constructeur erroné", monArrayListImpl.taille()==8);
}
@Test
public void testAjoute()
{
assertEquals(tailleInitiale, monArrayListImpl.taille());
monArrayListImpl.ajoute("Toto");
assertEquals(tailleInitiale + 1, monArrayListImpl.taille());
// après avoir ajouté un nom, les 61ers noms doivent être les mêmes qu'initialement
for (int i = 0; i < ar.size(); i++)
{
assertEquals(monArrayListImpl.obtientA(i), (String) ar.get(i));
}
}
@Test
public void testAjouteA()
{
monArrayListImpl.ajouteA(tailleInitiale, "Titi");
assertEquals(tailleInitiale + 1, monArrayListImpl.taille());
// après avoir ajouté un nom a la position précisée
// les 6 1ers noms doivent être les mêmes qu'initialement
for (int i = 0; i < ar.size(); i++)
{
assertEquals(monArrayListImpl.obtientA(i), ar.get(i));
}
}
@Test
public void testEffaceTout()
{
monArrayListImpl.effaceTout();
assertEquals(0, monArrayListImpl.taille());
}
}
Le test avec jUnit donne :
Pourquoi cette erreur ?
Effectuer la modification.
Après modification :
Les mocks
Nous avons vu comment tester des classes individuellement. Nous avons vu que pour que le test reste simple et donc efficace, il
fallait tester les classes l'une après l'autre, méthode après méthode. Mais voilà, comme vous le savez, une classe toute seule
marche très rarement. Il faut qu'elle interagisse avec d'autres classes dont le comportement n'est pas forcément trivial. Et comme
nous ne voulons tester les classes qu'une à la fois, nous nous retrouvons bloqués.
Concrètement, la classe A a besoin de la classe B pour travailler. La classe B est testée tout comme il faut mais cependant il ne
faut pas l'utiliser dans le test. Pourquoi pas ? Parce que nous voulons faire du test unitaire d'une part et que d'autre part, où
s’arrête-t-on ? Pourquoi ne pas lancer toute l'application pour tester une classe ? Parce que nous savons que dans toute
l'application il y a un ou plusieurs bug. Parce qu'il faudrait tester un nombre bien trop grand d'entrées. Parce que ce serait trop
long. Il y a encore plein de raisons.
Nous avons donc un problème. Nous ne pouvons pas tester certaines classes parce que nous ne pouvons pas instancier les
classes dont elles ont besoin.
C'est là qu'interviennent les mocks. Un mock est une classe qui va en simuler une autre. Je m'explique : la classe A a besoin de la
classe B pour travailler. Ce n'est pas vrai. La classe A a besoin d'une classe implémentant l'interface I (souvenez-vous de
l'importance de la programmation par interface) et il se trouve que la classe B implémente l'interface I. La classe B fait un travail
sérieux qu'il est nécessaire de tester, on ne peut donc pas l'instancier dans le test de la classe A. Cependant, il nous faut bien
Les tests unitaires en Java 21/31
www.siteduzero.com
passer une classe implémentant l'interface I pour tester A. C'est pour cela que nous allons créer la classe BMock qui implémente
l'interface I mais qui soit si simple qu'il n'est plus la peine de la tester.
Appartée sur les interfaces
Nous avons vu les interfaces à deux endroits maintenant : lors de la rédaction d'un test si la classe n'est pas encore écrite et dans
le cas où nous avons besoin d'un mock. La programmation par interface est une chose importante en génie logiciel, cela permet
(en plus de ce que l'on vient de voir) de changer une implémentation en deux secondes et demi et passer d'une vieille classe à
une version toute neuve ou bien à adapter le logiciel en fonction des besoins. Il y a encore plein de bonnes raisons.
En pratique
Comme les chapitres précédents, nous allons voir le fonctionnement des mocks dans la pratique. Les mocks étant une notion
assez simple, je vais prendre un exemple aussi très simple mais qui sera réaliste. Imaginez une application en trois couches :
Une couche d'accès aux données qui gère le pool de connexion et les requêtes;
Une couche métier qui prend les données, leurs applique une transformation utile au business de l'entreprise;
Une couche présentation, qui récupère les résultats de la couche métier et les affiche d'une manière lisible par l'homme.
Ceci est une organisation très célèbre nommée trois tiers. Mais vous voyez immédiatement qu'il y a une forte dépendance entre
une couche et la suivante. Dans une application réelle il y a des mocks pour chaque couche excepté la couche présentation.
Ainsi, la couche présentation peut tourner soit avec le mock de la couche métier soit avec son implémentation et la couche métier
peut travailler sur de vraies données ou sur des données contenues dans un mock. C'est d'ailleurs comme ça qu'on cache le
retard : on dit qu'une fonctionnalité est implémentée alors qu'elle tourne avec un mock.
Dans une architecture 3 tiers, tiers est un mot anglais signifiant partie. On peut donc avoir des applications 2 tiers
(client-serveur) ou n-tiers (en général des applications 2 ou 3 tiers chaînées).
Dans une architecture n-tiers, la couche k ne peut accéder qu'à la couche k-1 et c'est tout ! La couche k-1 ne peut
accéder à la couche k que par le retour d'une méthode et deux couches séparées par une troisième ne peuvent pas
communiquer directement. De plus, seule la couche présentation peut dialoguer avec le monde extérieur. Ainsi, tout est
facilité : imaginez que votre base de données ne vous convienne plus, changez là et changez la couche d'accès aux
données et tout marche. Vous ne voulez plus dialoguer avec des humains, changez la couche présentation, formatez les
entrées-sorties en suivant le bon protocole et vous dialoguez maintenant avec r2-d2. Cool, non ?
Bon, tu nous as dit que les mocks allaient nous sauver la vie. Mais on sait toujours pas quoi mettre dedans ?
Oui c'est vrai. Je m'égare un peu je crois. Bon, comme les mocks, c'est assez simple, on va faire quelque chose de simple. Soit
deux classes : une de la couche présentation et une de la couche métier. L'une va afficher une adresse et l'autre va la chercher
quelque part. Voici donc les deux interfaces.
Les interfaces :
La première pour la classe qui va retrouver les adresses :
Code : Java
package main.inter;
import main.implem.Address;
public interface AddressFetcher {
Address fetchAddress(String name);
}
Et pour la seconde qui les affiche :
Code : Java
Les tests unitaires en Java 22/31
www.siteduzero.com
package main.inter;
public interface AddressDisplayer {
String displayAddress();
void setAddressFetcher(AddressFetcher af);
}
Les implémentations :
L'afficheur d'adresse, un code très simple :
Code : Java
package main.implem;
import main.inter.AddressDisplayer;
import main.inter.AddressFetcher;
public class AddressDisplayerImpl implements AddressDisplayer {
private AddressFetcher addressFetcher;
@Override
public String displayAddress(String name) {
Address a = addressFetcher.fetchAddress(name);
String address = a.getName() + "\n";
address += a.getNb() + " " + a.getStreet() + "\n";
address += a.getZip() + " " + a.getTown();
return address;
}
@Override
public void setAddressFetcher(AddressFetcher af) {
this.addressFetcher = af;
}
}
Le chercheur d'adresse. Attention, code très compliqué :
Code : Java
package main.implem;
import main.inter.AddressFetcher;
public class AddressFetcherImpl implements AddressFetcher {
@Override
public Address fetchAddress(String name) {
/*
* Du code très compliqué ici
* Quelque chose de très complexe ici. Traitement pluslien à la base
de donnée, etc.
* */
return null;
}
}
Et enfin, la classe Address un peu longue alors je la mets en secret :
Les tests unitaires en Java 23/31
www.siteduzero.com
Secret (cliquez pour afficher)
Code : Java
package main.implem;
public class Address {
private String street;
private String name;
private int nb;
private int zip;
private String town;
public Address(String street, String name, int nb, int zip,
String town) {
super();
this.street = street;
this.name = name;
this.nb = nb;
this.zip = zip;
this.town = town;
}
public String getStreet() {
return street;
}
public void setStreet(String street) {
this.street = street;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getNb() {
return nb;
}
public void setNb(int nb) {
this.nb = nb;
}
public int getZip() {
return zip;
}
public void setZip(int zip) {
this.zip = zip;
}
public String getTown() {
return town;
}
public void setTown(String town) {
this.town = town;
}
}
Afin d'avoir un logiciel maintenable, il faut adopter une structure de package correcte. Voici la mienne :
Les tests unitaires en Java 24/31
www.siteduzero.com
arborescence
Ainsi, dans mon main, je n'ai que mon logiciel, interface d'un coté, implémentation de l'autre. Dans mon package test il
n'y a que les tests et dans mock, que les mocks. Lorsqu'il faudra livrer le logiciel, je ne donnerai que le package main.
Idéalement, dans inter et implem, je devrais avoir les packages donnees, metier et presentation mais je ne voulais pas
surcharger.
Il ne nous reste donc plus que le test et le mock.
Et tu ne nous as toujours pas dit ce que faisait un mock.
C'est vrai. Pour le moment, vous savez seulement que c'est une classe qui va remplacer une vraie classe pour éviter d'aller trop
loin dans les dépendances. En réalité, un mock ne va faire que le strict minimum : implémenter l'interface et c'est tout. Le mock va
rendre les données en dur. Vous vous souvenez les règles comme "pas de magic number", ou bien "utiliser des constantes" ?
Oubliez-les. Enfin, seulement pour les mocks.
L'interface dit que la méthode a rend une chaîne de caractère, voici le mock correspondant :
Code : Java
public String a() {
return "";
}
Facile, non ?
Voici donc enfin notre mock :
Code : Java
package mock;
import main.implem.Address;
import main.inter.AddressFetcher;
public class AddressFetcherMock implements AddressFetcher {
@Override
public Address fetchAddress(String name) {
return new Address("Avenue champs-Elysés", "Mathias Dupond", 5,
75005, "Paris");
}
}
Les tests unitaires en Java 25/31
www.siteduzero.com
Vous pouvez maintenant tester notre classe comme vous le souhaitez. Voici mon test :
Code : Java
package test;
import static org.junit.Assert.*;
import main.implem.AddressDisplayerImpl;
import main.inter.AddressDisplayer;
import mock.AddressFetcherMock;
import org.junit.BeforeClass;
import org.junit.Test;
public class AddressDisplayerTest {
private static AddressDisplayer sut;
@BeforeClass
public static void setUpBeforeClass() throws Exception {
sut = new AddressDisplayerImpl();
sut.setAddressFetcher(new AddressFetcherMock());
}
@Test
public void testDisplayAddress() {
String resutlatTheorique = "Mathias Dupond\n5 Avenue champs-
Elysés\n75005 Paris";
String ResultatPratique = sut.displayAddress("Dupond");
assertTrue(ResultatPratique.compareTo(resutlatTheorique) == 0);
}
}
Et voilà, tout ça pour ça.
Hé mais attends. Tu nous dis qu'il faut tester avec plein d'entrées et tout et tout et là tu ne testes qu'avec un seul nom.
Tu ne trouves pas qu'il y a un truc qui cloche ?
C'est vrai que ça peut sembler bizarre. Mais que teste-t-on réellement ici ? On teste seulement si l'affichage est correct, on ne
cherche pas à savoir si on affiche la bonne personne (c'est le boulot de AddressFetcher de trouver la bonne personne), on
cherche à savoir si on affiche correctement la personne X. Pour cela il nous faut une personne et on prend la première venue. Par
contre, s'il y avait eu plusieurs modes d'affichage, là oui, il aurait fallu tous les tester.
Voilà encore une partie de terminée. Cette fois nous avons appris à rendre notre code indépendant afin de concentrer nos tests
sur une seule classe à la fois comme c'était le but des tests unitaires.
Les problèmes des tests
Le test lui-même
Un test, comme un logiciel, peut avoir des problèmes. Tout d'abord, nos tests ne sont pas exhaustifs. Il ne trouveront donc pas
toutes les erreurs. Mais ce n'est pas de cela que je veux vous parler. Je veux vous parler de la mauvaise conception d'un test.
Nous avons vu qu'un test était composé de cinq entités :
Les arguments;
Le résultat;
Un moyen de comparer les deux;
Les tests unitaires en Java 26/31
www.siteduzero.com
La classe à tester;
Le contexte dans lequel le test doit avoir lieu.
Il y a autant de mauvaises conceptions qu'il y a de points dans la liste ci-dessus.
Les arguments
Vous pouvez mal les initialiser dans le cas d'arguments complexes, vous pouvez ne pas tester suffisamment de cas limites.
Idéalement, il faudrait tous les tester, ce qui signifie tous les identifier. Identifier tous les cas limites peut être difficile, ils peuvent
être nombreux et bien cachés.
Le résultat
Il vous faut calculer le résultat théorique de l'exécution de votre méthode. Pour notre exemple c'était facile. Mais ce n'est pas
toujours le cas. D'une manière générale, la génération de l'ensemble (entrée, sortie) peut s'avérer quelque chose de difficile. Pour
tester, vous pouvez vous appuyer sur d'autres logiciels qui ont fait leurs preuves, sur des théorèmes (pour notre exemple, a + b -
b = a est trivial).
L'oracle
La chose la plus difficile peut-être. Dans notre cas très simple, un simple test d'égalité suffit. Et l'égalité entre deux entiers est très
bien définie. Maintenant, l'égalité entre deux objets. Elle dépend de chaque objet. Est-ce-qu'on teste tous les champs ?
Probablement pas, typiquement le champ UniversalSerialID ne devrait pas faire partie du test. L'exemple parfait du mauvais
oracle est le test d'égalité == entre deux Strings.
La classe
Là aussi il peut y avoir des difficultés. Une instance d'une classe est un objet ayant un état bien défini. Cet état peut bien sûr
influer sur les tests (c'est même souhaitable). Mais ça signifie qu'il faut bien initialiser notre objet avant de lancer le test et aussi
qu'il faut multiplier les tests par le nombre d'état significatif.
La méthode
Enfin, la méthode, ce que l'on souhaite réellement tester. On peut tomber dans deux travers : être trop spécifique, ne tester que les
cas limites et oublier les cas normaux (ou le contraire) ou bien ne pas être assez spécifique et passer trop de temps à écrire notre
test. Le deuxième travers est le moins mauvais, mais des tests inutiles mettront trop longtemps à s'exécuter (surtout s'il s'agit
d'une méthode qui prend du temps), seront plus long à écrire du coup vous serez moins productif.
La stratégie de test
Enfin, j'aimerai vous reparler des tests boite noire et des tests boite blanche. Voici un exemple de test boite blanche :
Le cas d'un test boite blanche
la méthode : son test :
Code : Java
/**
* soit a un entier et b aussi
compris entre 0 et b un entier sur 3
bits
* @param a un entier
* @param b un entier
* @return ab
*/
public int xyz(int a, int b) {
return a*10+b;
}
Code : Java
@Test
public final void
testXyz() {
CalculatorImpl calc =
new CalculatorImpl();
int a, b, res;
a = 5; b = 8; res =
58;
assertFalse(calc.xyz(a
, b) == res);
}
Les tests unitaires en Java 27/31
www.siteduzero.com
Ici le testeur a lu la méthode et il a interprété return ab comme la concaténation de a et b. Il s'est donc empressé de créer un cas
pour ceci, il a fait exprès de le faire échouer pour que, lorsque la méthode sera corrigée, le test passe au vert. Le test n'est pas
complet évidemment. Mais ce que je veux vous montrer c'est que ab dans la tête de celui qui a commandé la méthode c'est le
produit a*b. Votre test ne sert donc à rien et s'il ne sert à rien c'est parce que vous nous saviez pas ce que voulait dire ab, vous
avez regardé le code qui vous a induit en erreur. Si le test avait été boite noire, vous n'auriez pas vu le code, pas su ce que
voulais dire ab, auriez demandé et vous auriez vu l'énorme erreur qu'a fait le programmeur de la méthode xyz.
Le cas d'un test boite noire
Voici maintenant un test boite noire pour la méthode int divide(int, int) que nous avons développé. Reprenons le même test :
Code : Java
@Test
public final void testDivide() {
Calculator calc = new CalculatorImpl();
int a, b, res;
a = 5; b = 5; res = a / b;
assertTrue("a et b positif", calc.divide(a, b) == res);
a = 0; b = 5; res = a / b;
assertTrue("a nul", calc.divide(a, b) == res);
a = -5; b = 5; res = a / b;
assertTrue("a negatif", calc.divide(a, b) == res);
a = 5; b = -5; res = a / b;
assertTrue("b negatif", calc.divide(a, b) == res);
a = -5; b = -5; res = a / b;
assertTrue("a et b negatif", calc.divide(a, b) == res);
}
@Test (expected = ArithmeticException.class)
public final void testDivideByZero() {
Calculator calc = new CalculatorImpl();
int a, b, res;
a = 0; b = 0; res = 0;
assertTrue("a et b nuls", calc.divide(a, b) == res);
a = 5; b = 0; res = 0;
assertTrue("b nul", calc.divide(a, b) == res);
}
Ce test est bon, n'est-ce-pas ? C'est celui que je vous ai montré, qu'on a fait ensemble. Il est bon.
Voila la nouvelle implémentation de la méthode à tester :
Code : Java
@Override
public int divide(int a, int b) {
if (b == 0) {
throw new ArithmeticException();
}
if (b == 1) {
return b;
}
boolean resEstNegatif = false;
int res = 0;
if ( a < 0) {
resEstNegatif = true;
a = -a;
}
if ( b < 0) {
Les tests unitaires en Java 28/31
www.siteduzero.com
resEstNegatif = !resEstNegatif;
b = -b;
}
while (a > 0) {
a = substract(a, b);
res++;
}
if (resEstNegatif) {
res = -res;
}
return res;
}
Les lignes 6 et 7 ont été ajoutées. Une petite optimisation du programmeur. Diviser par 1 est inutile, on retourne tout de suite le
dividende. Normal quoi ! Oui mais erreur d'inattention, il a retourné le diviseur...
Ce cas aurait été difficile à identifier comme cas limite et le fait de voir l'implémentation de la méthode aurait aidé.
Conclusion
Faire un test n'est pas quelque chose de trivial. En général, dans une équipe de développeurs, ce ne sont pas les mêmes
personnes qui testent et qui développent. L'inconvénient c'est que l'expert en base de données va développer la couche d'accès
à la base de données et donc c'est quelqu'un qui ne connaît rien (j'exagère un tout petit peu) qui va la tester.
Que vous choisissiez des tests boite noire ou boite blanche, il y a des avantages et des inconvénients, ça dépend de comment
vous vous sentez le plus à l'aise, de votre expérience et de tout un tas d'autres facteurs.
Enfin, voilà une citation dont je ne connais pas l'auteur :
Citation : inconnu
If you think test-first is expensive, try debug-later
C'est-à-dire pour les non-anglophones :
Citation : inconnu
Si vous pensez que tester en premier est coûteux, essayez donc de débugger plus tard.
Ainsi que quelques principes :
Celui qui code une classe ne devrait pas la tester, apporter une nouvelle vue à ce problème est toujours une bonne chose
;
Testez les entrées valides mais aussi les entrées invalides. Que se passe-t-il si je donne un caractère au lieu d'un entier ?
Vous voyez maintenant que le typage fort de Java est un avantage, non ?
Partez avec un esprit de challenger ! Si vous faites des tests, c'est pour trouver le plus d'erreurs possibles, pas pour
confirmer qu'il n'y en a pas ;
Soyez sûrs du résultat théorique avant de lancer le test. Ainsi, vous éviterez le "Ah, ça colle pas ? Mais c'est cohérent
quand même, c'est moi qui ai dû me tromper en faisant la théorie. Modifions ce test pour qu'il passe."
Voilà, ce tutoriel touche à sa fin, j'espère que vous aurez appris pleins de choses (sur les tests notamment). Il reste pleins de
choses à voir, un mini-tuto ne suffit pas à tout dire, mais ceci est déjà une introduction solide je pense. Le test est une des
méthodes qui feront que vos projets peuvent passer avec succès la barre des mille lignes et rester maintenables.
J'espère que ce tutoriel vous a plu. N'hésitez pas à laisser des commentaires pour me dire comment l'améliorer, pour me dire ce
qu'il manque ou ce qui est mal dit.
J'espère que vous savez maintenant à quoi servent les tests, que vous ne les voyez plus comme une perte de temps et que vous
les mettrez en place.
Deux derniers liens :
Les tests unitaires en Java 29/31
www.siteduzero.com
Le sourceforge de JUnit ici;
La javadoc de JUnit ici.
Partager
Les tests unitaires en Java 30/31
www.siteduzero.com__