jeudi 3 février 2011

Industrialisez vos tests fonctionnels avec Maven, JSF, Selenium RC et Jetty


Ce billet a pour but de montrer comment utiliser Maven pour organiser les tests fonctionnels de votre application web.

Quelques concepts


La méthodologie TDD ou Test Driven Development est une pratique qui incite le développeur à écrire d'abord les tests et ensuite le code du logiciel.
Pour que cette approche fonctionne réellement , le TDD doit être un processus hautement itératif.
Il s'agit de basculer de manière intensive entre l'écriture d'un fragment de code du logiciel et de son test.
Pour ma part, une heure en mode TDD me conduira à basculer plusieurs dizaines de fois entre le code du logiciel et son test.
Cette approche a de nombreux avantages :
  • le code est testé aussitôt qu'il est écrit : détection des problèmes très en amont
  • du code testé automatiquement : permet de diminuer le temps de test "fait main" et évite les erreurs humaines
  • moins d'anomalies
  • augmente la confiance du développeur dans son code
  • augmente la satisfaction du client final
La librairie JUnit est toute indiquée pour l'écriture de tests automatisés.
L'intégration avec Maven est parfaite et il existe un support de cette librairie dans tous les IDE Java dignes de ce nom.

Tests unitaires vs tests d'intégration vs tests fonctionnels


Un test unitaire va généralement tester une classe ou un ensemble très réduit de classes correspondant à un besoin fonctionnel implémenté par le logiciel. Nul besoin dans ce cas de disposer d'un serveur d'application, d'une base de données, ...
Un tests d'intégration correspond à une validation technique : mes composants techniques cohabitent-ils en bon terme ?
Un test fonctionnel teste un cas d'utilisation concret du logiciel : plusieurs composants logiciels seront alors sollicités pour réaliser le cas.
Pour ma part, je pense que les tests fonctionnels correspondent à une validation fonctionnelle : mes composants cohabitent mais rendent-ils le service attendu à l'utilisateur ?
Généralement, il devient nécessaire de déployer l'application sur un serveur d'application et de disposer d'une base de données pour les tests fonctionnels.
En effet, dans le cadre d'une application web, l'utilisateur final sera simulé via un navigateur, or un navigateur ne sait pas invoquer directement les composants de votre application. Par construction, il va poster des requêtes HTTP vers un serveur d'application.

Configurer le build pour gérer les tests fonctionnels


Comme nous l'avons vu il existe bien une différence conceptuelle entre un test unitaire, un test d'intégration et un test fonctionnel. Cependant, la différence est peu visible au niveau technique.
JUnit peut donc être utilisé pour vos tests fonctionnels.
Cependant, Maven propose une gestion automatisée des tests implémentés avec JUnit.
Tout d'abord les tests doivent se trouver dans la src/test/java pour le code etsrc/test/resources pour les ressources associées.
Ensuite, Maven joue ces tests à un moment précis du cycle de vie du projet : avant la phase "package" qui consiste à produire un livrable (JAR, WAR, EAR, ...).
Ils vont donc se déclencher lors d'un mvn package ou mvn install. C'est le plugin maven surefire qui joue les tests.
Le problème est que pour jouer vos tests fonctionnels il est souvent indispensable de déployer le livrable.
Par défaut, tous les tests contenus dans src/test/java vont s'exécuter lors de la phase test avant la phase package.Le processus par défaut proposé par Maven ne convient pas.
Maven a bien prévu une phase integration-test mais il n'y a pas de comportements par défaut de Maven qui permettent de jouer nos tests d'intégration : le plugin surefire est attaché à la phase test.
La solution consiste à séparer les tests unitaires des tests fonctionnels.
Pour cela, il suffit de marquer nos classes de tests fonctionnels et d'utiliser ce marqueur pour indiquer à Maven de ne pas prendre ces tests en compte pendant la phase "test" puis de les prendre en compte pendant la phase "integration-test".
Comme nous l'avons vu c'est le plugin surefire qui prend en charge les tests écrits avec JUnit.
Le marqueur consiste à utiliser un nom de package contenant le mot functional pour toutes les classes de tests fonctionnels.
Nous allons donc le configurer comme suit :
<build>
        <plugins>
                <plugin>
                        <groupId>org.apache.maven.plugins</groupId>
                        <artifactId>maven-surefire-plugin</artifactId>
                        <configuration>
                                <excludes>
                                        <exclude>**/functional/**</exclude>
                                </excludes>
                        </configuration>
                        <executions>
                                <execution>
                                        <id>run-funct</id>
                                        <phase>integration-test</phase>
                                        <goals>
                                                <goal>test</goal>
                                        </goals>
                                        <configuration>
                                                <excludes>
                                                        <exclude>*</exclude>
                                                </excludes>
                                                <includes>
                                                        <include>**/functional/**</include>
                                                </includes>
                                        </configuration>
                                </execution>
                        </executions>
                </plugin>
Maven permet d'attacher l'exécution d'un goal d'un plugin à une phase du cycle de vie.
Ici, nous déclenchons le goal test du plugin surefire lors de la phase integration-test.

Configurer le build pour démarrer un serveur d'application


Nous choisirons Jetty pour l'exemple.
Le plugin Jetty permet de démarrer et d'arrêter le serveur à des moments choisis du cycle de vie.
Ici, nous utiliserons les phases pre-integration-test et post-integration-test pour respectivement démarrer et stopper le conteneur :
       <plugin>
                 <groupId>org.mortbay.jetty</groupId>
                <artifactId>maven-jetty-plugin</artifactId>
                <version>6.1.22</version>
                <configuration>
                    <scanIntervalSeconds>0</scanIntervalSeconds>
                    <daemon>true</daemon>
                    <stopPort>9966</stopPort>
                    <stopKey>foo</stopKey>
                </configuration>
                <executions>
                    <execution>
                        <id>start</id>
                        <phase>pre-integration-test</phase>
                        <goals>
                            <goal>run-exploded</goal>
                        </goals>
                    </execution>
                    <execution>
                        <id>stop</id>
                        <phase>post-integration-test</phase>
                        <goals>
                            <goal>stop</goal>
                        </goals>
                    </execution>
                </executions>
       </plugin>
La commande mvn clean package devrait démarrer le serveur Jetty, déployer la webapp courante, puis arrêter le serveur.

Configurer le build pour démarrer un serveur Selenium


Le serveur Selenium reçoit les commandes que votre test jouera pour piloter le navigateur. Nous pouvons contrôler le démarrage et l'arrêt du serveur en utilisant les même phases que pour le serveur d'application :
 <plugin>
        <groupId>org.codehaus.mojo</groupId>
        <artifactId>selenium-maven-plugin</artifactId> 
        <version>1.0.1</version> 
        <executions>
                <execution>
                        <id>start</id>
                        <phase>pre-integration-test</phase>
                        <goals>
                                <goal>start-server</goal>
                        </goals>
                        <configuration>
                                <background>true</background>
                        </configuration>
                </execution>
                <execution>
                        <id>stop</id>
                        <phase>post-integration-test</phase>
                        <goals>
                                <goal>stop-server</goal>
                        </goals>
                </execution>
        </executions>
 </plugin>

Ecrire un test fonctionnel


JSF utilise des IDs dynamiques lorsque les éléments HTMLs sont rendus côté client.
Un id JSF représente un chemin jusqu'au composant JSF nommé qui se trouve dans l'arbre de composant représentant la vue.
Considérons la page suivante :
<html xmlns="http://www.w3.org/1999/xhtml"
        xmlns:f="http://java.sun.com/jsf/core"
        xmlns:h="http://java.sun.com/jsf/html">
<head>
  <title>Login !</title>
</head>
<body>
  <f:view>
    <h1><h:outputText value="Welcome !" /></h1>
    <br/>
    <br/>
    <h:form id="loginForm">
        Login : <h:inputText id="login" value="#{loginBackingBean.login}"/><br/>
        Password : <h:inputText id="passwd" value="#{loginBackingBean.password}"/><br/>
        <br/>
        <h:commandButton label="Log in" action="#{loginBackingBean.login}" value="Log in"/>
   </h:form>
   </f:view>
</body>
</html>
Ainsi, on va retrouver les ids composants parent, grand-parent , etc ... dans l'id du composant courant :
<?xml version="1.0" encoding="UTF-8"?> 
<html xmlns="http://www.w3.org/1999/xhtml"><head><title>Login !</title></head><body><h1>Welcome !</h1><br /><br /> 
<form id="loginForm" name="loginForm" method="post" action="/jsf-maven-selenium/faces/index.jspx" enctype="application/x-www-form-urlencoded"> 
<input type="hidden" name="loginForm" value="loginForm" /> 
 
        Login : <input id="loginForm:login" type="text" name="loginForm:login" /><br /> 
        Password : <input id="loginForm:passwd" type="text" name="loginForm:passwd" /><br /
login identifie mon composant, loginForm correspond au composant père.
L'inconvénient majeur de cette pratique est qu'un déplacement même mineur du composant dans la vue ou un changement dans les composants englobants impactera l'id côté navigateur.
L'API Java Selenium RC (Remote Control) permet de sélectionner un objet du DOM de différentes manières.
Par défaut il faut nommer un objet par son nom complet, cependant, Selenium RC propose plusieurs stratégies de sélection d'un noeud du DOM.
La stratégie gagnante dans notre cas va consister à utiliser un selector XPath.
protected static String buildXPathForIdSelector(String relativeId) {
 
       StringBuilder xPathSelector = new StringBuilder("xpath=//*[contains(@id,'");
       xPathSelector.append(relativeId);
       xPathSelector.append("')]");
       return xPathSelector.toString();
}
Le test case ressemblera à cela :
package jee.architect.cookbook.tdd.functional;
...
public class LoginTestCase extends TestCase {

    private Selenium selenium;

    protected void setUp() throws Exception {

        this.selenium = new DefaultSelenium("localhost", 4444, "*firefox",
                "http://localhost:8080/jsf-maven-selenium/faces/index.jspx") {

            public void open(String url) {
                commandProcessor.doCommand("open", new String{url, "true"});
            }
        };
        selenium.start();
    }

    protected void tearDown() {
         this.selenium.stop();
        this.selenium = null;
    }

    public void testLogin() {
        this.selenium.open("http://localhost:8080/jsf-maven-selenium/faces/index.jspx");
        this.selenium.waitForPageToLoad("5000"); 

        // Write assertions 

        this.selenium.type(buildXPathForIdSelector("login"), "admin");
        this.selenium.type(buildXPathForIdSelector("passwd"), "12345678");
        this.selenium.click(buildXPathForIdSelector("log-in"));
        this.selenium.waitForPageToLoad("5000"); 

        // Write assertions
        
    }

    protected static String buildXPathForIdSelector(String relativeId) { 

        StringBuilder xPathSelector = new StringBuilder("xpath=//*[contains(@id,'");
        xPathSelector.append(relativeId);
        xPathSelector.append("')]");
        return xPathSelector.toString();
    }
}
Un simple mvn clean install déclenchera alors le processus de test fonctionnel.
Il est courant de devoir utiliser des profils pour apporter de la souplesse à votre configuration de test. Les profils vous permettront notamment de positionner des options différentes en fonction de votre environnement technique (ports, ...).
Vous trouverez un exemple complet ici, testé avec Maven 2.1.0, 2.2.1, 3.0.1.


Contrat Creative Commons
the jee architect cookbook by Olivier SCHMITT est mis à disposition selon les termes de la licence Creative Commons Paternité - Pas d'Utilisation Commerciale - Pas de Modification 3.0 Unported.