clavier ouvert

Blog d'enseignement d'Adrien Foucart.
Toutes les opinions présentées ici n'engagent que moi. Blog garanti sans pub, sans traqueurs, et 100% rédigé par un humain.

🛑2026.02.07
java test runner

Les tests unitaires en Java semblent assez désagréable à mettre en place. Le framework le plus utilisé semble être JUnit, et à première vue ça me semble un peu “usine à gaz”. Il y a aussi visiblement eu de gros changements entre les versions, et les tutoriels que j’ai trouvé sont pour la plupart obsolètes.

Mes besoins en tests unitaires sont assez limités. Je ne compte pas faire des tonnes de réflexion, de mocking ou autres joyeusetés. Je voudrais juste pouvoir facilement créer des classes de test, et avoir un point d’entrée d’où je peux tous les lancer automatiquement. Ca ne devrait pas nécessiter de faire tout un setup Maven ou Gradle et d’importer des jar dans tous les sens.

On va voir: faisons un petit “test runner” Java en utilisant uniquement la librairie standard.

Première étape: les tests à tester

Dans mon dossier src, où j’aurais dans un projet les “vraies” classes développées, mettons un dossier Test et créons une classe TestTest qui servira à tester le test runner. Rien de confus jusqu’à présent.

package Test;

public class TestTest {
    public static void testSuccess(){
        assert true : "if this fails, we are in trouble.";
    }

    public static void testFailed(){
        assert false : "test has failed successfully!";
    }
}

On met deux tests dedans: le premier devrait réussir, le second échouer.

Deuxième étape: lancer les tests

Ajoutons maintenant une classe TestRunner, avec une méthode main. C’est ici que toute la partie intéressante doit se passer. Commençons simplement et laissons de côté la partie “découverte des classes et méthodes de test” pour juste lancer les deux tests et vérifier que tout se passe comme prévu.

package Test;

public class TestRunner {
    public static void main(String[] args){
        TestTest.testSuccess();
        TestTest.testFailed();
    }
}

On lance… et rien ne se passe. Petit moment de recherche: par défaut, les assertions en Java sont désactivées. Pour qu’elles aient de l’effet, il faut aller modifier le DefaultAssertionStatus du ClassLoader:

public class TestRunner {
    public static void main(String[] args){
        ClassLoader loader = ClassLoader.getSystemClassLoader();
        loader.setDefaultAssertionStatus(true);

        TestTest.testSuccess();
        TestTest.testFailed();
    }
}

On relance:

Exception in thread "main" java.lang.AssertionError: test has failed successfully!

Parfait.

On continue: trouver les méthodes dans une classe

La classe Class permet de récupérer les méthodes qui existent dans une classe donnée. Imaginons donc qu’on a une liste de “classes de test”. On peut parcourir ces classes et récupérer leurs méthodes:

public class TestRunner {
    private static String[] testClasses = {"TestTest"};

    public static void main(String[] args){
        // ...
        for (String c: testClasses){
            try {
                Class testClass = Class.forName(c);
                Method[] methods = testClass.getDeclaredMethods();
                for (Method m : methods) {
                    System.out.println(m.getName());
                }
            } catch (ClassNotFoundException e) {
                System.err.println("Class not found: " + c);
                return;
            }
        }
    }

Ce qui me donne…

Class not found: TestTest

Effectivement, le “nom” de la classe doit inclure le nom du package:

    // ...
    private static String[] testClasses = {"Test.TestTest"};
    // ...

On a maintenant:

testSuccess
testFailed

Parfait.

Invoquer les méthodes et récupérer les exceptions

Reste à invoquer la méthode, ce qu’on fait avec evoke:

public class TestRunner {
    // ...
    public static void main(String[] args){
        // ...
            for (Method m : methods) {
                m.invoke(null);
            }
        // ...
    }

Mon IDE me signale que je dois traiter des possibles IllegalAccessException et InvocationTargetException. La première peut arriver si la méthode que je cherche à invoquer est privée. Ceci nous donne donc gratuitement un filtre sur notre méthode de découverte: si la méthode est privée, on ne va pas l’invoquer. Dans nos classes Test, on pourra donc utiliser ça pour écrire des méthodes “internes” dont on pourrait avoir besoin.

InvocationTargetException arrive si une exception a lieu lors de l’exécution de la méthode. Par exemple, une AssertionError si un assert est false. On a donc notre moyen de détecter si le test a échoué.

Voyons où on en est:

public class TestRunner {
    private static String[] testClasses = {"Test.TestTest"};

    public static void main(String[] args){
        ClassLoader loader = ClassLoader.getSystemClassLoader();
        loader.setDefaultAssertionStatus(true);

        for (String c: testClasses){
            try {
                Class testClass = Class.forName(c);
                Method[] methods = testClass.getDeclaredMethods();
                for (Method m : methods) {
                    try {
                        m.invoke(null);
                    } catch (IllegalAccessException e) {
                        continue;
                    } catch (InvocationTargetException e) {
                        System.err.println("❌ Test " + m.getName() + " failed (" + e.getMessage() + ")");
                        continue;
                    }
                    System.out.println("✅ Test " + m.getName() + " passed");
                }
            } catch (ClassNotFoundException e) {
                System.err.println("Class not found: " + c);
                return;
            }
        }
    }
}

Testons:

✅ Test testSuccess passed
❌ Test testFailed failed (null)

Presque bon: le message “test has failed successfully!” n’apparaît pas. Pourquoi? InvocationTargetException ne contient pas ce message, c’est — me dit la documentation — un “wrapper” autour de l’exception dans la méthode invoquée. Je dois donc récupérer d’abord la-dite exception, qui sera normalement le AssertionError.

System.err.println("❌ Test " + m.getName() + " failed (" + e.getCause().getMessage() + ")");
✅ Test testSuccess passed
❌ Test testFailed failed (test has failed successfully!)

Cette fois, c’est bon!

Découverte automatique des classes

Java ne fournit pas de moyen simple de récupérer toutes les classes présentes dans un package, mais on peut s’en sortir grâce à notre ami le ClassLoader, celui-là même qui nous permet de voir les assert.

Un certain Victor Tatai a partagé en 2007 un bout de code permettant de faire tout ça. Créons une sous-classe privée Discovery pour encapsuler toute cette logique.

public class TestRunner {
    // ...

    private class Discovery {
        private static Class[] getClasses(ClassLoader classLoader, String packageName) {
            // ...
        }
    }

On pourra ainsi appeler Discovery.getClasses(loader, "Test") pour récupérer la liste des classes de test.

La première étape est de récupérer toutes les “ressources” du package:

// dans getClasses(...)
assert classLoader != null;
List<Class> classes = new ArrayList<>();
try {
    resources = classLoader.getResources(packageName);
} catch (IOException e) {
    return classes.toArray(new Class[0]);
}

On va recevoir une liste d’URLs qui aura un seul élément dans notre cas: le chemin vers le dossier du package. Si on n’arrive pas à charger les ressources, on renvoie une liste vide. On peut parcourir les URLs et récupérer le dossier correspondant dans un objet de type File.

// ...
while (resources.hasMoreElements()) {
    URL resource = resources.nextElement();
    File directory = new File(resource.getPath());
}

Ensuite: récupérer la liste des fichiers dans le répertoire.

// ...
File[] files = directory.listFiles();
assert files != null;
for (File file : files) {
    // ...
}

Les fichiers qu’on cherche seront les fichiers .class, qui contiennent le code Java compilé. Pour ceux-là, on doit récupérer leur nom (sans le .class), et on peut ensuite charger les classes correspondantes avec Class.forName(...), en n’oubliant pas de rajouter le nom du package devant:

for (File file : files) {
    if (file.getName().endsWith(".class")) {
        String className = packageName + "." + file.getName().substring(0, file.getName().length()-6);
        classes.add(Class.forName(className));
    }
}

C’est à peu près bon, mais il faut maintenant traiter le cas où on a une arborescence un peu plus compliquée avec des sous-dossiers dans notre répertoire de test. Dans ce cas-là, file peut être aussi un dossier, et il faut alors chercher les fichiers dans ce sous-dossier. Pour pouvoir réaliser ça, on va devoir faire un peu de récursion et extraire une méthode findClassesInDirectory de notre méthode getClasses. On arrive au final à tout ceci pour la classe Discovery:

class TestRunner {
    // ...
    private class Discovery {
        private static Class[] getClasses(ClassLoader classLoader, String packageName) {
            assert classLoader != null;
            List<Class> classes = new ArrayList<>();
            Enumeration<URL> resources;
            try {
                resources = classLoader.getResources(packageName);
            } catch (IOException e) {
                return classes.toArray(new Class[0]);
            }
            while (resources.hasMoreElements()) {
                URL resource = resources.nextElement();
                File directory = new File(resource.getPath());
                classes.addAll(findClassesInDirectory(directory, packageName));
            }
            return classes.toArray(new Class[0]);
        }

        private static List<Class> findClassesInDirectory(File directory, String packageName) {
            List<Class> classes = new ArrayList<Class>();
            if (!directory.exists()) {
                return classes;
            }
            File[] files = directory.listFiles();
            assert files != null;
            for (File file : files) {
                if (file.isDirectory() && !file.getName().contains(".")) {
                    classes.addAll(findClassesInDirectory(file, packageName + "." + file.getName()));
                } else if (file.getName().endsWith(".class")) {
                    String className = packageName + "." + file.getName().substring(0, file.getName().length()-6);
                    try {
                        classes.add(Class.forName(className));
                    } catch (ClassNotFoundException e) {
                        continue;
                    }
                }
            }
            return classes;
        }
    }
}

Utilisation de la découverte automatique

On peut maintenant retirer notre attribut testClasses et utiliser la découverte automatique pour récupérer toutes les classes de test. Voyons ce que ça donne:

public class TestRunner {
    public static void main(String[] args){
        ClassLoader loader = ClassLoader.getSystemClassLoader();
        loader.setDefaultAssertionStatus(true);

        Class[] classes = Discovery.getClasses(loader, "Test");
        for (Class c : classes) {
            System.out.println(c.getName());
        }
    }
    // ...
}

On reçoit:

Test.TestRunner$Discovery
Test.TestRunner
Test.TestTest

Évidemment, le TestRunner et la sous-classe Discovery sont là aussi. Il va falloir les filtrer, puis on peut continuer comme avant.

public class TestRunner {
    public static void main(String[] args){
        ClassLoader loader = ClassLoader.getSystemClassLoader();
        loader.setDefaultAssertionStatus(true);

        Class[] classes = Discovery.getClasses(loader, "Test");
        System.out.println("Found " + (classes.length-2) + " test classes.");
        for (Class c : classes) {
            if (c.getName().startsWith(TestRunner.class.getName()))
                continue;
            System.out.println("Searching for tests in " + c.getName());
            Method[] methods = c.getDeclaredMethods();
            // ...
        }
    }
    // ...
}

On relance:

Found 1 test classes.
Searching for tests in Test.TestTest
✅ Test testSuccess passed
❌ Test testFailed failed (test has failed successfully!)

Et c’est bon! J’ai maintenant un dossier Test que je peux “dropper” dans d’autres projet pour déployer des tests unitaires simples sans me casser la tête.

Code complet sur Codeberg: https://codeberg.org/adfoucart/JavaTestRunner/