Test Unitaire : introduction

Olivier Capuozzo

Ce document est placé sous la Licence GNU Free Documentation License, Version 1.1 ou ultérieure publiée par la Free Software Foundation.

Avec la praticiaption de Frédéric Varni

29 mai 2006

Résumé

Ce document a été réalisé sous GNU/Linux avec vim, au format docbook , mis en page avec le processeur XSLT saxon développé par Michael Kay et les feuilles de styles de Norman Walsh.


Table des matières

1. Assurance qualité
2. Préparation du projet
3. Présentation de l'outil de test
4. Plan
5. Génération d'un squelette d'une classe de test
6. Préparation des injecteurs dans la classe de test
7. Exécution des tests
8. Conception de tests unitaires
9. Cas particulier des exceptions attendues
10. Instanciation d'un OUT
11. Bonnes pratiques
12. Injection d'un OUT
13. TP
14. Inversion de dépendance
15. Divers

1. Assurance qualité

Le concept de tests unitaires n'est pas une nouveauté. Depuis le début de l'informatique, tester fait partie de l'activité quotidienne d'un développeur. Ce qui est nouveau aujourd'hui, c'est que l'on place cette activité, en particulier les tests unitaires, au coeur du processus de conception (ref. Méthodes Agiles). Plusieurs raisons à cela :

  • Concevoir le test unitaire d'un service avant même d'avoir codé ce dernier, favorise la modularité (petites unitiés à tester) et la concision (car centré sur une utilité immédiate).

  • Disponibilité d'outils d'aide à la conception et exécution de tests unitaires (open source).

  • Bonne intégration de ces outils dans les ateliers de génie logiciel.

  • Qualité du code produit.

2. Préparation du projet

L'application exemple dont il est question a pour objectif de permettre à un utilisateur (aide de camp d'un administrateur d'un système) de générer un fichier unique d'utilisateurs de la forme :


<?xml version="1.0" ?>
<users>
  <user>
    <name>Bob Martin</name>
    <uid>bmartin</uid>
    <pw>12345</pw>
  </user>
  <user>
    etc.
<users>
   

Pour cela l'application doit être capable d'exploiter un document XML en lecture/écriture, de générer des identifiants et mots de passe utilisateur . Nous partirons d'une première itération de l'application (projet Eclipse) réalisée quick and dirty : GestUsersListInitial.zip.

UC

Description du cas d'utilisation : Création d'une liste d'utilisateurs

Acteur : un gestionnaire système

Postcondition : Une liste d'utilisateurs exploitable par un administrateur système.

Scénario type

  1. Le gestionnaire communique l'adresse du fichier des utilisateurs à mettre à jour

  2. Le système demande un nom et prénom d'un futur utilisateur

  3. Le gestionnaire saisit et soumet un nom et un prénom

  4. Le système ajoute à la liste des utilisateurs une nouvelle entrée composée du nom/prénom de l'utilisateur, d'un identifiant unique dans la liste et d'un mot de passe.

    -- le gestionnaire peut retourner à l'étape 2 ou arrêter le cas.

Contraintes: les identifiants (uid) et les mots de passe sont syntaxiquement valides pour le système cible.

En résumé : L'application permet à l'utilisateur de créer une liste d'utilisateurs (nom, uid, mot de passe), d'après une saisie répétée de noms et prénoms.

3. Présentation de l'outil de test

JUnit (http://www.junit.org) est un framework d'automatisation de tests unitaires écrit par Erich Gamma et Kent Beck.

Il est utilisé par des développeurs qui implémentent des tests unitaires en Java.

JUnit est un logiciel Open Source, sous licence Common Public License Version 1.0, placé sur SourceForge.

Junit est livré avec des outils permettant de lancer l'exécution des tests unitaires en mode texte ou en mode graphique.

Eclipse intègre Junit en standard. Le développeur peut donc mettre au point et exécuter des tests unitaires dans cet environnement. Pour cela il faut ajouter le chemin de junit.jar livré avec Eclipse dans le répertoire plugins.

Remarque : depuis Eclipse 1.1.x cette procédure est automatiquement assistée.

Project -> Properties -> JavaBuild Path -> Libraries -> Add Jars

Il est recommandé de créer une branche de tests parallèle aux classes (et packages) testées, nommée test.

File -> New -> Source Folder

ou bien

Project -> Properties -> JavaBuild Path -> Source -> Add Folder

4. Plan

  1. Présentation de l'application support, prise en main d'Eclipse

  2. Critique de l'architecture, identification des points pouvant faire l'objet d'un couplage faible

    Objectif : Mise en oeuvre de DIP sur le service de génération des uid.

  3. Conception d'une interface IGeneratorUid avec au moins deux opérations.

  4. Création d'un attribut geneUid de ce type dans l'application pour la sous-traitance de service

  5. Valorisation de geneUid à null

  6. Création d'une branche de test parallèle (source folder)

  7. Conception d'une classe de test unitaire pour un objet implémentant l'interface IGeneratorUid, basé sur les contrats de cette interface, par exemple IgeneratorUidTest.

  8. Injection de l'OUT MyGeneratorUid via un sous-classement de IGeneratorUidTest, par exemple MyGeneratorUidTest, par injection setter ou en redéfinissant la méthode getOUT (injection par getter).

  9. Conception d'un squelette de MyGeneratorUid (création d'un squelette par Eclipse)- le problème de l'unicité n'est pas abordé -

  10. Entrée dans un cycle d'itération

    1. Mise au point d'un test dans la classe IGeneratorUidTest

    2. Lancement des tests MyGeneratorUidTest

    3. Correction de l'objet métier MyGeneratorUid

  11. Intégration d'une instance de MyGeneratorUid dans l'application

  12. Test de l'application

5. Génération d'un squelette d'une classe de test

Présentation du projet initial (GestUsersListInitial.zip)

  1. En dehors d'Eclipse, copier GestUsersListInitial.zip dans l'espace de travail d'eclipse.

  2. Depuis Eclipse, importer un projet existant dans l'espace de travail (format archivé)

  3. Prendre connaissance de l'existant, le faire tourner.

En partant du projet initial, nous avons identifié une interface nommée IGeneratorUid (GestUsersListEtape1.zip, un projet Eclipse que vous installez avec la même procédure que précédemment). L'application soustraite maintenant la génération des uid à un objet de cette interface. Avant d'implémenter cette interface nous produisons un jeu de tests unitaires.

Remarque : La conception de tests unitaires peut nous amener à redéfinir le contrat de l'interface.

Une classe de test (branche test) est une classe java dédiée aux tests unitaires d'une classe du projet (branche src) appelée CUT (Class Under Test).

Une classe de test Junit hérite de la classe junit.framework.TestSuite. Eclipse dispose d'un assistant de création pour ces classes.

En premier lieu rendre actif l'onglet de la classe à tester afin qu'Eclipse comprenne qu'il s'agit de la classe à tester (CUT). Nous choisirons ici une interface org.vincimelun.stsig.IGeneratorUid

Puis, lancer l'assistant :File -> New -> JUnit Test Case

Figure 1. Exemple d'utilisation de l'assistant

Exemple d'utilisation de l'assistant

On ne tiendra pas compte du warning (en effet notre classe de test est une interface), car nous concevons un test générique.

[Note]Branche parallèle

Attention, sélectionner le source folder (répertoire source) : test.

Maintenant, sélectionnons les méthodes à tester :

Figure 2. Les méthodes de tests à générer

Les méthodes de tests à générer

Voici un extrait du code généré :


package org.vincimelun.stsig;

import junit.framework.TestCase;

public class IGeneratorUidTest extends TestCase {

protected void setUp() throws Exception {
  super.setUp();
}

/*
* Test method for
* org.vincimelun.stsig.IGeneratorUid.makeUid(String, String,String,int)
*/
public void testMakeUidStringStringInt() {
  // TODO Auto-generated method stub
}  

/*
* Test method for 
* org.vincimelun.stsig.IGeneratorUid.makeUid(String, String)
*/
public void testMakeUidStringString() {
  // TODO Auto-generated method stub
}

}
 

Observez comment l'assistant d'Eclipse gère le nom des tests des méthodes surchargées.

6. Préparation des injecteurs dans la classe de test

Nous venons de générer une classe de test contenant des tests vides. Avant de s'occuper de ces derniers, nous allons nous intéresser à l'objet à tester (OUT - Object Under Test) qui sera un objet de type IGeneratorUid.

Pour cela, on définit un couple getter/setter pour un objet de type IGeneratorUid.

Le setter (resp. le getter) est appelé un injecteur car il permet de valoriser (injecter) un objet à tester (OUT) dans le test générique.

 ...     
	
  protected IGeneratorUid getOut() {
    return null;
  }

  protected void setOut(IGeneratorUid geneUid) {
   
  }
  ...  

Par la suite, ces méthodes devront être marquées abstraite, et seront définies par autant de sous-classes de test qu'il y aura d'implémentation de l'interface IGeneratorUid.

Pour l'heure, contentons-nous d'excécuter ce pseudo-scénario de test.

7. Exécution des tests

Pour exécuter une suite de tests, il suffit de sélectionner (clic droit sur le nom de la classe de test dans Package Explorer) puis de lancer la commande Run As -> junit Test.

Figure 3. Résultats d'exécution des tests

Résultats d'exécution des tests

Nous observons pour la première fois la célèbre barre verte.

0n constate :

  1. Tous les tests ont été exécutés : 2/2 tests

  2. Aucune Error (erreur) n'a été rencontrée.

    Une error est une anomalie de fonctionnement non détecté par les tests.

  3. Aucun failure (échec) n'a été détecté.

    Une failure est le résultat d'un test qui ne passe pas, c'est à dire qu'une des assertions qu'il contient n'est pas vérifiée.

Dans notre cas tout ce passe à merveille, et pour cause, on n'a pas injecté d'OUT et les procédures de tests sont vides !

8. Conception de tests unitaires

Dans cette première partie, nous ne nous préoccupons pas de savoir comment notre objet à tester (OUT) est codé (implémenté), et considérons qu'il est accessible par la méthode getOUT.

Notre objectif est de vérifier les réactions de l'OUT face à des comportements attendus (logique métier, technique) que nous découperons en autant de méthodes appelées test unitaire.

Afin de profiter au mieux du framework JUnit, nous préfixons les méthodes de test par le mot test.

Un test unitaire est composé d'assertions basées sur un résultat attendu d'un service de l'OUT. Par exemple, le test ci-dessous vérifie que le générateur testé (l'OUT représenté par geneUid) retourne bien un mot de passe de la longueur souhaitée et qu'il ne comporte pas d'espace.

Voici un exemple de test unitaire :

public void testMakeUidStringStringInt() {
  // IGeneratorUid geneUid = getOut();
  String uid = geneUid.makeUid("Amadeus", "Mozart", 8);
  assertTrue("Bonne longueur attendue", uid.length() == 8);
  uid = geneUid.makeUid("Guy", "Ba", 8);
  assertTrue("Bonne longueur attendue", uid.length() <= 8 
        && uid.length() > 0);
}

public void testMakeUidStringString() {
  // IGeneratorUid geneUid = getOut();
  String uid = geneUid.makeUid("Amadeus", "Mozart");
  assertTrue("Bonne longueur attendue", uid.length() >= 1
      && uid.length() <= 12);
  assertTrue("Ne devrait pas contenir d'espace", uid.indexOf(" ") == -1);
}
      

Les méthodes d'assertions de JUnit sont surchargées (2 versions) afin de pouvoir recevoir un message (en premier argument) qui est retransmis lorsque le test ne passe pas.

[Note]Forme générale d'une assertion JUnit

Les méthodes, static, sont de la forme :

public static void assertXXX(java.lang.String message,
                             typeYYY expected,
                             typeYYY actual)
  • Le premier argument (optionnel) est un message qui sera affiché par le framework seulement si l'assertion n'est pas vérifiée.

  • Le deuxième (ou premier s'il n'y a pas de message) correspond à la valeur attendue, l'orsqu'il y a deux arguments à vérifier.

  • Le troisième correspond à la valeur actuelle (celle à tester).

Les principales opérations d'assertions sont :

  • assertTrue, assertFalse : vérifie une expression booléenne.

  • assertNull, assertNotNull : vérifie si l'argument est null ou non.

  • assertEquals : compare les deux arguments en s'appuyant sur la méthode equals.

  • assertSame : vérifie si les arguments référencent le même objet.

9. Cas particulier des exceptions attendues

Comment tester le bon fonctionnement d'une exception métier ?

Réponse : grâce à la méthode fail de JUnit. Exemple :

public void testMakeUidMauvaisParametre() {
 try {
   // IGeneratorUid geneUid = getOut();
   String uid = geneUid.makeUid("Amadeus", "Mozart", 0);
   fail("Ne devrait pas accepter une longueur inf ou egale à 0 : " + uid);
 } catch (MakeUidBadParameterException e) {
  // ok
 }  
} 
  

On place la méthode fail là où on ne souhaite pas que le pointeur d'instruction passe.

On remarquera que ce test a révélé le besoin (discutable) d'une classe exception métier qui devra être déclenchée si la valeur d'au moins un des paramètres viole la logique d'utilisation. L'interface IGeneratorUid doit être modifiée en conséquence.

10. Instanciation d'un OUT

Si nous lançons l'exécution de notre test, nous obtiendrons des Error, car la méthode getOUT rend null, il n'y a aucune implémentation à tester !.

L'exécution, tel quel, du fichier de test IGeneratorUidTest n'a pas grand intérêt !

Nous allons donc lui injecter un OUT (un objet d'une classe d'implémentation de l'interface IGeneratorUid.

Considérons que la classe MyGeneratorUid existe. Il ne nous reste alors qu'à concevoir une sous-classe de IGeneratorUidTest qui fournira un OUT au test générique :

package org.vincimelun.stsig;

public class MyGeneratorUidTest extends IGeneratorUidTest {
 
  public MyGeneratorUidTest(){
    setOut(new MyGeneratorUid());
  }
    
}
  

Bien entendu, la clase MyGeneratorUid est inconnue d'Eclipse.

Figure 4. Classe inconnue

Classe inconnue

Nous solicitons Eclipse pour résoudre ce problème (clic gauche sur la croix rouge à gauche de la ligne posant problème).

Figure 5. Classe inconnue

Classe inconnue

L'assistant d'Eclipse comprend que la classe à concevoir implémente l'interface IGeneratorUid.

Figure 6. Assistant de conception d'une classe

Assistant de conception d'une classe

On fera attention à placer la classe dans la branche source src.

Voici un exemple de résultat produit par Eclipse :

package org.vincimelun.stsig;

public class MyGeneratorUid implements IGeneratorUid {

 public String makeUid(String firstName, String name, int lenMax)
    throws MakeUidBadParameterException {
   // TODO Auto-generated method stub
   return null;
 }

 public String makeUid(String firstName, String name) {
     // TODO Auto-generated method stub
     return null;
 }
}                                 
 

Dès lors nous pouvons lancer le test MyGeneratorUidTest (clic droit, run as->Junit Test).

Figure 7. Tests lancés

Tests lancés

Nous n'avons plus d'erreurs, juste des Failures. Ceci est tout à fait normal car la classe MyGeneratorUid n'implémente aucune logique métier !

A ce niveau, votre objectif est tout trouvé : faire passer les tests (à la recherche de la barre verte...).

11. Bonnes pratiques

  1. Le code commun à tous les tests peut être factorisé directement en le placant dans les méthodes setUp et tearDown, pour, respectivement, initialiser des variables avant et exécuter d'autres instructions après l'éxécution de chacun des tests.

  2. Les tests unitaires doivent être indépendants les uns les autres, donc ne pas dépendre d'un ordre d'exécution.

    Un test unitiare ne doit donc pas provoquer d'effets de bord.

  3. Un test unitaire est court et rapide.

  4. Un test unitaire est centré sur un seul problème.

    Par exemple le test proposé testMakeUidStringString() gagnerait à être découpé en deux : testMakeUidBonneLongueur() et testMakeUidContenuSansBlanc(). Ainsi le développeur pourra -t-il plus aisément les affiner.

12. Injection d'un OUT

Comment injecter l'OUT dans la classe de test ?

Réponse : on peut utiliser un framework d'injection comme Spring, HiveMind par exemple, ou le faire à la main dynamiquement ou en déclarant une classe qui injecte (comme c'est le cas ici), soit via une factory. L'injection peut se réaliser par constructuer, setter ou getter.

13. TP

Vous trouverez ici le projet initial, version Eclipse : GestUsersListEtape1.zip. La partie Test et CUT est à développer suivant les indications ci-dessus, dans ce document.

Puis vous concevez des tests unitiares qui vérifient que :

  • Par défaut, en absence d'information sur la longueur, la méthode makeUid retournera une chaine de longueur comprise dans l'intervalle [1..12].

  • Les uid générés sont bien en minuscule.

  • Le premier caractère de l'uid généré est soit la première lettre du prénom, soit la première lettre du nom.

  • Même si le nom ou prénom contient un caractère 'blanc', l'uid généré ne contient pas de blanc.

  • L'uid généré est différent du nom ou du prénom.

  • Les uid ne contiennent pas de caractères indésirables ({}/~...) ni accent ou apostrohe, guillement...

  • ...

Modifiez l'application principale afin qu'elle s'appuie sur MyGeneratorUid pour sa gestion des uid.

(Puis, plus tard, prévoir comment assurer l'unicité des uid, puis faire de même avec la génération des mots de passe)

14. Inversion de dépendance

Comment peut-on limiter la dépendance entre l'application et la classe MyGeneratorUid ? (appliquer le principe d'inversion de dépendance)

Réponse : proposer une architecture permettant un paramétrage basé sur le couple Interface/Classes d'implémentation.

Réalisation par la prise en charge du chargement dynamique d'une classe d'implémentation dont le nom est reçu en argument de l'application.

Réalisation avec le framework Spring.

15. Divers

Kent Beck a donné le ton en définissant deux règles de base d'une approche du développement guidée par les tests :

  1. Vous ne devez écrire un nouveau code métier seulement si un test automatisé a échoué.

  2. Vous devez éliminer toute duplication de code que vous trouvez.

Une bonne entrée sur le sujet : Scott W. Ambler