Accès rapide :
L'objectif du projet
Comment lister toutes le types (classes, interfaces, ...) accessibles à partir d'un ClassLoader ?
Préparation de notre jeu de tests
Implémentation du moteur de test
La classe Assert
Création d'une classe d'exceptions associée à des assertions non validées
Rappels sur les annotations utilisées par le framework de tests
Le moteur de test
Exécution de la procédure de test
Evolutions possibles
Afin de voir un cas concret d'utilisation des annotations, nous allons mettre en oeuvre notre propre framework de tests, un peu comme JUnit pour ceux qui connaissent. Le but d'un framework de test est d'automatiser l'exécution d'une procédure de test et de générer un rapport sur les résultats constatés.
La première difficulté à laquelle nous devons faire face, consiste à lister l'ensemble des types (classes, interfaces...) disponibles à partir du
« ClassLoader » de votre projet. Pour ce faire, nous allons utiliser l'API NIO2 (présente dans le package java.nio.file
).
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 |
package fr.koor.testrunner; import java.io.IOException; import java.nio.file.FileVisitResult; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.SimpleFileVisitor; import java.nio.file.attribute.BasicFileAttributes; import java.util.ArrayList; import java.util.List; public class TestRunner { /** * Cette méthode renvoi tous les types (classes, interfaces...) accessibles par le ClassLoader du projet. * @return Une liste contenant tous les noms de types accessibles. * @throws Exception Une exception peut être déclenchée si on ne peut * lister les éléments accessibles par le ClassLoader */ private static List<String> findAllClassesInProject() throws Exception { // On capture le ClassLoader du projet courant. ClassLoader classLoader = TestRunner.class.getClassLoader(); Path path = Paths.get( classLoader.getResource( "." ).toURI() ); int pathLength = path.toString().length() + 1; List<String> allClasses = new ArrayList<>(); // On parcourt récursivement tous les fichiers présents dans le dossier. Files.walkFileTree( path, new SimpleFileVisitor<Path>() { @Override public FileVisitResult visitFile( Path file, BasicFileAttributes attrs ) throws IOException { String relativePath = file.toString().substring( pathLength ); // On vérifie qu'il s'agit bien du fichier de byte code Java. if ( relativePath.endsWith( ".class" ) ) { String className = relativePath.replace( ".class", "" ) .replace( "/", "." ) .replace( "\\", "." ); allClasses.add( className ); } // On poursuit au fichier suivant. return FileVisitResult.CONTINUE; } }); return allClasses; } /** * Le point d'entrée de notre programme. * @param args */ public static void main( String[] args ) { try { List<String> classesNames = findAllClassesInProject(); for ( String className : classesNames ) { System.out.println( className ); } } catch( Exception exception ) { System.err.println( "Cannot scan project types" ); exception.printStackTrace(); } } } |
Voici, actuellement à quoi ressemble mon projet Eclipse.
$
correspondent aux deux classes internes (dont une anonyme) actuellement présente dans notre projet.
Et voici les résultats produits par cet exemple de code.
fr.koor.testrunner.TestRunner fr.koor.testrunner.TestRunner$1 fr.koor.testrunner.TestMethod fr.koor.testrunner.TestClass fr.koor.testrunner.TestMethod$NoExceptionExpected
Pour le code à tester, je vous propose de repartir du code de la classe Rational
que nous avions développé lors du chapitre sur
l'encapsulation et les bases de la programmation orientée objet.
Je vous laisse recopier le code de cette classe dans votre projet.
Il nous faut maintenant coder une classe correspondant à un jeu de test. Traditionnellement, on aime bien séparer les classes applicatives des classes de
tests. Nous allons faire la même chose. Cliquez avec le bouton droit de la souris sur le projet et demandez à créer un nouveau répertoire de codes sources
(assistant « Source Folder ») : appelez ce dossier test
. Ensuite, veuillez créer dans ce dossier une classe de test nommée
fr.koor.poo.RationalTest
. Voici à quoi doit ressembler votre projet à ce stade.
Nous allons maintenant écrire notre jeu de test. Pour ce faire nous allons réutiliser les deux annotations développées dans le chapitre précédent, pour marquer les classes et les méthodes à exécuter durant le déroulement de la procédure de tests. Voici à quoi cela pourrait ressembler.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
package fr.koor.poo; import fr.koor.testrunner.Assert; import fr.koor.testrunner.TestClass; import fr.koor.testrunner.TestMethod; @TestClass public class RationalTest { @TestMethod public void testAddition() { Rational r1 = new Rational( 1, 3 ); Rational r2 = new Rational( 2, 1 ); Rational result = r1.add( r2 ); Assert.assertEquals( 7, result.getNumerator() ); Assert.assertEquals( 3, result.getDenominator() ); } @TestMethod public void testSimply() { Rational r = new Rational( 5*7*11*13, 7*11*13*17 ); Assert.assertEquals( 5, r.getNumerator() ); Assert.assertEquals( 17, r.getDenominator() ); } @TestMethod( expected = RuntimeException.class ) public void testBadDenominator() { new Rational( 1, 0 ); } } |
Nous allons maintenant passer à la mise en oeuvre de notre moteur de test. Pour réaliser notre première version, nous avons besoin de trois classes et de nos deux annotations.
Si vous reprenez le code du jeu de tests présenté ci-dessus, vous pourrez remarquer les lignes de code commençant par Assert
.
Elles ont pour tâche de vérifier si une condition est réalisée. Si c'est le cas, elles ne produisent aucun message. Par contre, si l'assertion n'est pas
vraie, alors une exception (de type fr.koor.testrunner.AssertException
) sera déclenchée. Voici le code de la classe Assert
.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
package fr.koor.testrunner; public class Assert { /** * Test si les valeurs de deux paramètres sont identiques ou non. * @param expected La valeur attendue. * @param value La valeur constatée. */ public static void assertEquals( int expected, int value ) { String message = String.format( "Expected value == %d, actual value == %d", expected, value ); if ( expected != value ) throw new AssertException( message ); } // Toutes autres méthodes de test nécessaires... } |
assertNotEquals
, assertTrue
,
assertFalse
...)
Voici maintenant le code de la classe AssertException
utilisée pour indiquer au moteur de test qu'une assertion n'est pas respectée.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
package fr.koor.testrunner; public class AssertException extends RuntimeException { private static final long serialVersionUID = 3793171626258679203L; public AssertException() { super(); } public AssertException(String message) { super(message); } public AssertException(String message, Throwable cause) { super(message, cause); } } |
java.lang.RuntimeException
),
il ne sera pas nécessaire de déclarer la remontée d'une telle exception sur les méthodes de tests.
Dans le chapitre précédent, nous avions proposé les codes des annotations utilisées par notre framework.
Pour rappel, revoici le code de l'annotation TestClass
.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
package fr.koor.testrunner; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; @Retention( RetentionPolicy.RUNTIME ) @Target( ElementType.TYPE ) public @interface TestClass { String category() default "default"; } |
Et voici le code de l'annotation TestMethod
.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
package fr.koor.testrunner; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; @Retention( RetentionPolicy.RUNTIME ) @Target( ElementType.METHOD ) public @interface TestMethod { /** Permet de spécifier un éventuel type d'exception attendue */ Class<? extends Throwable> expected() default NoExceptionExpected.class; /** Un temps maximal d'exécution de notre scénario de test */ long timeout() default -1; /** Un type que nous associerons à une situation ou aucune exception n'est attendue. */ public static class NoExceptionExpected extends Throwable { private static final long serialVersionUID = -8242156681893882520L; } } |
Pour mettre en oeuvre notre moteur de test, nous allons réutiliser le code de recherche de classes à partir du ClassLoader
courant et
nous allons le compléter pour vérifier la présence des annotations. Voici le code de cette classe, quelques explications suivront.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 |
package fr.koor.testrunner; import java.io.IOException; import java.lang.reflect.Method; import java.nio.file.FileVisitResult; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.SimpleFileVisitor; import java.nio.file.attribute.BasicFileAttributes; import java.util.ArrayList; import java.util.List; import fr.koor.testrunner.TestMethod.NoExceptionExpected; public class TestRunner { private List<Class<?>> classesMetadata; /** * Constructeur de la classe TestRunner. * @param classesMetadata Les métadonnées de chaque classe de test. */ public TestRunner( List<Class<?>> classesMetadata ) { this.classesMetadata = classesMetadata; } /** * Exécute chaque méthode de test de chaque classe de test. */ public void start() throws Exception { int testCount = 0; int goodCount = 0; for (Class<?> metadata : classesMetadata) { Object testInstance = metadata.getDeclaredConstructor().newInstance(); Method [] methods = metadata.getDeclaredMethods(); for( Method method : methods ) { TestMethod annotation = method.getAnnotation( TestMethod.class ); if ( annotation == null ) continue; testCount ++; System.out.printf( "%-50s -> ", metadata.getName() + "." + method.getName() ); try { method.invoke( testInstance ); if ( annotation.expected() != NoExceptionExpected.class ) { // Si une exception était attendue => Ko System.out.println( "KO - no exception detected!" ); } else { goodCount ++; System.out.println( "Ok" ); } } catch( Exception exception ) { if ( annotation.expected() != NoExceptionExpected.class && // L'exception reçue est relative à la réflexion -> getCause() annotation.expected().isInstance( exception.getCause() ) ) { // Si l'exception était attendue => Ok goodCount ++; System.out.println( "Ok" ); } else { // Exception non prévue ou de type AssertException => Ko System.out.println( "KO !" ); exception.printStackTrace(); } } } } System.out.println( "--------------------------------------------------------" ); System.out.printf( "%d test(s) - %d good test(s) - %d bad test(s)\n", testCount, goodCount, testCount-goodCount ); } /** * Cette méthode renvoi tous les types (classes, interfaces...) accessibles par le ClassLoader du projet. * @return Une liste contenant tous les noms de types accessibles. * @throws Exception Une exception peut être déclenchée si on ne peut * lister les éléments accessibles par le ClassLoader */ private static List<String> findAllClassesInProject() throws Exception { // On capture le ClassLoader du projet courant. ClassLoader classLoader = TestRunner.class.getClassLoader(); Path path = Paths.get( classLoader.getResource( "." ).toURI() ); int pathLength = path.toString().length() + 1; List<String> allClasses = new ArrayList<>(); // On parcourt récursivement tous les fichiers présents dans le dossier. Files.walkFileTree( path, new SimpleFileVisitor<Path>() { @Override public FileVisitResult visitFile( Path file, BasicFileAttributes attrs ) throws IOException { String relativePath = file.toString().substring( pathLength ); // On vérifie qu'il s'agit bien du fichier de byte code Java. if ( relativePath.endsWith( ".class" ) ) { String className = relativePath.replace( ".class", "" ) .replace( "/", "." ) .replace( "\\", "." ); allClasses.add( className ); } // On poursuit au fichier suivant. return FileVisitResult.CONTINUE; } }); return allClasses; } /** * Le point d'entrée de notre programme. * @param args Les arguments spécifiés sur la ligne de commande (actuellement non utilisés). */ public static void main( String[] args ) { try { List<String> classesNames = findAllClassesInProject(); List<Class<?>> testClasses = new ArrayList<>(); for ( String className : classesNames ) { Class<?> metadata = Class.forName( className ); if ( metadata.getAnnotation( TestClass.class) != null ) { testClasses.add( metadata ); } } TestRunner testRunner = new TestRunner( testClasses ); testRunner.start(); } catch( Exception exception ) { System.err.println( "Cannot scan project types" ); exception.printStackTrace(); } } } |
Au niveau de la méthode main
et une fois les types de données (classes, interfaces...) localisés, on filtre ces types pour ne conserver que ceux
qui portent une annotation de type TestClass
. Une fois la sélection terminée, on démarre le moteur de tests unitaires sur cet ensemble de classes.
La méthode start
est certainement la partie de code la plus importante. C'est elle qui recherche toutes les méthodes d'une classe de tests
portant l'annotation TestMethod
. Pour chacune de ces méthodes on lance leur exécution et en fonction du résultat constaté, on met à jour
les compteurs utilisés pour la synthèse finale. Notez bien la présence des tests sur l'attribut expected
de l'annotation TestMethod
:
cela permet d'inverser les résultats dans le cas où une exception est attendue.
Voici les résultats produits par l'exécution de notre procédure de test.
fr.koor.poo.RationalTest.testAddition -> Ok fr.koor.poo.RationalTest.testSimply -> Ok fr.koor.poo.RationalTest.testBadDenominator -> Ok -------------------------------------------------------- 3 test(s) - 3 good test(s) - 0 bad test(s)
Notre framework de test pourrait être amélioré. Parmi les évolutions possibles qui me viennent à l'esprit, on peut citer :
Prise en compte de l'attribut timeout
de l'annotation TestMethod
(si le test dure trop longtemps, il est suspendu et considéré
comme étant échoué).
Parallélisation des tests pour profiter des architectures multi-coeurs.
Améliorations / Corrections
Vous avez des améliorations (ou des corrections) à proposer pour ce document : je vous remerçie par avance de m'en faire part, cela m'aide à améliorer le site.
Emplacement :
Description des améliorations :