Magazine High tech

5 trucs et astuces pour JUnit

Publié le 23 mai 2010 par Pbernard

JUnit JUnit est le framework de test unitaires en Java. Il est désormais bien connu des développeurs Java et on le trouve intégré à la majorité des IDE, Eclipse et NetBeans en tête.

Se servir de JUnit est une chose. Ecrire des tests efficaces en est une autre. Ce post n'a pas la prétention de livrer toutes les bonnes pratiques qui entourent JUnit, mais donne quelques trucs qui peuvent servir à un moment ou à un autre.

1- Définissez une classe de base pour vos tests

Un test JUnit doit hériter de junit.framework.TestCase. Cependant, pour un projet donné, on a souvent intérêt à créer une classe de base pour tous les tests JUnit. Par exemple, pour le projet "Titato" :

/**
 * All tests inherit this class.
 */
public abstract clas TitatoTestCase extends 
    junit.framework.TestCase {
}

Puis chaque test du projet hérite de cette classe :

/**
 * Test of ASpecificClass class.
 */
public class ASpecificClassTest extends TitatoTestCase {
  (...)
}

L'intérêt ? Avoir un endroit où définir des méthodes utilitaires qui pourront être utilisées par tous les tests du projet. Par exemple, JUnit ne propose pas d'assertion pour comparer le contenu de tableaux de byte. Si on a souvent besoin d'une telle assertion, TitatoTestCase est l'endroit approprié pour définir une telle méthode : elle sera accessible depuis tous les tests.

2- Pour les larges comparaisons, préférez assertEquals(String, String)

Toutes les assertions se valent... à priori. Pourtant, il y en a une qu'Eclipse traite différemment : la comparaison de chaine. Si on compare deux chaines différentes, Eclipse effectue un diff. Très pratique lorsque les chaines sont longues.

La comparaison :

public void testStrings() {
  assertEquals(
    "This is a string\nAnd this is another one",
    "This is a string\nAnd this is another ONE!");
}

produit :

Comparaison de chaines

Pour tirer parti de cette fonctionnalité, il suffit de rappeler assertEquals(String, String), plutôt qu'une de ses variantes. Prenons une comparaison de liste :

public testGetPeopleList() {
  ArrayList<String> expected = new ArrayList<String>();
  expected.add("John");
  expected.add("Bob");
  expected.add("Paul");
  expected.add("Michael");

  assertEquals(expected, getPeopleList());
}

En exécutant le test tel quel, Eclipse livre un message peu lisible :

junit.framework.AssertionFailedError: expected:<John, Bob, Paul, Michael> but was:<John, Bob, Paul, Greg, Michael>

Il y a une différence, mais quelle est-elle au juste ?

En ramenant les listes à des chaines, on se simplifie la vie. Définissons assertEquals(ArrayList<String>, ArrayList<String>), probablement dans la classe mère des tests JUnit du projet, afin que celle-ci soit utilisable partout :

public void assertEquals(
    ArrayList<String> expected, 
    ArrayList<String> observed) {
  // Let's call assertEquals(String, String)
  assertEquals(toString(expected), toString(observed));
}

private String toString(ArrayList<String> list) {
  String result = "";
  for (String s: list) {
    result += s + "\n";
  }
  return result;
}

Cette fois-ci, l'erreur est évidente :

Assertion pour les ArrayList

Dans toString(ArrayList<String> list), il est important de noter qu'on insère des sauts de ligne entre chaque item de la liste. C'est important puisque cela va avoir un impact sur la présentation du diff. Sans cela, Eclipse affiche un diff portant sur une unique ligne. Nettement moins lisible.

3- Ne prenez pas de risque pour le test d'exception

Les tests négatifs nécessitent régulièrement de vérifier qu'on obtient une exception en cas d'appel erroné :

public void testTouchyMethod {
  try {
    // touchyMethod shall throw a SomeException
    touchyMethod();
    fail("An exception was expected");
  }
  catch(SomeException e) {
    // Correct case
  }
}

Le point faible de ce type de construction vient de l'appel à la méthode fail de JUnit, invoquée si touchyMethod retourne au lieu de lancer une exception. Cet appel est vite oublié. Si tel est le cas, le test ne sert à rien, tout en donnant l'impression inverse.

Afin d'éviter ce risque, on peut implémenter un helper :

public abstract class ExceptionTester extends TestCase {
  public abstract void runTestedAction() throws Throwable;

  public ExceptionTester(Class expectedClass) {
    try {
      runTestedAction();
    }
    catch (Throwable t) {
      assertEquals(expectedClass, t.getClass());
      return;
    }
    fail("Expected " + expectedClass + " but got no error");
  }
}

Puis, lorsqu'on a besoin de vérifier un lancement d'exception :

public void testFailingMethod() {
  new ExceptionTester(Exception.class) {
    @Override
    public void runTestedAction() throws Throwable {
      touchyMethod();
    }
  };
}

Cette seconde version n'est guère plus compacte, mais là n'est pas l'intérêt. Les avantages :

  • Par le jeu de la complétion des IDE modernes, elle est beaucoup plus rapide à saisir. Notamment, Eclipse va déclarer runTestedAction de lui-même.
  • Cette forme est plus sûre : pas d'appel à fail à oublier.

4- Utilisez des fichiers sans dépendre de leur chemin absolu

Un test doit pouvoir d'exécuter dans tout environnement. Lorsqu'on a besoin d'utiliser un fichier à partir d'un test, il est tentant d'utiliser un chemin absolu mais nous savons tous où cela mène...

La solution généralement recommandée est d'utiliser getResource et getResourceAsStream :

InputStream is = this.getClass().getResourceAsStream("data.txt");

C'est un début. Utiliser ces méthodes directement présente néanmoins une difficulté. Comme elles prennent comme référence le répertoire de la classe, le test JUnit dans notre cas, on doit souvent se fendre d'un conséquent "../../../.." avant d'atteindre le répertoire ciblé.

Une bonne technique est de régler ces problème une fois pour toute, dans la classe mère des tests JUnit du projet :

public InputStream getResourceAsStream(String path) 
    throws IOException {
  // Get the path of the JUnit test base class
  URL url = SampleTestCase.class.getResource(
    "TitatoTestCase.class");
  // Get the directory of the class
  // (ie. the TitatoTestCase.class file)
  // This method assume that TitatoTestCase is not located 
  // in a JAR file
  File file = new File(url.getPath());
  // Here, the "/../" depends on the project layout
  return new FileInputStream(file.getParent() + "/../" + path);
}

Cette méthode doit être adaptée pour chaque projet :

  • "TitatoTestCase.class" dépend du nom de la classe de base des tests JUnit.
  • Dans la dernière ligne, "/../" est destiné à se placer au niveau du projet. Ce composant dépend de l'endroit où est compilée la classe ainsi que son package.

Enfin, chaque test peut ouvrir un fichier :

getResourceAsStream("test/test_data/data.txt");

5- Test de séquences complexes : utilisez des traces sous la forme de String

Certain tests consistent à vérifier que telle ou telle méthode a bien été appelée. C'est régulièrement le cas lorsqu'on implémente le design pattern Template method.

Prenons l'exemple d'une classe FileHelper. Cette classe possède une méthode process qui prend en paramètre un nom de fichier ainsi qu'un FileProcessor. FileProcessor est une interface dotée de 3 méthodes :

  • fileOpened(String fileName) : appelée lorsque le fichier à traiter est ouvert ;
  • lineRead(String line) : appelée pour chaque ligne du fichier traité ;
  • fileClosed() : appelé lorsque le fichier traité est fermé.

Lorsqu'on appelle FileHelper.process("myFile.txt", aProcessor), on s'attend à la équence suivante :

  • aProcessor.fileOpened est appelé avec "myFile.txt" ;
  • aProcessor.lineRead est appelé pour chaque ligne du fichier ;
  • aProcessor.fileClosed est appelé.

Comment tester FileHelper.process ? Une solution pratique est de tracer l'exécution au sein d'une instance de FileProcessor et de comparer cette trace avec le résultat attendu.

On commence par créer un fichier d'exemple, nommé data_for_filehelper_process.txt :

This is a sample file
to test FileHelper.process
Bye!

On implémente FileProcessor. Cette implémentation ne fait rien d'autre que sauver la trace des méthodes qui sont appelées :

public class TestProcessor implements FileProcessor {
  String trace = "";
  public void fileOpened(String fileName) {
    trace += "fileOpened(" + fileName + ")\n";
  }
  public void lineRead(String line) {
    trace += "lineRead(" + line + ")\n";
  }
  public void fileClosed() {
    trace += "fileClosed()\n";
  }
  public String getTrace() {
    return trace;
  }
}

Il ne reste plus qu'à implémenter le test :

public void testProcess() {
  TestProcessor processor = new TestProcessor();
  // For an idea of how getResourcePath should work, 
  // see previous section above
  FileHelper.process(getResourcePath(
    "test/test_data/data_for_filehelper_process.txt"), 
    processor);
  // Check the trace
  assertEquals(
    "fileOpened(data_for_filehelper_process.txt)\n" +
    "lineRead(This is a sample file)\n" +
    "lineRead(to test FileHelper.process)\n" +
    "lineRead(Bye!)\n" +
    "fileClosed()\n", 
    processor.getTrace());
}

Si FileHelper.process est buggué et omet de lire la dernière ligne du fichier, on obtiendra sous Eclipse :

Trace testing

Bon test avec JUnit !


Retour à La Une de Logo Paperblog

Dossiers Paperblog