Tutoriel pour apprendre à construire son propre framework de tests unitaires

Olivier Lafleur

Ce tutoriel montre comment construire un framework de tests à la JUnit.

Pour réagir au contenu de ce tutoriel, un espace de dialogue vous est proposé sur le forum Commentez . ♪

- Titre : Tutoriel pour apprendre à construire son propre framework de tests unitaires

- Auteur : Olivier Lafleur

- Parution : 24 juillet 2017

- Licence : Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright ® 2017 Olivier Lafleur. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. Introduction

- II. Créer les fonctions de validation

- III. Exécution de tous les tests

- IV. Ne pas planter si un test plante

- V. Messages d'erreurs et nombre de tests

- VI. Conclusion et exécution

- VII. Remerciements

I - Introduction

Une des bibliothèques les plus utilisées (si ce n'est la plus utilisée) dans le monde des programmeurs Java est JUnit. Il s'agit d'un framework de tests (pas nécessairement unitaires) qui nous permet de rédiger des tests de ce genre :

1. @Test

2. public void unTest() {

3. Pile pile = new Pile();

5. assertTrue(pile.estVide());

6. }

C'est un outil très pratique, mais pour beaucoup de développeurs, cette bibliothèque (tout comme beaucoup d'autres outils des programmeurs) a parfois un aspect magique. On se dit généralement que l'on ne pourrait certainement pas bâtir personnellement une bibliothèque de ce genre.

En informatique, les développeurs devraient comprendre (au moins à haut niveau) comment fonctionnent les outils qu'ils utilisent. On devrait utiliser les outils non pas parce que l'on ne sait pas comment les faire, mais plutôt parce que l'on n'a pas le goût de les réécrire. Dans la majorité des cas, il est beaucoup plus avantageux de réutiliser un outil déjà existant, qui a déjà été utilisé par une quantité importante de personnes, qui a été pensé, structuré et débogué depuis déjà un certain temps.

Je me suis donc mis en tête de comprendre un peu mieux comment un framework de tests pourrait être bâti.

Tout d'abord, quels sont les éléments qui composent un tel framework (bien que petit et incomplet) ? On devrait :

- avoir accès à des fonctions qui valident le résultat du test comme assertTrue ou assertEquals ;

- pouvoir dérouler tous les tests d'une classe, peu importe leur nom ou leur nombre ;

- ne pas planter si un test est en erreur (s'il lance une exception, par exemple).

La première chose que l'on pourrait faire est écrire une classe de tests telle qu'un développeur qui utilise notre framework l'écrirait.

1. import static com.olivierlafleur.testtest.MonFrameworkDeTest.verifieEgal;

2. import static com.olivierlafleur.testtest.MonFrameworkDeTest.verifieVrai;

4. public class ClasseDeTests {

5. //Ce test devrait être en erreur puisqu'il lance une exception

6. public void divisionParZeroTest() {

7. int var1 = 1;

8. int var2 = 2;

10. int res = 2/0;

12. verifieVrai(var2 > var1);

13. }

15. //Ce test devrait passer

16. public void unAutreTest() {

17. int var1 = 1;

18. int var2 = 2;

20. verifieVrai(var2 > var1);

21. }

23. //Ce test devrait échouer

24. public void unAutreTest2() {

25. int var1 = 1;

26. int var2 = 2;

28. verifieVrai(var1 > var2);

29. }

31. //Ce test devrait passer

32. public void unTestEgalite() {

33. verifieEgal(2, 2);

34. }

36. //Ce test devrait échouer

37. public void unTestInEgalite() {

38. verifieEgal(1, 2);

39. }

40. }

Alors voici donc les éléments à remarquer dans ce code.

1. Chaque test correspond à une fonction dans la classe.

2. On a accès à (au moins) deux fonctions, verifieVrai(...) et verifieEgal(...,...) qui nous permettent de dire si le test passe ou échoue.

3. Nous n'utilisons pas (pour le moment ?) d'annotation @Test comme en JUnit. Nous exécuterons simplement la classe comme un paramètre à envoyer au framework plutôt que de faire le tout via un processeur d'annotations.

II - Créer les fonctions de validation

Pour créer nos fonctions de validation génériques, nous devons donc en faire des fonctions statiques, qui seront importables dans la classe qui contient les tests.

Pour la vérification d'une condition booléenne :

1. public static void verifieVrai(boolean condition) {

2. if(condition) {

3. reussite();

4. } else {

5. echec();

6. }

7. }

Pour la vérification d'une égalité :

1. public static void verifieEgal(int attendu, int resultat) {

2. if(attendu == resultat) {

3. reussite();

4. } else {

5. fail(nomMethode + " : Attendu " + attendu + " / Obtenu " + resultat);

6. }

7. }

Pour le moment, nous ferons simplement écrire « . » lorsque le test passera et « F » lorsqu'il ne passera pas.

Une fois que cela est fait, on a donc la fonctionnalité de vérification, à savoir si un test passe ou pas.

III - Exécution de tous les tests

Pour le moment, si l'on souhaite avoir le résultat d'un test, il faut appeler directement la fonction qui contient le test par son nom. Ce que l'on vise est d'exécuter tous les tests d'une classe, peu importe leur nombre et leur nom.

Pour ce faire, il faudra utiliser la réflexivité. Il s'agit d'aller inspecter la structure d'une autre classe à l'intérieur d'un programme.

Dans ce cas-ci, ce qui nous intéresse est d'aller chercher toutes les méthodes qui sont membres de la classe. On peut le faire de la façon suivante :

1. Class c = Class.forName("ClasseDeTests.java");

2. Method[] m = c.getDeclaredMethods();

Nous avons donc maintenant accès à un tableau contenant toutes les méthodes qui sont définies dans cette classe.

Puisque l'on veut exécuter toutes les fonctions, il suffit de boucler sur le tableau, après avoir défini une instance de la classe, et exécuter chacune des méthodes de l'instance testsClass en appelant la méthode invoke(...) :

1. Constructor constructor = c.getConstructor();

2. Object testsClass = constructor.newInstance();

4. for (Method test : m) {

5. test.invoke(testsClass);

6. }

IV - Ne pas planter si un test plante

Actuellement, avec ce que nous avons fait, si jamais un test lance une exception, c'est toute l'application qui plante. Ce que l'on souhaiterait, c'est que l'erreur soit attrapée et qu'elle soit probablement aussi affichée. Par contre, il faudrait que les tests continuent à s'exécuter.

Nous allons donc entourer l'appel à invoke d'un try ... catch de cette façon :

1. for (Method test : m) {

2. try {

3. test.invoke(testsClass);

4. } catch (Exception e) {

5. fail(test.getName() + " : \n" + e.getCause().getMessage() + "\n" + printArray(e.getCause().getStackTrace()));

6. }

7. }

Ainsi, on conserve le test qui échoue, la cause et la stack trace de l'échec.

V - Messages d'erreurs et nombre de tests

Lorsqu'une exception est levée, il est facile d'écrire le nom du test qui échoue, puisque l'on est justement en train de boucler dessus. Par contre, lorsque ce n'est que la condition finale qui échoue, il peut être un peu plus ardu de détecter exactement quel test est en échec (parce qu'ultimement, ce n'est qu'une fois l'appel à la méthode de vérification fait que l'on sait si on a besoin du nom du test ou pas.).

Ainsi, on modifiera donc la fonction verifieVrai de cette façon, pour qu'elle aille chercher dans la pile d'appels de quel test on arrive :

1. public static void verifieVrai(boolean condition) {

2. if(condition) {

3. reussite();

4. } else {

5. String nomMethode = Thread.currentThread().getStackTrace()[2].getMethodName();

7. echec(nomMethode + " : Condition non vérifiée");

8. }

9. }

VI - Conclusion et exécution

Si on reprend notre exemple initial, on obtient donc le résultat suivant à l'exécution :

1. F.F.F

2. -----

3. 3 test(s) en erreur sur 5 tests exécutés

5. divisionParZeroTest :

6. / by zero

7. com.olivierlafleur.testtest.ClasseDeTests.divisionParZeroTest(ClasseDeTests.java:12)

8. sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)

9. sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)

10. sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)

11. java.lang.reflect.Method.invoke(Method.java:497)

12. com.olivierlafleur.testtest.MonFrameworkDeTest.main(MonFrameworkDeTest.java:21)

13. sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)

14. sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)

15. sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)

16. java.lang.reflect.Method.invoke(Method.java:497)

17. com.intellij.rt.execution.application.AppMain.main(AppMain.java:140)

19. unAutreTest2 : Condition non vérifiée

20. unTestInEgalite : Attendu 1 / Obtenu 2

22. Process finished with exit code 0

Bien entendu, on est loin d'un framework de tests complet et utilisable en production, mais j'espère que cela aura pu vous donner une meilleure idée de comment les framework de tests peuvent être bâtis, sans que cela ne soit de la magie noire. :)

Une amélioration potentielle de cette bibliothèque serait de faire l'exécution des tests, non pas avec une classe Main, mais avec un processeur d'annotations, comme en JUnit (qui utilise l'annotation @Test pour dire qu'il s'agit d'un test à exécuter).

Par ailleurs, vous pourrez trouver sur GitHub l'exemple complet dans lequel la classe qui contient les tests est reçue en argument à l'exécution (rendant ainsi le logiciel beaucoup plus générique).

VII - Remerciements

Cet article a été publié avec l'aimable autorisation d'[Olivier Lafleur] http://blog.olivierlafleur.com/. L'article original est disponible à cette adresse : Bâtir son propre framework de tests.

Nous tenons à remercier jacques_jean pour la relecture orthographique attentive de cet article et Mickael Baron pour la mise au gabarit.