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).
Ø Ajouter les
librairies Junit 4.12 et Hamcrest
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
étaient 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 negatifs");
}
}
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 :
Ø Le test passe
au vert =>
implémenter
la méthode suivante et son test;
Ø 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 positifs");
}
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 negatifs");
}
}
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
positifs", 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 negatifs", calc.add(a, b) == res);
}
Onglet JUnit
une fois que quelques tests passent au vert (ici 2 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 boolean enleveNom(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 int taille()
{
return taille;
}
@Override
public void effaceTout()
{
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 class MonArrayListImplTest {
@BeforeClass
public static void setUpBeforeClass() throws Exception {
}
@AfterClass
public static void tearDownAfterClass() throws Exception {
}
@Before
public void setUp() throws Exception {
}
@After
public void tearDown() throws Exception {
}
@Test
public void testMonArrayListImpl() {
fail("The test case is a prototype.");
}
@Test
public void testAjoute() {
fail("The test case is a prototype.");
}
@Test
public void testEnleveA() {
fail("The test case is a prototype.");
}
@Test
public void testEnleveNom() {
fail("The test case is a prototype.");
}
@Test
public void testAjouteA() {
fail("The test case is a prototype.");
}
@Test
public void testObtientA() {
fail("The test case is a prototype.");
}
@Test
public void testTaille() {
fail("The test case is a prototype.");
}
@Test
public void testEffaceTout() {
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 boolean enleveNom(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 int taille()
{
return taille;
}
@Override
public void effaceTout()
{
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__