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() :

image


 

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):

image


 

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 :

image

 

Pourquoi cette erreur ?

Effectuer la modification.

 

 

 

 

 

Après modification :

image


 

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__