mercredi 21 mars 2012

Générer un matcher Hamcrest unifié

Hamcrest propose une alternative avantageuse aux assertions de JUnit, en fournissant une bibliothèque de matchers ou prédicats pour l'écriture de règles de correspondance de façon déclarative : en cas d'échec du test, le message est beaucoup plus explicite. Une autre qualité, qui a conditionné la viabilité du projet Hamcrest, est son extensibilité : il est facile de développer ses propres prédicats et Hamcrest les prendra en charge. Pour un usage plus aisé, il est même possible de générer un matcher unique à partir d'une multitude de matchers spécialisés, ce qui évitera les imports statiques à répétition.

Rompu à l'utilisation de JUnit, je ne me suis pas laissé facilement convaincre par le recours aux assertions fondées sur Hamcrest plutôt qu'aux traditionnelles assertions natives de JUnit, même si HamcrestCore est désormais embarqué dans JUnit. Peut-être à cause de l'obligation de choisir un matcher adéquat en fonction du type de donnée à vérifier, et d'écrire l'import statique correspondant. En ce sens, j'aurais tendance à préférer dorénavant FEST-Assert, qui se présente comme un DSL (Domain-Specific Language), plus intuitif.

Mais pour en revenir au sujet de l'article, j'ai récemment mis en oeuvre le générateur de code d'Hamcrest, et comme j'ai constaté quelques imperfections, j'ai dû trouver différents remèdes.

1) Prérequis : définition d'un prédicat personnalisé

Un prédicat personnalisé est déclaré sous l'une de ces formes :
  • public class MyCustomMatcher extends org.hamcrest.BaseMatcher {
        ...
    }
  • public class MyCustomMatcher extends org.hamcrest.TypeSafeMatcher {
        ...
    }
  • public class MyCustomMatcher {
        public static org.hamcrest.Matcher factoryMethodName(final ObjectToBeMatchedClass expected) {
            return new org.hamcrest.BaseMatcher() {
                private ObjectToBeMatchedClass theExpected = expected;
                ...
            }
    }
2) Génération d'un prédicat unique

Le générateur s'appuie sur un fichier de configuration XML listant les différents prédicats personnalisés à regrouper. A l'exécution, le générateur identifie par introspection la fabrique (factory method) grâce à l'annotation @org.hamcrest.Factory, et génère une classe unique de façade pour l'utilisation de tous ces prédicats. La signature de la méthode annotée par @Factory doit impérativement être de la forme :
public static Matcher factoryMethodName(...)

NB : Sans cela, si la signature devait avoir un autre type de retour, comme celui du matcher personnalisé (par exemple : "public static MyCustomConcreteMatcher factoryMethodName(...)"), alors le type en argument ObjectToBeMatchedClass ne serait pas reporté dans le code généré et n'apparaîtrait plus dans le prédicat de façade. De cela découleraient des warnings à la compilation, notamment dans les classes qui utiliseraient le prédicat de façade.

Résultat en console :

Generating xapn.testing.hamcrest.generated.MyMatchers
                       [T] is(Matcher p0)
                       [T] is(T param1)
               [Object] is(Class p0)
[OrdinarySystem] ready()
[OrdinarySystem] powerful()
[OrdinarySystem] lessOrdinary(OrdinarySystem thanAnother)
[OrdinarySystem] moreOrdinary(OrdinarySystem thanAnother)

Remarque : Le type en argument apparaît dans les logs entre crochets. Si la signature de la fabrique n'est pas correctement formatée, cela apparaîtra dans les logs de cette manière :
                         [] factoryMethodName()
Pour remédier à cet avertissement minimaliste dans le cadre d'une chaîne de génération avec Maven, il pourrait être de bon ton de développer un Mojo qui renverrait une exception du type org.apache.maven.plugin.MojoExecutionException, voire une MojoFailureException (échec du build dans le cas d'une release par exemple).

3) Invocation du générateur dans une chaîne de génération industrialisée

Le générateur peut être très simplement appelé en ligne de commande :
java -cp hamcrest-all-1.1.jar org.hamcrest.generator.config.XmlConfigurator $config-file $source-dir $generated-class $output-dir

Voici les arguments attendus, tels qu'ils sont explicités en ligne de commande  :
Args: config-file source-dir generated-class output-dir
config-file : Path to config file listing matchers to generate sugar for.
                  e.g. path/to/matchers.xml
source-dir  : Path to Java source containing matchers to generate sugar for.
                  May contain multiple paths, separated by commas.
                  e.g. src/java,src/more-java
generated-class : Full name of class to generate.
                  e.g. org.myproject.MyMatchers
output-dir : Where to output generated code (package subdirs will be
                  automatically created).
                  e.g. build/generated-code

Il faudra toutefois avoir déjà compilé les différents matchers listés dans $config-file, puisque le générateur est en fait alimenté par leurs binaires.

Hamcrest est aujourd'hui encore construit par Ant, aussi ce ne sera pas un problème d'ajouter une tâche Ant dans le processus de build d'un projet, afin de générer le code source du prédicat de façade et de le compiler ensuite. Cela donnerait une tâche Ant comme suit pour invoquer le générateur Hamcrest, suivi d'une tâche Ant pour compiler le code source généré :
<java classname="org.hamcrest.generator.config.XmlConfigurator"
    fork="true"
    failonerror="true"
    maxmemory="128m"
    >
  <arg value="${config-file} ${source-dir} ${generated-class} ${output-dir}"/>
  <classpath>
    <pathelement location="lib/hamcrest-all-1.1.jar"/>
    <pathelement path="${java.class.path}"/>
  </classpath>
</java>

Avec Maven, c'est encore plus simple, grâce au plugin org.codehaus.mojo:exec-maven-plugin. Si l’artéfact org.hamcrest:hamcrest-all fait partie des dépendances, toujours en ligne de commande cela donne :
mvn exec:java -Dexec.mainClass="org.hamcrest.generator.config.XmlConfigurator" -Dexec.args="$config-file $source-dir $generated-class $output-dir"

En revanche, dans le cadre d'un projet multimodule Maven, la démarche est un peu plus subtile. Typiquement, vous avez écrit vos matchers personnalisés dans un module dédié, et vous souhaitez pouvoir les utiliser dans vos tests dans des modules qui constituent votre projet. Pour obtenir une bibliothèque de matchers personnalisés, vous créez un module de type jar, et tirez une dépendance vers hamcrest-all, de scope compile. Dans les autres modules de votre projet, vous pourrez dorénavant dépendre de votre bibliothèque pour vos test, avec le scope test, de même qu'avec JUnit, TestNG ou Hamcrest. Rien de compliqué jusqu'à présent, seulement du classique.
Pour générer votre bibliothèque de matchers, vous allez en revanche devoir reproduire la démarche présentée avec Ant :
  1. compilation des matchers personnalisés,
  2. invocation du générateur Hamcrest,
  3. compilation du matcher de façade généré.
C'est là que ça devient problématique, étant donné que le cycle de vie Maven spécifique du type de package jar suit les phases suivantes dans cet ordre, sans qu'il soit possible de les répéter dans le même processus de build :
  • process-resources
  • compile : compilation des matchers personnalisés, à laquelle on ajoute l'invocation du générateur.
  • process-test-resources
  • test-compile
  • test
  • package
  • install
  • deploy
En l'état, le matcher de façade ne sera pas embarqué dans le JAR de l'artéfact final, faute d'avoir été compilé. Il faut donc choisir une solution parmi ces alternatives :
  • soit on développe un nouveau cycle de vie personnalisé : ça revient un peu à tuer une mouche avec un canon ;
  • soit on génère le matcher de façade dans un autre module : simple, expéditif, mais pas très élégant, car j'aurais voulu que tous les matchers soient empaquetés dans le même JAR pour faciliter la distribution de l'artéfact ;
  • soit on s'arrange pour tout faire rentrer dans la phase compile du processus de build de l'artéfact.
Sur internet, j'ai pu lire des posts où il était question de développer un Mojo, mais le problème lié au cycle de vie reste le même. Pour ma part, j'ai réussi à mettre en oeuvre la troisième option, en créant un profil Maven "hamcrest". Voici la définition de ce profil :
  • Profil désactivé par défaut.
  • Activation du profil si le code source à générer est absent.
  • Propriétés : arguments à fournir au générateur de code Hamcrest.
  • Plugin maven-clean-plugin / goal clean, attaché à la phase clean, pour supprimer le code source généré (dans le cycle de vie Clean de Maven).
  • Plugin maven-antrun-plugin / goal run, attaché à la phase compile, pour afficher un message echo en console contenant la valeur des arguments à destination du générateur de code Hamcrest.
  • Plugin exec-maven-plugin / goal java, attaché à la phase compile, pour invoquer le générateur de code Hamcrest.
  • Plugin maven-compiler-plugin / goal compile, attaché à la phase compile, pour forcer la compilation du code source généré.
Dès lors :
  • mvn clean install, pour laisser Maven activer automatiquement le profil "hamcrest".
  • mvn clean install -Phamcrest, pour forcer l'activation du profil "hamcrest".
Pour ne pas surcharger le contenu de cet article, je n'ai pas inclus le code source des matchers, ni les POM du projet de démonstration. Vous pouvez retrouver ce contenu dans son intégralité sur mon dépôt GitHub UnitTesting4Java : la bibliothèque des matchers personnalisée est "hamcrest-sugar-generation", et le matcher unifié est utilisé dans un test du module "junit4-features", dans le package "xapn.testing.junit.hamcrest.sugarmatcher".

4) Quelques conseils et astuces

Matcher Is :
Pour des raisons de commodité, pensez à toujours ajouter le matcher org.hamcrest.core.Is dans le fichier de configuration XML du générateur de code d'Hamcrest. Comme son utilisation est fréquente, cela vous épargnera d'ajouter un import statique dans tous vos tests pour un seul prédicat.
<matchers>

    <!-- Hamcrest library -->
    <factory class="org.hamcrest.core.Is"/>

</matchers>

Pourquoi un profil ?
Un profil peut comporter des clauses d'activation, ce qui, dans ce cas, évitera de lancer inutilement une génération à chaque processus de build. Par contre, il faudra penser à activer le profil suite à une modification des matchers personnalisés.

Quelle version d'Hamcrest ?
La version d'Hamcrest embarquée dans JUnit n'est pas la dernière, il y a même longtemps qu'elle n'a pas été mise à jour. Pour bénéficier de toute la variété des matchers développés par Hamcrest, il peut être utile d'ajouter la bibliothèque HamcrestAll dans ses dépendances.

Faciliter l'écriture des imports statiques dans une classe :
L'IDE Eclipse permet d'organiser automatiquement les imports statiques par autocomplétion, ou encore grâce au raccourci Ctrl+Shift+M. Pour que cela fonctionne également avec les matchers d'Hamcrest, il convient de modifier les préférences d'Eclipse : dans Java / Editor / Content Assist / Favorites, vous pouvez ajouter soit de nouveaux types ("New Type"), soit de nouveaux membres ("New Member").
Exemples de types intéressants à ajouter aux imports statiques automatiques :
  • org.hamcrest.CoreMatchers.* (hamcrest-core ou junit) : prédicats de base fournis par JUnit et issus d'HamcrestCore.
  • org.hamcrest.Matchers.* (hamcrest-all) : ces prédicats fournis par HamcrestAll réunissent les prédicats de base de HamcrestCore, également embarqués par JUnit, ainsi que des prédicats dédiés aux collections, aux textes, aux nombres et d'autres encore.
  • org.junit.matchers.JUnitMatchers.* (junit) : le prédicat de façade de JUnit agrégeant les prédicats internes de JUnit, ainsi que les prédicats d'Hamcrest embarqués par JUnit.
Hamcrest : org.hamcrest.CoreMatchers vs org.hamcrest.Matchers ?
Qui peut le plus peut le moins, et dans ce cas, Matchers recouvre CoreMatchers.

Contribution :
Hamcrest est aujourd'hui maintenu sous Google Code, et toute contribution devrait passer par la soumission d'un patch, à attacher à une issue existante... La question de faire passer le projet sous GitHub a été soulevée.

Liens :

Aucun commentaire: