"Never in the field of software development have so many owed so much to so few lines of code" - Gerard Meszaros, auteur de XUnit Test Patterns
Selon une étude de 2013, JUnit est la librairie la plus utilisée en Java (à égalité avec Slf4j). On peut retracer les racines du framework à un papier de Kent Beck datant de 1989. La version SUnit sera véritablement publiée en 1994 par ce même Kent Beck qui sera également à l’origine de la version Java (avec Eric Gamma). Si vous souhaitez en savoir plus sur l’histoire des tests, je vous conseille ces deux très bons articles : Ten Years Of Test Driven Development et A Brief History of Test Frameworks.
Place au code maintenant. Nous allons réécrire une version de JUnit de zéro, très proche du code de production, même si quelques libertés seront nécessaires pour éviter à cet article de trop s’allonger. Les noms de classes/méthodes respectent ceux de la librairie.
JUnit est publié sous licence Eclipse. Le code présenté dans cet article a été simplifié pour des raisons évidentes et n’a pas pour vocation à être utilisé en dehors de ce contexte d’apprentissage. Cet article est basé sur la version 4.11 de JUnit.
Un premier exemple
Voici une suite de tests très simple.
Lancés avec JUnit, ces tests échouent à cause du dernier test en erreur. L’objectif est maintenant de réussir à ré-exécuter cette classe mais sans l'aide de la librairie JUnit.
C’est parti !
Si on met de côté nos IDEs, le moyen le plus simple de lancer JUnit sur une classe donnée se résume à la ligne suivante :
Nous allons partir de cette même ligne et recoder chacune des classes. On commence donc par effacer les imports
et proposer une implémentation pour les deux classes concernées. (Result
et JUnitCode
)
La classe Result
regroupe les informations qui vont nous permettre d’afficher le résultat de
l’exécution, soit Green, soit Red. On retrouve donc le nombre de tests exécutés et les échecs :
Pour chaque échec, il est intéressant de connaître par exemple le nom du test et l’exception attrapée.
C’est ce que représente la classe Failure
:
Une Description
, comme son nom l'indique, décrit un test donné, reprenant son nom, son annotation
(@Test
avec l’exception attendue éventuellement), la suite auquel il appartient, etc.
Ici, on se contentera uniquement du nom mais on conservera l’abstraction représentée par Description
.
C’en est terminé pour l’objet Result
. Tournons nous à présent vers la seconde classe JUnitCore
qui joue le rôle de façade pour le reste de la librairie. Voici son implémentation.
On y retrouve les principales abstractions que l’on va ensuite décrire.
La méthode run
lève un peu plus le voile sur ce qui nous attend. Elle prend en entrée un Runner
,
élément central de la librairie puisque c’est cette classe qui se charge d’exécuter les tests tout en nous
signalant sa progression à travers différents événements (lancement du test, test échoué, test terminé, ...). Il
existe de nombreuses implémentations de Runner
pour, par exemple, supporter les tests JUnit 3,
les tests paramétrés,
les théories, etc.
Il est tout à fait possible de créer son propre Runner
comme l’ont fait Spring ou Mockito grâce à l’annotation
@RunWith
.
Tous ces runners respectent une interface similaire à :
Comment les Runners nous communiquent-ils le résultat des tests ?
La classe RunNotifier
implémente
le pattern Observer. Pour chaque événement possible, la classe RunNotifier
propose une méthode
de notification appelée par le Runner
(ex : fireTestStarted
).
Chaque listener est alors prévenu et peut réagir en conséquence. Dans notre cas, c’est notre objet Result
qui va écouter ces événements et construire pas à pas le résultat final.
Voici l'implémentation de la classe RunNotifier
.
Où RunListener
correspond à la classe suivante :
Pour que le code compile à nouveau, nous devons revenir sur notre classe Result
pour implémenter la
méthode result.createListener()
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class Result {
private int count;
private List<Failure> failures = new ArrayList<Failure>();
// ...
public RunListener createListener() {
return new Listener();
}
private class Listener extends RunListener {
@Override
public void testStarted(Description description) {
}
@Override
public void testFinished(Description description) {
count++;
}
@Override
public void testFailure(Failure failure) {
failures.add(failure);
}
}
}
- 11
- On écoute les événements reportés par le runner.
- 19
- On mémorise chaque exécution de test.
- 24
- On sauvegarde également chaque échec.
Le coeur de JUnit : le Runner
Cela nous conduit tout droit vers la dernière étape : l’implémentation du Runner
.
L’implémentation standard actuelle est la classe BlockJUnit4ClassRunner
qui hérite la plupart
des fonctionnalités de sa superclasse ParentRunner
.
Elles deux représentent près de 1000 lignes sans compter les autres classes annexes.
Nous allons donc nous éloigner du code de production actuel tout en conservant le plus possible a logique.
Commençons par une première version supportant uniquement l’annotation @Test
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public class OurSimpleClassRunner implements Runner {
private final Class<?> testClass;
private final TestIntrospector introspector;
public OurSimpleClassRunner2(Class<?> testClass) {
this.testClass = testClass;
this.introspector = new TestIntrospector(testClass);
}
public void run(RunNotifier notifier) {
List<Method> testMethods = introspector.getTestMethods(Test.class);
for (Method eachTestMethod : testMethods) {
invokeTestMethod(eachTestMethod, notifier);
}
}
private void invokeTestMethod(Method method, RunNotifier notifier) {
Description description =
Description.createTestDescription(testClass, method.getName());
try {
Object test = createTest();
notifier.fireTestStarted(description);
method.invoke(test);
} catch (Throwable t) {
Failure failure = new Failure(description, t);
notifier.fireTestFailure(failure);
} finally {
notifier.fireTestFinished(description);
}
}
private Object createTest() throws Exception {
return testClass.getConstructor().newInstance();
}
}
- 12
- On utilise une classe utilitaire pour connaître les méthodes de test à lancer.
- 25, 31, 33
- On notifie à chaque étape de l'exécution.
- 38
- On créée une nouvelle instance de notre classe de test avant chaque exécution de méthode (voir explications plus bas).
Reprenons plus en détails ces précédents points.
Le Runner
utiliser une classe TestIntrospector
pour extraire les méthodes de tests tout
en veillant à exclure les méthodes avec l’annotation @Ignore
. Voici l’implémentation de cette classe
utilitaire (inspirée de la version 4.1 de JUnit) :
JUnit garantit-il l’ordre d’exécution des tests ?
La réponse est oui mais pas l’ordre que l’on voudrait...
Comme pour notre code, JUnit utilise la méthode java.lang.Class.getDeclaredMethods()
pour extraire les méthodes annotées. La Javadoc est particulièrement explicite sur ce point :
"The elements in the array returned are not sorted and are not in any particular order"
.
En pratique, l’ordre retourné correspondait à l’ordre des méthodes dans le source mais cela
change depuis Java 7.
Pour garantir des tests reproductibles, JUnit impose un ordre par défaut (surchargeable).
Cela se passe dans le Comparator
org.junit.internal.MethodSorter.DEFAULT
:
Basé avant tout sur le hashCode
de la méthode String
, l’ordre final n’est ni
alphabétique, ni celui dans notre code source, mais est prévisible, et c’est là l’essentiel.
L'autre point concerne la création d'une nouvelle instance avant chaque exécution de test. L’intérêt a été décrit dans un post de Martin Fowler et se comprend mieux à travers un exemple :
Avec JUnit, ces deux tests passent et ce, qu’importe l’ordre d’exécution. La création d’une nouvelle instance pour chaque test garantit que ces tests travaillent sur deux listes différentes. Ce comportement n’a malheureusement pas été repris dans NUnit pour cause d’incompréhension et impossible maintenant de revenir en arrière sans causer de régressions.
Terminé !
Moins de 300 lignes auront été nécessaires pour refaire tourner nos tests. Le résultat est identique : autant de tests au vert et toujours un seul échec.
Le source complet est disponible ici.
Voici la version finale supportant également les annotations @Before
et @After
:
Et les IDEs dans tout çà ?
Prenons le cas d’Eclipse et de son plugin Java Development
Tools (JDT) qui assure le support JUnit. Ce plugin réutilise la librairie JUnit et exploite le faible
couplage offert par la classe RunNotifier
. Le plugin ajoute
un listener
qui va non seulement consolider le résultat des tests mais également mettre à jour la vue JUnit, etc.
A vous de forker
Voici quelques unes des libertés prises par notre implémentation :
-
Le calcul du runner à utiliser est plus complexe qu’une simple instanciation.
Pour en savoir plus :
org.junit.runner.JUnitCore
,org.junit.runner.Computer
,org.junit.runner.Request
. -
Notre implémentation du runner, ne reflète pas la complexité des runners actuels qui doivent par exemple
supporter d’autres annotations comme
@BeforeClass
et@AfterClass
mais aussi les Assumptions, les Categories, ... Pourquoi ne pas jeter un oeil au source pour découvrir comment ont été implémentées ces dernières évolutions. -
Les tests peuvent également s’exécuter en parallèle.
La plupart des classes présentées sont en réalité thread-safe. Quand les objets ne peuvent être immutables, c’est vers l’API
java.util.concurrent
que l’on se tourne :AtomicInteger
,CopyOnWriteArrayList
,Executors
, ...
A Retenir
- Seulement quelques lignes de code peuvent suffire à avoir un impact majeur dans le monde du développement.
-
La modélisation de toutes les abstractions (
Result
,Failure
,Description
) offre un code simple à comprendre. - L'utilisation de design patterns apporte la souplesse nécessaire pour l'utilisation de la librairie dans de nombreux contextes.