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 :
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 :
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 :
Bon test avec JUnit !