🛑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/