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 tous.
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ée 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 :
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 positif");
}
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);
booleanenleveNom(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;
publicMonArrayListImpl()
{
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;
booleantrouve = this.remove(pnom);
if(trouve)
{
taille--;
}
returntrouve;
}
@Override
public
void
ajouteA(int
pos,
String pnom)
{
this.add(pos, pnom);
}
@Override
publicString 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 void testTaille() {
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;
importjava.util.ArrayList;
public
class
MonArrayListImpl
extends
ArrayList
implements
MonArrayList
{
private
int
taille;
publicMonArrayListImpl()
{
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;
booleantrouve = this.remove(pnom);
if(trouve)
{
taille--;
}
returntrouve;
}
@Override
public
void
ajouteA(int
pos,
String pnom)
{
this.add(pos, pnom);
}
@Override
publicString 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__