Catégoriser ses tests JUnit avec Maven
J’ai eu envie de « catégoriser » mes tests JUnit, et de pouvoir ne lancer que certains d’entre eux via Maven.
Ce qui semblait une tâche relativement simple s’est avérée être en réalité un chemin semé d’embuches…
Voici mon carnet de voyage…
Le contexte
Dans l’un de mes projets, je dispose d’une grande quantité de tests unitaires.
En réalité, parmi ceux-ci, beaucoup d’entre eux ne sont pas unitaires du tout. Ils nécessitent parfois un accès à la base Oracle, parfois un contexte Spring plutôt lourd à charger, etc. Bref, ce ne sont pas là de bons tests unitaires, lancés en isolation.
Le but est donc de pouvoir séparer les tests en diverses catégories.
Afin de simplifier les choses, je ne considèrerais que deux catégories ici : @FastTest
et @SlowTest
.
Solutions envisagées
JUnit a introduit dans la version 4.8 les @Category
(voir le changelog et un exemple).
Le principe est d’annoter une classe (ou une méthode) avec @Category
, et de définir à quelle catégorie ce(s) test(s) appartient(-nent). Pour notre besoin, nous pourrions donc écrire :
// Interfaces pour catégoriser les tests... public interface FastTest { } public interface SlowTest { } @Category(SlowTest.class) public class ASlowTest { @Test public void foo() { ... } } @Category(FastTest.class) public class ARealUnitTest { @Test public void foo() { ... } }
Bref, ça s’annonce plutôt bien. Mais si l’on regarde dans le détail, pour pouvoir lancer tous les tests d’une catégorie spécifique, il faut faire :
@RunWith(Categories.class) @IncludeCategory(FastTest.class) @SuiteClasses( { ARealUnitTest.class, AnotherRealUnitTest.class }) public class FastTestsSuite { }
Et c’est là notre souci : il faut créer une Suite
(via l’annotation @SuiteClasses
) afin de définir les classes devant être exécutées. Cela nous oblige donc à maintenir une liste exhaustive des tests à lancer pour une suite donnée. Les oublis risquent donc d’être fréquents…
Se passer des Suite JUnit
Comment pallier ce problème de Suite
? Rapidement, quelques idées me viennent :
- Ecrire des
Suite
manuellement comme on vient de le dire, mais avec le risque d’en oublier. - Ecrire des
Suite
via un plugin Maven. J’en avais écrit un dans ce but il y a environ 2 ans et je pourrais m’en resservir. Mais la question est de savoir à quel moment il doit être lancé. Etant donné que mon plugin écrit ou modifie des classes de tests, on ne peut pas l’exécuter automatiquement ni n’importe quand. De plus, il se peut que certaines classes ne doivent pas être ajoutées dans ces suites, donc on ne peut pas considérer cette solution comme viable. - Se baser sur des nommages spécifiques pour les classes de test. Par exemple tous les tests vraiment unitaires portent le nom de
***FastTest.java
, les autres étant nommés***SlowTest.java
. Maven, et ses profiles, se chargeant ensuite de choisir quel pattern donné à Surefire. Bien qu’il s’agisse d’une idée intéressante, je ne suis que moyennement pour, car on risque d’avoir là aussi des oublis ou des erreurs. - Utiliser TestNG, un autre framework de tests, proposant plus de fonctionnalités que JUnit. En particulier, TestNG permet de créer des groupes sur les tests, ce qui correspond à nos
@Category
de JUnit, mais qui marchent mieux. Le plugin Maven de tests – Surefire – gère nativement le support des groupes. C’est très simple, il suffit de définir une propriété dans la configuration Maven du plugin Surefire ! Mais dans mon cas, je souhaite rester sur du JUnit, bien que cette solution soit sans doute la plus élégante… - Modifier le plugin surefire de Maven pour gérer les
@Category
de JUnit. Ne rigolez pas, j’ai essayé de le faireMais modifier un plugin du coeur de Maven n’est forcément pas une bonne idée !
Sur ces cinq solutions, j’ai donc opté pour pour la sixième !
La sixième solution : Ne pas se passer des Suite !
L’idée consiste à utiliser des Suite
de JUnit, mais que celles-ci soit générées dynamiquement.
Cette Suite
va aller chercher dans le classpath les classes de tests annotées avec une annotation particulière.
Lors de mes recherches, je suis tombé sur le projet classpathSuite. L’un des intérêts de ce projet est de pouvoir construire une Suite
en cherchant des classes présentes dans le ClassPath.
Ainsi il suffit d’écrire :
@RunWith(Categories.class) @Categories.IncludeCategory(FastTest.class) @Suite.SuiteClasses( { AllTests.class }) public class FastTestsSuite { } @RunWith(ClasspathSuite.class) public class AllTests { }
pour que classpathSuite cherche toutes les classes présentes dans le ClassPath appartenant à la catégorie FastTest
.
Magique. Sauf que si ça marche très bien avec Eclipse, cela ne fonctionne pas lorsque les tests sont lancés par Maven.
Ou alors j’ai raté quelque chose !
Donc il faut refaire quelque chose de proche, mais qui marche avec Maven !
La solution
Première étape, je créé deux annotations simples : @FastTest
et @SlowTest
.
Ces annotations vont être utilisées pour annoter des classes de tests unitaires (dans cette solution, nous n’annoterons que les classes, et pas les méthodes). Grosso-modo ça remplacera les annotations @Category(FastTest.class)
et @Category(SlowTest.class)
.
Ensuite, je crée une classe utilitaire qui va chercher dans tout le classpath les classes étant annotées par une annotation donnée (donc essentiellement @FastTest
ou @SlowTest
) et qui appartiennent à un package donné (ou un package fils).
Cela nous donne :
public final class ClasspathClassesFinder { /** * Get the list of classes of a given package name, and that are annotated by a given annotation. * * @param packageName The package name of the classes. * @param testAnnotation The annotation the class should be annotated with. * @return The List of classes that matches the requirements. */ public static Class<?>[] getSuiteClasses(String packageName, Class<? extends Annotation> testAnnotation) { try { return getClasses(packageName, testAnnotation); } catch (Exception e) { e.printStackTrace(); } return null; } /** * Get the list of classes of a given package name, and that are annotated by a given annotation. * * @param packageName The package name of the classes. * @param annotation The annotation the class should be annotated with. * @return The List of classes that matches the requirements. * @throws ClassNotFoundException If something goes wrong... * @throws IOException If something goes wrong... */ private static Class<?>[] getClasses(String packageName, Class<? extends Annotation> annotation) throws ClassNotFoundException, IOException { ClassLoader classLoader = Thread.currentThread().getContextClassLoader(); String path = packageName.replace('.', '/'); // Get classpath Enumeration<URL> resources = classLoader.getResources(path); List<File> dirs = new ArrayList<File>(); while (resources.hasMoreElements()) { URL resource = resources.nextElement(); dirs.add(new File(resource.getFile())); } // For each classpath, get the classes. ArrayList<Class<?>> classes = new ArrayList<Class<?>>(); for (File directory : dirs) { classes.addAll(findClasses(directory, packageName, annotation)); } return classes.toArray(new Class[classes.size()]); } /** * Find classes, in a given directory (recurively), for a given package name, that are annotated by a given annotation. * * @param directory The directory where to look for. * @param packageName The package name of the classes. * @param annotation The annotation the class should be annotated with. * @return The List of classes that matches the requirements. * @throws ClassNotFoundException If something goes wrong... */ private static List<Class<?>> findClasses(File directory, String packageName, Class<? extends Annotation> annotation) throws ClassNotFoundException { List<Class<?>> classes = new ArrayList<Class<?>>(); if (!directory.exists()) { return classes; } File[] files = directory.listFiles(); for (File file : files) { if (file.isDirectory()) { classes.addAll(findClasses(file, packageName + "." + file.getName(), annotation)); } else if (file.getName().endsWith(".class")) { // We remove the .class at the end of the filename to get the class name... Class<?> clazz = Class.forName(packageName + '.' + file.getName().substring(0, file.getName().length() - 6)); // Does the file is annotated with the given annotation? if (clazz.getAnnotation(annotation) != null) { classes.add(clazz); } } } return classes; }
NB: Je me suis inspiré de ce post pour ce code, mais adapté à mes besoins.
Je n’ai pas regardé en détail, mais il est possible que le projet Reflections puisse faciliter l’écriture d’une telle classe, mais bon…
Ainsi, le code ClasspathClassesFinder.getSuiteClasses("my.company", FastTest.class)
va me retourner un tableau de classes ayant comme package ancêtre my.company
et annotées par @FastTest
.
Je construit maintenant une Suite
qui va utiliser cette précédente classe utilitaire pour me trouver les tests rapides :
public class FastTestsSuite extends org.junit.runners.Suite { public FastTestsSuite(Class<?> clazz, RunnerBuilder builder) throws InitializationError { this(builder, clazz, ClasspathClassesFinder.getSuiteClasses("my.company", FastTest.class)); } public FastTestsSuite(RunnerBuilder builder, Class<?> clazz, Class<?>[] suiteClasses) throws InitializationError { super(builder, clazz, suiteClasses); } }
Dernier élément, le point d’entrée de mes tests pour le plugin Maven Surefire :
@RunWith(FastTestsSuite.class) public class RunAllFastTests { }
Il ne me reste plus qu’à indiquer à Maven de ne prendre « que » ce test là :
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-surefire-plugin</artifactId> <configuration> <!-- Run only tests annotated with @FastTest. --> <includes> <include>**/RunAllFastTests.java</include> </includes> ... </configuration> </plugin>
Un petit bémol : si l’on travaille avec des projets multi-modules Maven (ce qui est mon cas), il faut que chaque module dispose de sa propre classe nommée RunAllFastTests
.
Concernant les SlowTest, il me suffit de définir un profile Maven qui lancera la classe RunAllSlowTests
, qui fera appel à la suite SlowTestsSuite
qui appelle ma classe utilitaire pour trouver toutes les classes annotées par @SlowTest
. Le serveur d’Intégration Continue Hudson Jenkins activant alors ce profile lors des nightly-builds.
Conclusion
Au final, il n’y a rien de bien méchant là-dedans, le principe une fois en place est simple (il suffit d’annoter la classe JUnit avec la bonne annotation). Mais je trouve cela regrettable que JUnit, pourtant très largement utilisé, soit aussi peu flexible pour un besoin somme toute très simple.
Donc si vous trouvez que j’ai sorti l’artillerie lourde pour rien, faites m’en part dans les commentaires, je suis curieux de voir comment vous vous y seriez pris !
Bonus
Suite à une suggestion de Jean-Philippe Briend, j’ai mis à disposition le code source d’un petit projet contenant toutes les classes évoquées ici.
Le projet se builde avec Maven 2 ou 3, Java 1.6 et JUnit 4.8.1.
En plus de toutes les classes nécessaires au bon fonctionnement du mécanisme, il contient trois classes de test unitaires, pour montrer le principe en action :
OneFastTest
, annotée avec@FastTest
;OneSlowTest
, annotée avec@SlowTest
;TestWithoutAnnotation
, classe non annotée. Afin de montrer que ce test n’est pas exécuté, il contient une méthode qui appelle lefail
de JUnit. Ainsi, si la classe est exécutée, le test échouera.
En lançant la commande mvn clean install
, seule la classe annotée avec @FastTest
est exécutée.
En lançant la commande mvn clean install -Djenkins=true
(ou -Pjenkins
), les deux classes annotées sont exécutées.
N’hésitez pas à me faire un retour sur ce projet si vous trouvez là aussi des énormités…
Start Slide Show with PicLens Lite
about 14 years ago
Merci beaucoup pour cette solution, bien pensé et propre
Dans mon projet nous avons exactement le problème: De centaines de tests d’intégrations qui mettent des heures à s’exécuter, tandis que nous avons aussi des tests réellement unitaires que j’aimerais déclencher lors des « compile sur checkin » dans Jenkins.
Je vais voir pour mettre en oeuvre ta solution, et te ferais un retour
about 14 years ago
@Marc Merci, ce serait effectivement sympa d’avoir un retour sur une vraie mise en place.
Je vais moi-même la mettre en application (sur un projet que tu dois connaître d’ailleurs ;o) ) et éventuellement changer des choses si cela ne me convient pas…
about 14 years ago
Salut Romain,
Comme beaucoup tu sembles déçu par l’annotation @Category proposé par JUnit. Ta solution est correcte mais si tu utilises Spring (comme tu le suggère) tu aurais pu utiliser le runner SpringJUnit4ClassRunner qui te permet de faire la même chose. J’en avais parlé ici si celà t’intéresse : http://blog.xebia.fr/2010/01/13/comment-separer-ses-tests-dintegrations/
about 14 years ago
@Nathaniel Merci pour ce lien, j’avais dû le lire l’année dernière.
Mon but était de ne pas utiliser Spring dans mon cas (même si mon application elle l’utilise), et de me limiter autant que possible sur les dépendances à utiliser (hormis JUnit 4, bien sûr). Mais au final, ça s’approche effectivement de la solution que je propose.
about 12 years ago
Si comme moi vous ne lisez ce billet que maintenant, le plugin surefire gère maintenant @Category, cf http://jira.codehaus.org/browse/SUREFIRE-656