Participer au site avec un Tip
Rechercher
 

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 :

Coder un mini framework de tests unitaires avec les annotations

Coder un nouveau type d'annotation La notion de package en Java



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

L'objectif du projet

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.

nous présenterons le framework JUnit dans un futur chapitre.

Comment lister toutes le types (classes, interfaces, ...) accessibles à partir d'un ClassLoader ?

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).

l'API NIO2 est disponible depuis le Java SE 7.0. Si vous utiliser une version antérieure de Java, vous ne pourrez pas faire tourner ce programme.
 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();
        }
    }
}
On parcourt les classes accessibles à partir du ClassLoader du projet

Voici, actuellement à quoi ressemble mon projet Eclipse.

Structure actuelle du projet.
Les noms de classes contenant des caractères $ 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

Préparation de notre jeu de tests

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.

Création d'un « Source Folder » appelé test.

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 );
    }

}
Exemple d'écriture d'une classe de test basée sur notre framework de test

Implémentation du moteur de test

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.

La classe Assert

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...
}
il vous appartiendra de rajouter toutes autres méthodes statiques de vérification nécessaires à vos besoins (assertNotEquals, assertTrue, assertFalse...)

Création d'une classe d'exceptions associée à des assertions non validées

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);
    }

}
Comme il s'agit d'une « unckecked exception » (car dérivée de la classe 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.

Rappels sur les annotations utilisées par le framework 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";
    
}
L'annotation TestClass

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;     
    }
        
}
L'annotation TestMethod

Le moteur de test

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();
        }
    }
}
Notre moteur de tests unitaires.

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.

Exécution de la procédure de test

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)

Evolutions possibles

Notre framework de test pourrait être amélioré. Parmi les évolutions possibles qui me viennent à l'esprit, on peut citer :

vous pouvez télécharger le projet Eclipse correspondant à ce chapitre en activant ce lien.


Coder un nouveau type d'annotation La notion de package en Java