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 faire ;) Mais 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.

ZIP

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 le fail 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 PicLens