Développement dirigé par les tests : mise en pratique

David Boissier

Ce tutoriel a pour objectif de mettre en pratique le développement dirigé par les tests en construisant une petite application web. Du fait de sa taille conséquente, il sera mis à jour régulièrement. ( Commentez )

- Titre : Développement dirigé par les tests : mise en pratique

- Auteur : David Boissier

- Parution : 26 février 2009

- Mise à jour : 19 février 2020

- Licence : Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright ® 2009 David Boissier. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. Introduction

- I-A. Un peu d'histoire

- I-B. Objectifs

- I-C. Prérequis

- I-D. Cahier des charges

- I-E. Modèle de données

- I-F. Architecture de l'application et choix des frameworks

- I-G. Organisation

- II. Chapitre 1 - Préparatifs

- II-A. Création de la structure Maven

- II-B. Génération du projet sous l'IDE

- II-C. Création du script DDL de la base de données

- III. Chapitre 2 - Gestion de l'authentification

- III-A. Écriture de la couche DAO

- III-A-1. Écriture du test

- III-A-2. Exécution du test qui doit échouer

- III-A-3. Implémentation de la fonction d'authentification en base

- III-A-4. Réexécution du test

- III-A-5. Remaniement du test et de l'implémentation

- III-B. Tâche 2 - Écriture de la couche Business

- III-B-1. Écriture du test unitaire

- III-B-2. Exécution du test qui doit échouer

- III-B-3. Implémentation

- III-B-4. Réexécution du test

- III-B-5. Remaniement du test

- III-B-6. Remaniement du test (encore ?!)

- III-C. Tache 3 : Écriture de la couche Web

- III-C-1. Écriture de la page d'accueil

- III-C-1-a. Écriture du test

- III-C-1-b. Exécution du test qui doit échouer

- III-C-1-c. Implémentation

- III-C-1-d. Réexécution du test

- III-C-2. Gestion de l'authentification de la page d'accueil

- III-C-2-a. Écriture du test

- III-C-2-b. Exécution du test qui doit échouer

- III-C-2-c. Implémentation

- III-C-2-d. Exécution du test

- III-C-3. Affichage de la page principale après connexion

- III-C-3-a. Écriture du test

- III-C-3-b. Exécution du test

- III-C-3-c. Implémentation

- III-C-3-d. Exécution du test

- III-C-3-e. Remaniement des tests et de l'implémentation

- III-D. Conclusion

- III-E. Téléchargement

- IV. Chapitre 3 - Tests fonctionnels

- IV-A. Préparation du livrable

- IV-A-1. Mise à jour du pom

- IV-A-2. Écriture du fichier web.xml

- IV-B. Configurer l'environnement d'intégration

- IV-B-1. Installation et configuration du serveur de base de données

- IV-B-2. Installation et configuration du container de servlet

- IV-B-3. Initialisation du schéma de la base de données

- IV-C. Écriture et exécution du premier test fonctionnel

- IV-C-1. Introduction de Canoo Webtest

- IV-C-2. Test de connexion utilisateur

- IV-C-3. Pilotage de l'exécution de tests fonctionnels par Maven

- IV-C-4. Génération du rapport au format html

- IV-C-5. Remaniement du test fonctionnel

- IV-C-6. Remarque sur l'exécution d'une suite de tests fonctionnels

- IV-D. Conclusion

- IV-E. Téléchargement

- V. Chapitre 4 - Gestion des notes

- V-A. Écriture du test fonctionnel

- V-B. Implémentation du besoin

- V-B-1. Écriture de la couche Dao

- V-B-1-a. Écriture du test de sélection des notes d'un utilisateur

- V-B-1-b. Écriture du test de sélection d'une note

- V-B-1-c. Remaniement de la classe de test

- V-B-1-d. Écriture du test d'insertion d'une note

- V-B-2. Ecriture de la couche Service

- V-B-2-a. Écriture du test de sélection des notes de l'utilisateur

- V-B-2-b. Écriture du test d'insertion d'une note

- V-B-3. Écriture de la couche web

- V-B-3-a. Page principale des notes

- V-B-3-a-i. Écriture du test

- V-B-3-a-ii. Exécution du test

- V-B-3-a-iii. Remaniement de l'existant

- V-B-3-a-iv. Implémentation

- V-B-3-a-v. Réexécution du test

- V-B-3-b. Mise à jour d'une note

- V-B-3-b-i. Exécution du test

- V-B-3-b-ii. Implémentation

- V-B-3-b-iii. Réexécution du test

- V-B-3-c. Suppression d'une note

- V-B-3-c-i. Écriture du test

- V-B-3-c-ii. Implémentation

- V-B-4. Exécution des tests fonctionnels

- V-C. Allons un peu plus loin ...

- V-C-1. Amélioration de l'exécution des tests fonctionnels

- V-C-1-a. Écriture du test

- V-C-1-b. Exécution du test

- V-C-1-c. Implémentation

- V-C-1-d. Intégration dans le projet mynotes

- V-C-2. Amélioration de l'assertion des tableaux HTML

- V-C-2-a. Préparatifs

- V-C-2-b. Écriture du test

- V-C-2-c. Exécution du test

- V-C-2-d. Implémentation

- V-C-2-e. Intégration dans le projet mynotes

- V-C-3. Téléchargement

- V-D. Conclusion

- V-E. Téléchargement

- VI. Remerciements

I - Introduction

I-A - Un peu d'histoire

Il y a quelque temps, je travaillais pour une société informatique où se tenaient certaines conversations entre développeurs, chefs de projet et décideurs sur le test d'application. Certains d'entre eux disaient que les tests unitaires automatisés c'est rébarbatif, une perte de temps et que des tests fonctionnels effectués manuellement par un personnel adéquat suffisent amplement. Bien sûr, chaque livraison était accompagnée de son lot de bugs de régression, de non-conformité au besoin, etc. En conséquence de cela venait une phase de correction bien stressante et les testeurs chargés de relancer leurs tests manuels (vous savez, une bonne feuille Excel qui indique chaque étape à effectuer) n'en pouvaient plus. Ce genre de cycle de développement, je l'ai vécu 2 ans. Lorsque le client a haussé le ton sur la qualité de l'application livrée, les décideurs ont réagi sur le coup en demandant aux " testeurs " d'appréhender une plateforme de tests automatisés et d'écrire les tests. Concernant les développeurs, ils étaient invités à faire des heures supplémentaires pour écrire des tests unitaires. Selon moi, c'était trop tard. L'application était déjà trop grosse pour pouvoir couvrir tous les cas d'utilisation et leurs règles de gestion sachant que la fin du projet arrivait très vite.

I-B - Objectifs

Les objectifs de ce tutoriel sont d'une part de présenter et appliquer certains principes de la méthodologie XP, notamment le Développement piloté par les tests (Test Driven Development) et le remaniement d'application (Refactoring). Nous tenterons de montrer que les tests unitaires permettent :

- De contrôler et conserver la qualité du produit tout au long du projet

- De se focaliser sur l'amélioration du code, plutôt que sur les bugs

- D'améliorer la productivité de l'équipe en automatisant un maximum de tâches redondantes et ennuyantes

D'autre part, nous montrerons que l'apprentissage et maturité sur l'application viennent au fur et à mesure de son cycle de développement. L'effort sera assez conséquent lors des premières tâches ("mavenisation" du projet, intégration des frameworks applicatifs et de test), mais il se réduira pour atteindre un niveau acceptable dans le sens où il ne sera soutenu que dans l'implémentation fonctionnelle des besoins.

I-C - Prérequis

Méthodologiques

- eXtreme Programming

- Réflexions très intéressantes autour du développement dirigé par les tests

- Conception de tests unitaires avec JUnit

Techniques

- Spring : ici et là

- iBatis

- Wicket

- Maven : ici et là

- Hsqldb

I-D - Cahier des charges

Système de gestion de notes avec comme fonctions attendues :

- Authentification avec la notion d'administrateur et d'utilisateur normal

- Rôle utilisateur normal : Modification du profil et liste, ajout modification et suppression de notes

- Rôle administrateur : fonctions de liste, ajout, mise à jour et suppression des utilisateurs et de ses notes

I-E - Modèle de données

Modèle de données

I-F - Architecture de l'application et choix des frameworks

Nous suivrons un standard d'architecture des applications Web. L'application sera décomposée comme ci-dessous :

- Une couche DAO implémentée avec iBatis ;

- Une couche métier ;

- Une couche web implémentée avec Wicket ;

- L'ensemble sera configuré via Spring ;

- Le container de servlet sera Jetty ;

- La base de données sera Hsqldb ;

- La version du langage Java sera 5 (Tiger) ;

- La structure du projet suivra le standard Maven 2.

Ce tutoriel n'a pas vocation à explorer les frameworks cités ci-dessus, ils servent seulement d'illustration. Néanmoins ci-dessous quelques arguments sur le choix :

- iBatis : La mise en oeuvre est très simple et j'apprécie la séparation entre le code SQL et le code Java. Il n'est pas aussi puissant qu'Hibernate, mais ses possibilités suffisent amplement à illustrer ce tutoriel ;

- Spring : Il est inutile de répéter ce qu'apportent l'Inversion de contrôle et l'injection de dépendance. Il existe suffisamment d'articles à ce sujet ;) ;

- Wicket : J'aime bien son approche très "java centric" et orientée composants à la manière de Swing (phase de remaniement plus aisé), il respecte bien le modèle MVC et il se passe des JSP ;

- Hsqldb : Base de données embarquée qui peut d'exécuter en mode mémoire ce qui est très pratique pour les tests unitaires ;

- Jetty : Container de Servlet très compact qui nous permettra de faire des tests fonctionnels de l'application sans devoir configurer un environnement de recette ou de production.

Nous évoquerons les frameworks de test tout au long de ce tutoriel.

I-G - Organisation

La méthode XP préconise le découpage des besoins en scénarii qui seront composés de plusieurs tâches. Ainsi, en se fixant des objectifs sur du court terme, on a l'impression d'avancer rapidement. Nous allons donc organiser les scénarii de cette application en chapitres:

- Chapitre 1 : Création du projet;

- Chapitre 2 : Gestion de l'authentification;

- Chapitre 3 : Tests fonctionnels;

- Chapitre 4 : Gestion de notes;

- Chapitre 5 : Gestion des utilisateurs.

Pour chacun (hors chapitres 1 et 3) les tâches seront les suivantes :

- Tâche 1 : écriture de la couche DAO;

- Tâche 2 : écriture de la couche Métier;

- Tâche 3 : écriture de la couche WEB.

II - Chapitre 1 - Préparatifs

II-A - Création de la structure Maven

Première chose à faire, créer la structure Maven2 avec la ligne de commande suivante :

mvn archetype:create -DgroupId=com.developpez.mynotes -DartifactId=mynotes

Dans un premier temps le module aura les dépendances externes suivantes :

- ibatis (persistance)

- commons-dbcp (pool de connexion)

- hsqldb (Base de données embarquée + driver de connexion)

- DBUnit pour les Tests unitaires des DAO

- JUnit 4

<dependencies>
<!-- Persistence Layer -->
<dependency>
<groupId>org.apache.ibatis</groupId>
<artifactId>ibatis</artifactId>
<version>2.3.3.720</version>
</dependency>
<dependency>
<groupId>commons-dbcp</groupId>
<artifactId>commons-dbcp</artifactId>
<version>1.2.2</version>
</dependency>

<!-- Tests -->
<dependency>
<groupId>org.dbunit</groupId>
<artifactId>dbunit</artifactId>
<version>2.2.3</version>
<scope>test</scope>
</dependency>
<!-- Database for testing -->
<dependency>
<groupId>hsqldb</groupId>
<artifactId>hsqldb</artifactId>
<version>1.8.0.7</version>
</dependency>
<dependency>
<groupId>log4j</groupId>
<artifactId>log4j</artifactId>
<version>1.2.14</version>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.4</version>
<scope>test</scope>
</dependency>
</dependencies>

Remarque : iBatis n'est pas déployé par défaut sur les dépôts publics Maven. Ainsi il faut télécharger la version 2.2.3 sur leur site Internet et déployer à la main dans son dépôt local.

Ensuite nous allons configurer le build en indiquant les répertoires de ressources (tout fichier non Java) de l'application et de ses tests :

<resources>
<resource>
<directory>src/main/resources</directory>
</resource>
<resource>
<directory>src/main/resources-filtered</directory>
<filtering>true</filtering>
</resource>
</resources>
<testResources>
<testResource>
<directory>src/test/resources</directory>
</testResource>
<testResource>
<directory>src/test/resources-filtered</directory>
<filtering>true</filtering>
</testResource>
</testResources>

Dans la balise <plugins/> de <build> nous allons utiliser le plugin maven-compiler-plugin pour pouvoir forcer la version du jdk :

<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>1.5</source>
<target>1.5</target>
</configuration>
</plugin>

Pour intégrer le projet dans l'IDE Eclipse ou IDEA, il faut déclarer les plugins suivants :

<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-idea-plugin</artifactId>
<version>2.2</version>
<configuration>
<downloadSources>true</downloadSources>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-eclipse-plugin</artifactId>
<version>2.5.1</version>
<configuration>
<downloadSources>true</downloadSources>
</configuration>
</plugin>

Enfin, pour des besoins de métriques de qualité nous allons ajouter maven-cobertura-plugin qui lancera un rapport de couverture de test lors du lancement du goal mvn site :

<reporting>
<plugins>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>cobertura-maven-plugin</artifactId>
</plugin>
</plugins>
</reporting>

II-B - Génération du projet sous l'IDE

Générons le projet suivant l'IDE utilisé :

- IDEA : mvn idea:idea

- Eclipse : mvn eclipse:eclipse

Normalement, nous devrions avoir la structure du projet suivant :

Projet IDEA

Projet Eclipse

II-C - Création du script DDL de la base de données

mynotes.ddl

DROP TABLE UTILISATEUR IF EXISTS ;
CREATE TABLE UTILISATEUR (
USER_ID    VARCHAR(20) NOT NULL,
FIRST_NAME VARCHAR(50) NOT NULL,
LAST_NAME  VARCHAR(50) NOT NULL,
PASSWORD   VARCHAR(30) NOT NULL,
EMAIL      VARCHAR(50) NOT NULL,
IS_ADMIN   BOOLEAN     NOT NULL,
PRIMARY KEY(USER_ID)
) ;

III - Chapitre 2 - Gestion de l'authentification

Le besoin est le suivant :

- L'authentification sera effectuée en base de données

- L'utilisateur se connectera via son identifiant et son mot de passe

- La déconnexion sera également gérée.

III-A - Écriture de la couche DAO

Nous allons créer les packages suivants :

- model pour les classes JavaBean

- dao pour la couche d'accès aux données

Lors de l'écriture de la couche métier et de la couche web, nous mettrons à jour la structure. L'idée est de préparer seulement les éléments nécessaires pour l'écriture de la couche DAO.

Nous rappelons les principes du processus du développement piloté par les tests :

1. Écriture du test qui doit correspondre à un besoin concret

2. Exécution du test qui doit échouer, car l'implémentation n'est pas encore faite

3. Écriture de l'implémentation

4. Exécution du test qui doit passer

5. Remaniement éventuel du test et de l'implémentation

III-A-1 - Écriture du test

Il est important que le test soit court et surtout lisible par n'importe quel développeur. Ce sera donc un test simple où l'on assertera que l'utilisateur authentifié existe et que son identifiant, prénom, nom et email sont JohnDoe, John, Doe, john.doe@abc.com, et qu'il n'est pas administrateur.

Rappel des annotations JUnit 4 :

- @RunWith permet de spécifier un TestClassRunner particulier : dans notre cas, nous utilisons SpringJUnit4ClassRunner afin de pouvoir utiliser l'injection de dépendances de Spring.

- @Test indique que la méthode est une méthode de test à exécuter

- @Before indique que la méthode doit être exécutée avant chaque test (équivalent du setUp() dans JUnit 3.8)

- @After indique que la méthode doit être exécutée après chaque test (équivalent du tearDown() dans JUnit 3.8)

Classe de test SecurityDaoTest.java

public class SecurityDaoTest {

@Test
public void authenticateSuccess() throws Exception {
SecurityDao securityDao = new SecurityDaoImpl();

User user = securityDao.authenticate("JohnDoe", "seven");
assertNotNull("User should be not null", user);
assertEquals("JohnDoe", user.getUserId());
assertEquals("John", user.getFirstName());
assertEquals("Doe", user.getLastName());
assertEquals("john.doe@abc.com", user.getEmail());
assertFalse(user.isAdmin());
}
}

Cette classe ne compile pas, car il manque :

- Le Javabean User

- L'interface SecurityDao

- L'implémentation SecurityDaoImpl

Rectifions cela au plus vite ;) :

JavaBean User.java

public class User implements Serializable {
private String userId;
private String firstName;
private String lastName;
private String email;
private String password;
private boolean isAdmin;

/* getters et setters ont été supprimés pour plus de clarté */
...
}

Interface SecurityDao.java

public interface SecurityDao {
public User authenticate(String userId, String password);
}

Implémentation SecurityDaoImpl.java

public class SecurityDaoImpl implements SecurityDao {

public User authenticate(String userId, String password) throws SQLException {
return null;  // Todo
}
}

III-A-2 - Exécution du test qui doit échouer

Test en échec

L'écriture du test est incomplète, car il n'initialise pas la structure de la table et il ne met pas de données de test dans la base de données.

Mettons-le à jour en utilisant DBUnit, framework de test qui offre des méthodes permettant d'interagir avec la base de données : Ordres SQL, comparaison de données de table, etc. Les données à insérer sont exprimables dans un fichier XML :

Fichier de données DBUnit SecurityDaoTest.xml

<dataset>
<table name="UTILISATEUR">
<column>USER_ID</column>
<column>FIRST_NAME</column>
<column>LAST_NAME</column>
<column>PASSWORD</column>
<column>EMAIL</column>
<column>IS_ADMIN</column>
<row>
<value>JohnDoe</value>
<value>John</value>
<value>Doe</value>
<value>seven</value>
<value>john.doe@abc.com</value>
<value>false</value>
</row>
</table>
</dataset>

Nous allons utiliser la fixture DatabaseTester de DBUnit. Il faut noter que DBUnit ne gère pas nativement le type BOOLEAN de Hslqdb. Il faudra modifier la configuration de la connexion DBUnit (Classe qui wrappe la connexion JDBC) :

SecurityDaoTest.java

public class SecurityDaoTest {

@Test
public void authenticateSuccess() throws Exception {
...
}

@Before
public void setUp() throws Exception {
IDatabaseTester databaseTester =
new JdbcDatabaseTester("org.hsqldb.jdbcDriver",
"jdbc:hsqldb:mem:mynotes",
"sa", "");

//grouille pour gérer le type BOOLEAN deHSQLDB
IDatabaseConnection dbUnitConnection = databaseTester.getConnection();
DatabaseConfig config = dbUnitConnection.getConfig();
config.setProperty(DatabaseConfig.PROPERTY_DATATYPE_FACTORY,
new HsqldbDataTypeFactory());

DatabaseOperation.CLEAN_INSERT.execute(dbUnitConnection, getDataSet());
}

private IDataSet getDataSet() throws Exception {
return new XmlDataSet(getClass().getResourceAsStream("SecurityDaoTest.xml"));
}
}

N'oublions pas d'écrire le code qui va initialiser notre base avec le fichier mynotes.ddl :

SecurityDaoTest.java

public class SecurityDaoTest {

@Test
public void authenticateSuccess() throws Exception {
...
}

@Before
public void setUp() throws Exception {
IDatabaseTester databaseTester =
new JdbcDatabaseTester("org.hsqldb.jdbcDriver",
"jdbc:hsqldb:mem:mynotes",
"sa", "");

//initialisation de la base de donnees
Connection connection = dbUnitConnection.getConnection();
Statement statement = connection.createStatement();
try {
statement = connection.createStatement();
String sql = readSqlFile("/mynotes.ddl");

statement.execute(sql);
}
finally {
statement.close();
}

//grouille pour gérer le type BOOLEAN deHSQLDB
IDatabaseConnection dbUnitConnection = databaseTester.getConnection();
DatabaseConfig config = dbUnitConnection.getConfig();
config.setProperty(DatabaseConfig.PROPERTY_DATATYPE_FACTORY,
new HsqldbDataTypeFactory());

DatabaseOperation.CLEAN_INSERT.execute(dbUnitConnection, getDataSet());
}

private String readSqlFile(String fileName) throws FileNotFoundException {
StringBuilder strBuilder = new StringBuilder();
Scanner scanner = new Scanner(new File(getClass().getResource(fileName).getFile()));

while (scanner.hasNextLine()) {
String line = scanner.nextLine();
strBuilder.append(line);
}
scanner.close();

return strBuilder.toString();
}
}

Bon c'est un peu touffu, mais l'étape de remaniement n'intervient pas encore. Ré exécutons le afin de vérifier s'il fonctionne toujours, puis attaquons l'implémentation de la fonction d'authentification :

Échec du test

III-A-3 - Implémentation de la fonction d'authentification en base

L'implémentation sera faite à l'aide du framework Ibatis qui propose une séparation franche entre le code Java et le code SQL. Ce dernier est spécifié dans un fichier XML dont les requêtes SQL sont identifiées par un statementName. L'appel de ces requêtes se fait via cet identifiant, d'où l'appellation SQLMap (Mapping SQL).

Le fichier SQLMap Security.xml contiendra la requête d'authentification en base. Cette dernière retournera un objet User (attribut resultClass) et les paramètres d'entrée seront exprimés à l'aide d'une Map (attribut parameterClass). Notons que l'on peut définir un alias sur les classes utilisées dans le fichier pour alléger l'écriture des requêtes :

Fichier SQlMap Security.xml

<?xml version="1.0" encoding="UTF-8" ?>
"http://ibatis.apache.org/dtd/sql-map-2.dtd">
<sqlMap namespace="User">

<typeAlias alias="user" type="com.developpez.mynotes.model.User"/>
<typeAlias alias="map" type="java.util.Map"/>

<select id="authenticateUser" resultClass="user" parameterClass="map">
select
USER_ID as userId,
FIRSTNAME as firstName,
LASTNAME as lastName,
EMAIL as email
from UTILISATEUR
where USER_ID = #userId# and PASSWORD = #password#
</select>
</sqlMap>

Ensuite le fichier de configuration Ibatis sqlMapconfig.xml contiendra la configuration de la datasource et référencera notre fichier de mapping Security.xml :

Fichier de configuration iBatis sqlMapconfig.xml

<?xml version="1.0" encoding="UTF-8" ?>
<sqlMapConfig>

<properties resource="com/developpez/mynotes/dao/database.properties"/>

<transactionManager type="JDBC">
<dataSource type="SIMPLE">
<property name="JDBC.Driver" value="${driver}"/>
<property name="JDBC.ConnectionURL" value="${url}"/>
<property name="JDBC.Username" value="${username}"/>
<property name="JDBC.Password" value="${password}"/>
</dataSource>
</transactionManager>

<sqlMap resource="com/developpez/mynotes/dao/Security.xml"/>
</sqlMapConfig>

Le fichier de properties de la bdd database.properties contiendra les paramètres de la datasource :

Fichier de propriétés de la connexion JDBC database.properties

driver=org.hsqldb.jdbcDriver
url=jdbc:hsqldb:mem: mynotes
username=sa
password=

Enfin, l'implémentation de SecurityDao : Un SqlMapClient y sera instancié avec le fichier de configuration iBatis. L'appel du code SQL se fera à l'aide de la méthode de queryForObject("authenticateUser", parameters) :

SecurityDaoImpl.java

public class SecurityDaoImpl implements SecurityDao {

private static SqlMapClient sqlMap;

static {
try {
Reader reader = getResourceAsReader("com/developpez/mynotes/dao/sqlMapConfig.xml");
sqlMap = SqlMapClientBuilder.buildSqlMapClient(reader);
}
catch (IOException e) {
System.out.println("Erreur lors de l'initialisation du DAO = " + e.getMessage());
}
}

public User authenticate(String userId, String password) throws SQLException {
Map<String, String> parameters = new HashMap<String, String>();
parameters.put("userId", userId);
parameters.put("password", password);
return (User)sqlMap.queryForObject("authenticateUser", parameters);
}
}

III-A-4 - Réexécution du test

L'implémentation est terminée, nous pouvons relancer le test qui devrait réussir cette fois-ci :

Est-ce que tous les cas sont couverts ? Non, il manque le cas où l'utilisateur n'existe pas. Rajoutons quelques tests :

SecurityDaoTest.java

...
@Test
public void authenticateFailsWithUnknownUser() throws Exception {
SecurityDao securityDao = new SecurityDaoImpl();

User user = securityDao.authenticate("John", "seven");
assertNull("User should be null", user);
}

@Test
public void authenticateFailsWithNullParams() throws Exception {
User user = securityDao.authenticate(null, null);
assertNull("User should be null", user);
}
...

Exécutons-les tous :

III-A-5 - Remaniement du test et de l'implémentation

Nous serions tentés de dire que la tâche est finie. Cependant nous devons prendre un peu de recul face au code et nous demander si un remaniement peut être effectué afin de le rendre plus simple, plus lisible, plus flexible.

Après analyse, nous pouvons noter les points suivants :

- Dans chaque méthode de test, l'implémentation de SecurityDao est instanciée. Nous pouvons en faire une variable d'instance. L'instanciation pourrait se faire dans une méthode d'initialisation setUp().

- Ensuite, le test est fortement couplé à l'implémentation de SecurityDao, il faudrait pouvoir le rendre indépendant, et ce, afin que le test puisse être exécuté sans modification du code Java lors d'un changement d'implémentation.

- Enfin, le test et l'implémentation devraient partager la même source de données.

Nous allons utiliser l'Inversion de Contrôle et de l'Injection de Dépendance avec le module spring-test. En effet, il propose des classes de test et des annotations compatibles JUnit 4.4 qui facilitent l'écriture des tests.

Ajoutons les dépendances nécessaires :

pom.xml

...
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring</artifactId>
<version>2.5.5</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-test</artifactId>
<version>2.5.5</version>
<scope>test</scope>
</dependency>
...

Nous allons configurer la datasource via Spring avec le fichier suivant :

applicationContext-dao.xml

<?xml version="1.0" encoding="UTF-8"?>
<beans>
<bean id="propertyConfigurer"
class="org.springframework.beans.factory.config.PropertyPlaceholderConfigurer">
<property name="location" value="classpath:/com/developpez/mynotes/dao/database.properties"/>
</bean>

<!-- DAO Components Configuration -->
<bean id="dataSource" class="org.apache.commons.dbcp.BasicDataSource">
<property name="driverClassName" value="${driver}"/>
<property name="url" value="${url}"/>
<property name="username" value="${username}"/>
<property name="password" value="${password}"/>
</bean>

<!-- DAO Stack -->
<bean id="securityDao" class="com.developpez.mynotes.dao.SecurityDaoImpl">

</bean>
</beans>

Ensuite réécrivons le test unitaire à la sauce Spring JUnit 4 avec les annotations Spring :

- @ContextConfiguration(locations = ...) précise les fichiers de configuration Spring

- @Autowired indique à Spring les variables de classes sur lesquelles il va rattacher les instances des objets déclarés dans les fichiers de configuration.
Par exemple, dans notre classe de test, nous indiquons que la variable d'instance securityDao accueillera une implémentation de SecurityDao. Il en sera de même pour notre DatabaseTester

Nous en profitons pour découper l'initialisation en petites méthodes afin d'améliorer la lecture :

SecurityDaoTest.java

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(locations = {"applicationContext-dao.xml", "applicationContext-dao-test.xml"})
public class SecurityDaoTest {

@Autowired
private SecurityDao securityDao;

@Autowired
private DataSourceDatabaseTester databaseTester;

@Test
public void authenticateSucceed() throws Exception {
User user = securityDao.authenticate("JohnDoe", "seven");
...
}

//Autres tests
...

@Before
public void setUp() throws Exception {
initDatabase();
initDataSet();
}

private void initDatabase() throws Exception {
IDatabaseConnection dbUnitConnection = databaseTester.getConnection();
Connection connection = dbUnitConnection.getConnection();
Statement statement = connection.createStatement();
try {
statement = connection.createStatement();
String sql = readSqlFile("/mynotes.ddl");

statement.execute(sql);
}
finally {
statement.close();
}
}

private void initDataSet() throws Exception {
IDatabaseConnection dbUnitConnection = databaseTester.getConnection();
DatabaseConfig config = dbUnitConnection.getConfig();
config.setProperty(DatabaseConfig.PROPERTY_DATATYPE_FACTORY,
new HsqldbDataTypeFactory());
DatabaseOperation.CLEAN_INSERT.execute(dbUnitConnection, getDataSet());
}

private XmlDataSet getDataSet() throws DataSetException {
...
}

private String readSqlFile(String fileName) throws FileNotFoundException {
...
}
}

Le fichier applicationContext-dao-test.xml contiendra la déclaration du bean DatabaseTester :

applicationContext-dao-test.xml

<?xml version="1.0" encoding="UTF-8"?>
<beans>
<bean id="databaseTester" class="org.dbunit.DataSourceDatabaseTester">
<constructor-arg index="0" ref="dataSource"/>
</bean>
</beans>

Réexécutons les tests :

Exécution de la classe de test réussie

En relisant le test, cela reste encore un peu lourd. Nous allons extraire une classe abstraite qui sera réutilisée pour nos futurs scénarii. L'élément variable finalement est le fichier SecurityDaoTest.xml. Nous déclarerons donc une méthode abstraite getDataSetFile(). Enfin, le fichier applicationContext-dao-test.xml sera déclaré dans cette classe.

Classe de test AbstractDaoTestCase.java

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(locations = {"applicationContext-dao-test.xml"})
public abstract class AbstractDaoTestCase {

@Autowired
private DataSourceDatabaseTester databaseTester;

protected abstract String getDataSetFile();

@Before
public void setUp() throws Exception {
initDatabase();
initDataSet();
}

private void initDatabase() throws Exception {
...
}

private void initDataSet() throws Exception {
...
}

private XmlDataSet getDataSet() throws DataSetException {
...
}

private String readSqlFile(String fileName) throws FileNotFoundException {
...
}
}

En conséquence, la classe de test SecurityDaoTest subira quelques modifications :

SecurityDaoTest.java

@ContextConfiguration(locations = {"applicationContext-dao.xml"})
public class SecurityDaoTest extends AbstractDaoTestCase {

@Autowired
private SecurityDao securityDao;

@Test
public void authenticateSucceed() throws Exception {
...
}

@Test
public void authenticateFailsWithUnkownUser() throws Exception {
...
}

@Test
public void authenticateFailsWithNullParams() throws Exception {
...
}

@Override
protected String getDataSetFile() {
return "SecurityDaoTest.xml";
}
}

Les tests passent encore. La classe de test SecurityDaoTest semble plus simple à lire. L'importance d'extraire des classes abstraites est que cela permet de mettre en évidence et d'isoler un comportement redondant. Par ailleurs, lorsque le développeur écrira une nouvelle classe de test, il se focalisera sur l'écriture de ses tests et ne perdra pas de temps à mettre en place la configuration de leur exécution.

À présent, nous allons remanier l'implémentation du DAO : la classe SqlMapClient devrait à présent être gérée par Spring et la datasource devrait être aussi utilisée par l'implémentation.

Mise à jour du fichier applicationContext-dao.xml :

applicationContext-dao.xml

<?xml version="1.0" encoding="UTF-8"?>
<beans>
<bean id="propertyConfigurer"
class="org.springframework.beans.factory.config.PropertyPlaceholderConfigurer">
<property name="location" value="classpath:/com/developpez/mynotes/dao/database.properties"/>
</bean>

<!-- DAO Components Configuration -->
<bean id="dataSource" class="org.apache.commons.dbcp.BasicDataSource">
<property name="driverClassName" value="${driver}"/>
<property name="url" value="${url}"/>
<property name="username" value="${username}"/>
<property name="password" value="${password}"/>
</bean>
<bean id="sqlMapClient"
class="org.springframework.orm.ibatis.SqlMapClientFactoryBean">
<property name="configLocation">
<value>classpath:/com/developpez/mynotes/dao/sqlMapConfig.xml</value>
</property>
<property name="dataSource">
<ref bean="dataSource"/>
</property>
</bean>

<!-- DAO Stack -->
<bean id="securityDao" class="com.developpez.mynotes.dao.SecurityDaoImpl">
<property name="sqlMapClient">
<ref bean="sqlMapClient"/>
</property>
</bean>
</beans>

Le fichier sqlMapConfig.xml se simplifie en ne référençant que les fichiers de mapping SQL :

sqlMapConfig.xml

<?xml version="1.0" encoding="UTF-8" ?>
<sqlMapConfig>
<sqlMap resource="com/developpez/mynotes/dao/Security.xml"/>
</sqlMapConfig>

Ensuite, utilisons le template Ibatis proposé par spring-orm pour mettre à jour la classe SecurityDaoImpl :

SecurityDaoImpl.java

public class SecurityDaoImpl extends SqlMapClientTemplate implements SecurityDao {

public User authenticate(String userId, String password) throws SQLException {
Map<String, String> parameters = new HashMap<String, String>();
parameters.put("userId", userId);
parameters.put("password", password);
return (User)queryForObject("authenticateUser", parameters);
}
}

Nous pouvons remarquer que l'implémentation se simplifie grandement.

Pour ceux qui préfèrent garder l'indépendance du code applicatif vis-à-vis de Spring, l'implémentation sera presque similaire à la première version :

SecurityDaoImpl.java (sans spring-orm)

public class SecurityDaoImpl implements SecurityDao {

private SqlMapClient sqlMapClient;

public User authenticate(String userId, String password) throws SQLException {
Map<String, String> parameters = new HashMap<String, String>();
parameters.put("userId", userId);
parameters.put("password", password);
return (User)sqlMapClient.queryForObject("authenticateUser", parameters);
}

public void setSqlMapClient(SqlMapClient sqlMapClient) {
this.sqlMapClient = sqlMapClient;
}
}

Réexécutons les tests unitaires qui doivent toujours passer :

Résumé des tâches effectuées pour la couche Dao :

- Nous avons écrit le test unitaire de l'authentification BDD avec DBUnit,

- Nous avons exécuté le test qui a échoué,

- Nous avons implémenté une première version de SecurityDaoImpl avec Ibatis,

- Le test est passé,

- Nous avons intégré spring-test pour la classe de test et nous avons extrait une classe de test abstraite,

- Le test a continué à passer,

- Nous avons intégré spring-orm pour l'implémentation SecurityDaoImpl,

- Le test a continué à passer

Ce qui est important de retenir est que nous avons modelé notre code au fur est à mesure. Un des principes de XP est qu'il faut d'abord produire un code qui marche avant de penser à son remaniement/optimisation éventuel ("Do The Simplest Thing That Could Possibly Work"). Le test unitaire garantit la maîtrise du processus du développement (on sait rapidement où et quand le bug apparaît) et la robustesse du code.

III-B - Tâche 2 - Écriture de la couche Business

La couche Business ou Service est celle où " en général " sont effectués les traitements fonctionnels. Par exemple, lors de l'envoi d'un email, il y a l'opération " Envoyer un email via SMTP ", puis stockage de l'email dans la base de données. Nous allons mettre à jour de la structure en rajoutant un package business sous com.developpez.mynotes.

III-B-1 - Écriture du test unitaire

Il sera très similaire à celui de la couche DAO :

Classe de test SecurityServiceTest.java

public class SecurityServiceTest {

@Test
public void authenticateSuccess() throws Exception {
SecurityService securityService = new SecurityServiceImpl();
User user = securityService.authenticate("JohnDoe", "seven");
assertNotNull("User should not be null",user);
}
}

Il nous manque l'interface SecurityService et son implémentation SecurityServiceImpl :

Interface SecurityService.java

public interface SecurityService {
public User authenticate(String userId, String password) throws Exception;
}

Implémentation SecurityServiceImpl.java

public class SecurityServiceImpl implements SecurityService {

public User authenticate(String userId, String password) throws Exception {
return null;
}
}

III-B-2 - Exécution du test qui doit échouer

Test authenticateSucess en échec

III-B-3 - Implémentation

Ce sera très simple. Il suffit d'invoquer la méthode authenticate(String, String) de SecurityDao :

SecurityServiceImpl.java

public class SecurityServiceImpl implements SecurityService {

private SecurityDao securityDao;

public User authenticate(String userId, String password) throws Exception {
return securityDao.authenticate(userId, password);
}
}

III-B-4 - Réexécution du test

Dans le cas d'une petite application, nous pourrions directement appeler l'implémentation de SecurityDao. L'ordre d'écriture des couches est arbitraire, mais imaginons que l'implémentation de SecurityDao n'est pas prête, il faut pouvoir avancer malgré ce handicap. Nous allons utiliser la technique de mocking (simulation).

Les Mock (simulacres) sont utilisés pour pouvoir tester une fonctionnalité sans devoir utiliser et configurer tous les modules externes avec qui elle doit communiquer. On simule les réponses attendues en fonction de conditions prédéfinies. Dans l'exemple de l'envoi d'un email, on simulerait l'envoi et la réponse du serveur SMTP.

Notre test veut récupérer un utilisateur JohnDoe dont le mot de passe est 'seven'. Implémentons notre mock à la "mano" ;) :

SecurityDaoMock.java

public class SecurityDaoMock implements SecurityDao{

public User authenticate(String userId, String password) throws Exception {
return null;  // Todo
}
}

Nous allons créer un dictionnaire d'utilisateur indexé sur son identifiant :

SecurityDaoMock.java

public class SecurityDaoMock implements SecurityDao{

private static final Map<String, User> users = new HashMap<String, User>();

static {
users.put("JohnDoe",
buildUser("JohnDoe", "John", "Doe", "john.doe@abc.com", "seven", false));
}

public User authenticate(String userId, String password) throws SQLException {
User user = users.get(userId);
if (user != null && user.getPassword().equals(password)) {
return user;
}
return null;
}

private static User buildUser(String userId, String firstName, String lastName, String email,
String password, boolean isAdmin) {
User user = new User();
user.setUserId(userId);
user.setFirstName(firstName);
user.setLastName(lastName);
user.setEmail(email);
user.setPassword(password);
user.setAdmin(isAdmin);
return user;
}
}

À présent, ajoutons un setter sur securityDao de SecurityServiceImpl :

SecurityServiceImpl.java

public class SecurityServiceImpl implements SecurityService {

...

public void setSecurityDao(SecurityDao securityDao) {
this.securityDao = securityDao;
}
}

Dans notre test, settons le mock :

SecurityServiceTest.java

public class SecurityServiceTest {

@Test
public void authenticateSuccess() throws Exception {
SecurityService securityService = new SecurityServiceImpl();
((SecurityServiceImpl) securityService).setSecurityDao(new SecurityDaoMock());
User user = securityService.authenticate("JohnDoe", "seven");
assertNotNull("User should not be null", user);
}
}

Enfin, exécutons notre test :

Test authenticate passe

Nous pouvons rajouter les tests d'échec et les exécuter :

SecurityServiceTest.java

public class SecurityServiceTest {

@Test
public void authenticateSuccess() throws Exception {
SecurityService securityService = new SecurityServiceImpl();
((SecurityServiceImpl)securityService).setSecurityDao(new SecurityDaoMock());
User user = securityService.authenticate("JohnDoe", "seven");
assertNotNull("User should not be null", user);
}

@Test
public void authenticateFailsWithBadPassword() throws Exception {
SecurityService securityService = new SecurityServiceImpl();
((SecurityServiceImpl)securityService).setSecurityDao(new SecurityDaoMock());
User user = securityService.authenticate("JohnDoe", "six");
assertNull("User should not be null", user);
}

@Test
public void authenticateFailsWithUnknownUser() throws Exception {
SecurityService securityService = new SecurityServiceImpl();
((SecurityServiceImpl)securityService).setSecurityDao(new SecurityDaoMock());
User user = securityService.authenticate("JohnLennon", "imagine");
assertNull("User should be null", user);
}

@Test
public void authenticateFailsWithNullParams() throws Exception {
SecurityService securityService = new SecurityServiceImpl();
((SecurityServiceImpl)securityService).setSecurityDao(new SecurityDaoMock());
User user = securityService.authenticate(null, null);
assertNull("User should be null", user);
}
}

III-B-5 - Remaniement du test

L'implémentation étant simple, nous allons nous focaliser sur le remaniement du test. Nous pouvons laisser spring gérer les dépendances en écrivant 2 configurations :

Nous reprenons notre fichier applicationContext-dao-test.xml en lui ajoutant notre mock :

applicationContext-dao-test.xml

<?xml version="1.0" encoding="UTF-8"?>
<beans>
...
<!-- DAO Stack -->
<bean id="securityDao" class="com.developpez.mynotes.dao.SecurityDaoMock">
</bean>
</beans>

Le fichier applicationContext-business.xml contiendra la déclaration de notre service :

applicationContext-business.xml

<?xml version="1.0" encoding="UTF-8"?>
<beans>
<bean id="securityService" class="com.developpez.mynotes.business.SecurityServiceImpl">
<property name="securityDao">
<ref bean="securityDao"/>
</property>
</bean>
</beans>

Mettons à jour le test pour qu'il se lance dans un contexte Spring :

SecurityServiceTest.java

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(locations = {"applicationContext-business.xml", "../dao/applicationContext-dao-test.xml"})
public class SecurityServiceTest {

@Autowired
private SecurityService securityService;

@Test
public void authenticateSuccess() throws Exception {
User user = securityService.authenticate("JohnDoe", "seven");
assertNotNull("User should not be null", user);
}

@Test
public void authenticateFailsWithBadPassword() throws Exception {
User user = securityService.authenticate("JohnDoe", "six");
assertNull("User should not be null", user);
}

@Test
public void authenticateFailsWithUnknownUser() throws Exception {
User user = securityService.authenticate("JohnLennon", "imagine");
assertNull("User should be null", user);
}

@Test
public void authenticateFailsWithNullParams() throws Exception {
User user = securityService.authenticate(null, null);
assertNull("User should be null", user);
}
}

Ainsi en couplant les 2 configurations, Spring rattache le securityDaoMock à la variable securityDao de SecurityService. Nous pouvons ré-exécuter les tests :

III-B-6 - Remaniement du test (encore ?!)

Nous pouvons nous passer de l'écriture d'un mock en utilisant des API dédiées. Plusieurs librairies de mock existent, les plus célèbres sont EasyMock, JMock. Nous allons étudier la librairie Mockito dont la syntaxe est très plaisante.

Cette librairie permet d'intercepter l'appel d'une méthode d'une classe et de simuler le renvoi spécifique d'un résultat en fonction des paramètres fournis. Dans le vocabulaire Mockito, c'est un stub (bouchon).

Dans notre cas nous avons besoin de simuler authenticate(String, String) de SecurityDao.

Tout d'abord, mettons à jour notre pom en rajoutant la dépendance Mockito :

pom.xml

...
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito</artifactId>
<version>1.5</version>
<scope>test</scope>
</dependency>
...

Ensuite nous allons modifier la classe de test. Pour cela nous déclarons le mock avec l'annotation @Mock. Ensuite dans une méthode @Before, nous allons remplacer le dao par le mock et les stubs correspondant aux tests :

SecurityServiceTest.java

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(locations = {"applicationContext-business.xml", "../dao/applicationContext-dao.xml"})
public class SecurityServiceTest {

@Mock
private SecurityDao securityDaoMock;

@Autowired
private SecurityService securityService;

... // les tests ont été enlevés ici pour plus de clarté

@Before
public void setUp() throws SQLException {
MockitoAnnotations.initMocks(this);

ReflectionTestUtils.setField(securityService, "securityDao", securityDaoMock);

Mockito.stub(securityDaoMock.authenticate("JohnDoe", "seven"))
.toReturn(buildUser("JohnDoe", "John", "Doe", "john.doe@abc.com", "seven", false));
}

private static User buildUser(String userId, String firstName, String lastName, String email,
String password, boolean isAdmin) {
User user = new User();
user.setUserId(userId);
user.setFirstName(firstName);
user.setLastName(lastName);
user.setEmail(email);
user.setPassword(password);
user.setAdmin(isAdmin);
return user;
}
}

Nous avons donc indiqué à la méthode stub() que lorsque la méthode authenticate() du SecurityDaoMock est invoquée avec les paramètres "JohnDoe" et "seven" alors il faut retourner un objet User (via la méthode toReturn() ). Dans les autres cas, Mockito retourne null.

Nous pouvons remarquer l'indépendance des tests par rapport à l'implémentation de la classe à tester. Cela évite l'écriture d'un mock et d'une configuration Spring spécifique au test.

III-C - Tache 3 : Écriture de la couche Web

À partir de cette tâche, nous utiliserons directement Spring pour les tests et pour la configuration, mais rien ne vous empêche d'écrire le test et le code en plusieurs versions ;).

Nous allons mettre à jour la structure en rajoutant un package web sous com.developpez.web et nous rajouter les dépendances Wicket dans le pom.xml :

pom.xml

...
<dependency>
<groupId>org.apache.wicket</groupId>
<artifactId>wicket</artifactId>
<version>1.3.4</version>
</dependency>
<dependency>
<groupId>org.apache.wicket</groupId>
<artifactId>wicket-ioc</artifactId>
<version>1.3.4</version>
<exclusions>
<exclusion>
<groupId>org.springframework</groupId>
<artifactId>spring</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.apache.wicket</groupId>
<artifactId>wicket-extensions</artifactId>
<version>1.3.4</version>
</dependency>
<dependency>
<groupId>org.apache.wicket</groupId>
<artifactId>wicket-spring-annot</artifactId>
<version>1.3.4</version>
</dependency>
...

Une remarque importante sur le couple Wicket + Spring : Sur le site officiel, Erik Van Oosten explique la difficulté qu'il a eue lorsqu'il a voulu ajouter le support de Spring dans Wicket :

- Wicket ne gère pas le cycle de vie de ses composants, car une page (ou composant) peut être instancié(e) à n'importe quel moment dans le code applicatif, ce qui rend difficile l'injection de dépendance, car Spring ne peut pas intercepter la création du composant dans ce cas là.

- Les composants et modèles Wicket sont souvent sérialisés. Il explique que Wicket conserve la hiérarchie des composants en session (un des inconvénients de wicket). Dans un environnement distribué, les données en session sont répliquées de cluster en cluster par le mécanisme de sérialisation/désérialisation. Si l'injection de dépendance est appliquée, en remontant tout l'arbre des dépendances des composants à sérialiser, on serait presque amené à sérialiser le container Wicket et, lors de la phase de désérialisation, on obtiendrait un clone du container et non le container d'origine ce qui n'est pas acceptable.

Plusieurs solutions ont été envisagées, celle qui semble simple à mettre en oeuvre est d'utiliser le module wicket-spring-annot avec l'annotation spécifique @SpringBean et la classe SpringComponentInjector.

- SpringComponentInjector est une classe qui va récupérer le contexte Spring de l'application.

- @SpringBean indiquera à SpringComponentInjector les champs à setter.

Nous allons découper la tâche en 3 sous-tâches :

- Écriture de la page d'accueil

- Gestion de l'authentification de la page d'accueil

- Affichage de la page principale après connexion

III-C-1 - Écriture de la page d'accueil

III-C-1-a - Écriture du test

Nous allons d'abord faire un test sur la page d'accueil par défaut : cette page contiendra un petit formulaire de connexion.

Wicket propose quelques classes très sympathiques

- ApplicationContextMock est une classe qui simule l'ApplicationContext de Spring et qui permet de rajouter des beans dans le container.

- WicketTester est une fixture qui possède des méthodes d'assertion intéressantes comme le rendu de la page, la présence de composants et des méthodes de saisie de formulaire.

Nous allons donc asserter que notre HomePage s'affiche bien et que les composants userId de type TextField et password de type PasswordTextField existent bien dans la page :

HomePageTest.java

public class HomePageTest {

protected WicketTester tester;

@Test
public void testRender() throws Exception {
tester.startPage(HomePage.class);
tester.assertRenderedPage(HomePage.class);
tester.assertComponent("loginForm:userId", TextField.class);
tester.assertComponent("loginForm:password", PasswordTextField.class);
tester.assertComponent("loginForm:submit", Button.class);
}

@Before
public void setUp() throws Exception {
tester = new WicketTester(new MyNotesApplication());
}
}

Pour que la classe de test compile, il nous manque :

- La classe MyNotesApplication qui est la servlet de l'application

- La classe HomePage et son fichier html

MyNotesApplication.java

public class MyNotesApplication extends WebApplication {

@Override
public Class getHomePage() {
return HomePage.class;
}
}

HomePage.java

public class HomePage extends WebPage {
}

HomePage.html

<html>
<head>
<title>MyNotes</title>
</head>

<body>
TODO
</body>
</html>

III-C-1-b - Exécution du test qui doit échouer

Test de rendu de HomePage

III-C-1-c - Implémentation

Nous devons ajouter, dans notre page, un formulaire avec un champ de saisie normal pour l'identifiant, un champ de saisie masquée pour le mot de passe, et un bouton de validation.

Chaque composant ajouté possède un identifiant qui servira à la déclaration du composant dans la page html. L'appel de la méthode setModel() avec un CompoundPropertyModel indique à Wicket qu'il va setter par introspection les attributs userId et password avec les valeurs des champs du formulaire.

HomePage.java

public class HomePage extends WebPage {

public HomePage() {
add(new LoginForm("loginForm"));
}

public class LoginForm extends Form {

private String userId;
private String password;

public LoginForm(String id) {
super(id);
setModel(new CompoundPropertyModel(this));
add(new Label("loginTitle", "MyNotes - Connexion"));
add(new TextField("userId"));
add(new PasswordTextField("password"));

Button button = new Button("loginSubmit") {
@Override
public void onSubmit() {

}
};
add(button);
}

//getter et setter de userId et password
...
}
}

Ensuite composons la vue html :

HomePage.html

<html>
<head>
<title>MyNotes</title>
</head>

<body>
<center>
<table cellpadding="0" cellspacing="0">
<tr>
<td>
<form wicket:id="loginForm">
<table>
<th><td><span wicket:id="loginTitle">Connexion</span></td></th>
<tr>
<td>Identifiant :</td>
<td><input wicket:id="userId" name="userId" type="text" size="50"/></td>
</tr>
<tr>
<td>Mot de passe :</td>
<td><input wicket:id="password" name="password" type="password" size="10"/></td>
</tr>
<tr>
<td></td>
<td><input wicket:id="loginSubmit" type="submit" value="Connexion"></td>
</tr>
</table>
</form>
</td>
</tr>
</table>
</center>
</body>
</html>

III-C-1-d - Réexécution du test

Exécution du test de rendu de HomePage réussie

III-C-2 - Gestion de l'authentification de la page d'accueil

Une fois, l'utilisateur authentifié, un menu apparaîtra à gauche et un texte d'accueil personnalisé à droite. Dans le menu, il y aura la déconnexion seulement.

III-C-2-a - Écriture du test

Nous allons créer un nouveau test pour une page nommée LoggedInPage :

LoggedInPageTest.java

public class LoggedInPageTest {

protected WicketTester tester;

@Test
public void testRender() throws Exception {
tester.startPage(HomePage.class);

FormTester formTester = tester.newFormTester("loginForm");
formTester.setValue("userId", "JohnDoe");
formTester.setValue("password", "seven");
formTester.submit("submit");

tester.assertNoErrorMessage();
tester.assertRenderedPage(LoggedInPage.class);
}

@Before
public void setUp() throws Exception {
tester = new WicketTester(new MyNotesApplication());
}
}

III-C-2-b - Exécution du test qui doit échouer

Exécution du test de rendu de LoogedInPage

III-C-2-c - Implémentation

Il nous faut donc implémenter l'authentification via le bouton submit. Il aura une référence vers SecurityService et dont l'implémentation sera gérée via Spring.

Dans la même situation que dans la tâche précédente, il se peut que l'implémentation de SecurityService ne soit pas disponible. Aussi nous pouvons mocker l'interface avec Mockito afin de pouvoir tester notre page. Mettons à jour la classe LoginForm de HomePage :

HomePage.java

...
@SpringBean
private SecurityService securityService;
...
Button button = new Button("submit") {
@Override
public void onSubmit() {
User user;
try {
user = securityService.authenticate(getUserId(), getPassword());
if (user == null) {
error("Identifiant ou mot de passe invalide.");
}
else {
MyNotesSession session = (MyNotesSession)getPage().getSession();
session.setUser(user);
setResponsePage(LoggedInPage.class);
}
}
catch (Exception e) {
error(e.getMessage());
}
}
};
...

Où SecurityService est déclaré en tant que bean Spring.

Lorsque l'objet user est renvoyé par la couche service, s'il n'existe pas il faut renvoyer un message d'erreur sinon il faut le stocker dans la session de l'utilisateur. Nous devons donc étendre la classe WebSession afin de pouvoir garder une référence d'objet user. Elle sera nommée MyNotesSession :

MyNotesSession.java

public class MyNotesSession extends WebSession {

private User user;

public MyNotesSession(Request request) {
super(request);
}

public void setUser(User user) {
this.user = user;
}

public User getUser() {
return user;
}
}

Notre MyNotesApplication devra donc surcharger la méthode newSession afin de retourner une instance de MyNotesSession :

MyNotesApplication.java

public class MyNotesApplication extends WebApplication {

@Override
public Class getHomePage() {
return HomePage.class;
}

@Override
public MyNotesSession newSession(Request request, Response response) {
return new MyNotesSession(request);
}
...
}

Notre page d'accueil intégrera un FeedbackPanel qui permet d'afficher les erreurs remontées par les pages web (comme la balise <errors/> de Struts). Du coup, il faut mettre à jour HomePageTest.java, HomePage.java, HomePage.html :

HomePageTest.java

public class HomePageTest {

protected WicketTester tester;

@Test
public void testRender() throws Exception {
tester.startPage(HomePage.class);
tester.assertRenderedPage(HomePage.class);
tester.assertComponent("feedback", FeedbackPanel.class);
tester.assertComponent("loginForm:userId", TextField.class);
tester.assertComponent("loginForm:password", PasswordTextField.class);
tester.assertComponent("loginForm:submit", Button.class);
}
...
}

HomePage.java

public class HomePage extends WebPage {

public HomePage() {
add(new LoginForm("loginForm"));
add(new FeedbackPanel("feedback"));
}
...
}

HomePage.html

<html>
<head>
<title>MyNotes</title>
</head>

<body>
<center>
<table cellpadding="0" cellspacing="0">
<tr>
<td>
<span wicket:id="feedback"/>
</td>
</tr>
<tr>
<td>
<form wicket:id="loginForm">
...
</form>
</td>
</tr>
</table>
</center>
</body>
</html>

Enfin nous allons mocker l'interface SecurityService et le contexte Spring dans notre classe de test :

LoggedInPageTest.java

public class LoggedInPageTest {

protected WicketTester tester;

@Mock
private SecurityService securityService;

@Test
public void testRender() throws Exception {
...
}

@Before
public void setUp() throws Exception {
final ApplicationContextMock applicationContextMock = new ApplicationContextMock();
MockitoAnnotations.initMocks(this);
Mockito.stub(securityService.authenticate("JohnDoe", "seven"))
.toReturn(UserFactory.buildUser("JohnDoe", "John", "Doe", "johndoe@abc.com", "seven", false));

applicationContextMock.putBean("securityService", securityService);
MyNotesApplication webApp = new MyNotesApplication() {
@Override
public void init() {
addComponentInstantiationListener(new SpringComponentInjector(this, applicationContextMock));
}
};

tester = new WicketTester(webApp);
}
}

Nous pouvons noter que :

- Nous avons surchargé spécifiquement la méthode init() de MyNotesApplication dans cette classe de test pour pouvoir introduire notre ApplicationContextMock dans notre servlet.

- La méthode buildUser a été mise dans une classe utilitaire nommée UserFactory afin que SecurityDaoMock, SecurityServiceTest (version Mockito) et LoggedInPageTest puissent l'utiliser.

UserFactory.java

public class UserFactory {

private UserFactory() {
}

public static User buildUser(String userId, String firstName, String lastName,
String email, String password, boolean isAdmin) {
User user = new User();
user.setUserId(userId);
user.setFirstName(firstName);
user.setLastName(lastName);
user.setEmail(email);
user.setPassword(password);
user.setAdmin(isAdmin);
return user;
}
}

III-C-2-d - Exécution du test

Exécution du test de rendu de LoggedInPage

III-C-3 - Affichage de la page principale après connexion

Nous allons mettre à jour le test en assertant nos composants web :

- L'hyperlien de déconnexion

- Le nom complet de l'utilisateur sera compris dans le message d'accueil (WicketTester ne peut que tester ses composants, et il est préférable de laisser le texte dans le code html plutôt que dans le code Java)

III-C-3-a - Écriture du test

LoggedInPageTest

public class LoggedInPageTest {

protected WicketTester tester;

@Mock
private SecurityService securityService;

@Test
public void testRender() throws Exception {
tester.startPage(HomePage.class);

FormTester formTester = tester.newFormTester("loginForm");
formTester.setValue("userId", "JohnDoe");
formTester.setValue("password", "seven");
formTester.submit("submit");

tester.assertNoErrorMessage();
tester.assertRenderedPage(LoggedInPage.class);
tester.assertComponent("logoutLink", Link.class);
tester.assertLabel("user", "John Doe");
}
...
}

III-C-3-b - Exécution du test

Exécution du test de rendu de LoggedInPage

III-C-3-c - Implémentation

Nous allons ajouter à la page LoggedInPage :

- L'hyperlien LogoutLink qui invalidera la session de l'utilisateur et redirigera le navigateur vers la page de connexion,

- Un Label contenant le nom de l'utilisateur pour personnaliser le message d'accueil.

LoggedInPage.java

public class LoggedInPage extends WebPage {

public LoggedInPage() {
add(new LogoutLink());
User user = ((MyNotesSession)getPage().getSession()).getUser();
add(new Label("user", user.getFullName()));
}

private class LogoutLink extends Link {

private LogoutLink() {
super("logoutLink");
}

@Override
public void onClick() {
Session.get().invalidateNow();
setResponsePage(Application.get().getHomePage());
}
}
}

LoggedInPage.html

<html>
<head>
<title>MyNotes</title>
</head>

<body>
<center>
<table cellpadding="0" cellspacing="0">
<tr>
<td>
<a wicket:id="logoutLink" name="logoutLink"><span wicket:id="label">logout</span></a>
</td>
<td>
Bonjour <span wicket:id="user"></span> et bienvenue dans l'application MyNotes.
</td>
</tr>
</table>
</center>
</body>
</html>

III-C-3-d - Exécution du test

Exécution du test de rendu de LoggedInPage

Nous pouvons rajouter un test d'erreur de connexion :

LoggedInPageTest.java

...
@Test
public void testAuthenticateFailsWithMissingInput() throws Exception {
tester.startPage(HomePage.class);

FormTester formTester = tester.newFormTester("loginForm");
formTester.submit("submit");
tester.assertErrorMessages(new String[] {
"Le champ 'userId' est obligatoire.",
"Le champ 'password' est obligatoire.",
});

}

@Test
public void testAuthenticateFailsWithUnknownUser() throws Exception {
tester.startPage(HomePage.class);

FormTester formTester = tester.newFormTester("loginForm");
formTester.setValue("userId", "JohnDoe");
formTester.setValue("password", "six");
formTester.submit("submit");
tester.assertErrorMessages(new String[] {"Identifiant ou mot de passe invalide."});
}
...

Après réexécution, nous obtenons les résultats suivants :

Nous nous rendons compte que le champ userId n'a pas été rendu obligatoire : changeons les propriétés du TextField associé dans la classe UserForm :

HomePage.java

...
public LoginForm(String id) {
super(id);
setModel(new CompoundPropertyModel(this));
add(new Label("loginTitle", "MyNotes - Connexion"));
TextField userIdTextField = new TextField("userId");
userIdTextField.setRequired(true);
add(userIdTextField);
...

Si nous relançons les tests, ils devraient tous passer :

III-C-3-e - Remaniement des tests et de l'implémentation

À présent nous pouvons prendre un peu de temps pour effectuer quelques remaniements dans le code en utilisant le mécanisme d'héritage des Pages de Wicket :

Les fichiers LoggedInPage.html et HomePage.html sont très similaires finalement, la structure commune pourrait être la suivante :

<html>
<head>
<title>MyNotes</title>
</head>

<body>
<center>
<table cellpadding="0" cellspacing="0">
<tr>
<td>
//contenu
</td>
</tr>
</table>
</center>
</body>
</html>

Par ailleurs le feebackPanel sera utilisé par toutes les pages lors de la remontée d'erreur. Ce composant doit remonter aussi une page de base.

Enfin, dès lors que nous avons dû récupérer la session de la page, nous avons dû transtyper en MyNotesSession. Cela alourdit l'écriture et la lecture du code. Nous pouvons donc écrire une méthode getMyNoteSession() dans cette page de base. Il en résulte de l'écriture de la classe MyNotesPage et son fichier html :

MyNotesPage.java

public class MyNotesPage extends WebPage {

public MyNotesPage() {
add(new FeedbackPanel("feedback"));
}

protected MyNotesSession getMyNotesSession() {
return (MyNotesSession) getSession();
}
}

MyNotesPage.html

<html>
<head>
<title>MyNotes</title>
</head>

<body>
<center>
<table cellpadding="0" cellspacing="0">
<tr>
<td>
<span wicket:id="feedback"/>
</td>
</tr>
<tr>
<td>
<div id="content">
<wicket:child/>
</div>
</td>
</tr>
</table>
</center>
</body>
</html>

La balise <wicket:child/> indique à Wicket que toutes les pages qui dériveront de cette page produiront un code html dans le cadre <div id="content">. En conséquence, les pages HomePage et LoggedInPage vont dériver de MyNotesPage. Les fichiers html correspondants auront comme balise racine <wicket:extend> :

HomePage.java

public class HomePage extends MyNotesPage {

public HomePage() {
add(new LoginForm("loginForm"));
//suppression de l'ajout du feedbackPanel
}

public class LoginForm extends Form {

...

public LoginForm(String id) {
...
Button button = new Button("submit") {
@Override
public void onSubmit() {
User user;
try {
user = securityService.authenticate(getUserId(), getPassword());
if (user == null) {
error("Identifiant ou mot de passe invalide.");
}
else {
MyNotesSession session = getMyNotesSession();
session.setUser(user);
setResponsePage(LoggedInPage.class);
}
}
catch (Exception e) {
error(e.getMessage());
}
}
};
add(button);
}
...
}

HomePage.html

<wicket:extend>
<form wicket:id="loginForm">
<table>
<th>
<td><span wicket:id="loginTitle">Connexion</span></td>
</th>
<tr>
<td>
Identifiant :
</td>
<td>
<input wicket:id="userId" name="userId" type="text" size="50"/>
</td>
</tr>
<tr>
<td>
Mot de passe :
</td>
<td>
<input wicket:id="password" name="password" type="password" size="10"/>
</td>
</tr>
<tr>
<td></td>
<td>
<input wicket:id="submit" type="submit" value="Connexion">
</td>
</tr>
</table>
</form>
</wicket:extend>

LoggedInPage.java

public class LoggedInPage extends MyNotesPage {

public LoggedInPage() {
add(new LogoutLink());
User user = getMyNotesSession().getUser();
add(new Label("user", user.getFullName()));
}
...
}

LoggedInPage.html

<wicket:extend>
<table cellpadding="0" cellspacing="0">
<tr>
<td>
<a wicket:id="logoutLink" name="logoutLink"><span wicket:id="label">logout</span></a>
</td>
<td>
Bonjour <span wicket:id="user"></span> et bienvenue dans l'application MyNotes.
</td>
</tr>
</table>
</wicket:extend>

Ainsi, le code gagne en lisibilité et robustesse, car nous avons supprimé la duplication du code html. En relançant les tests, nous constatons qu'il n'y a pas eu de régression :

III-D - Conclusion

Nous venons de finir l'implémentation de notre premier besoin et nous constatons que l'effort fourni a été assez conséquent. Ceci est normal, car nous avons posé les bases de notre application en appréhendant et intégrant les frameworks applicatifs et de test. Nous verrons que lorsque nous exécuterons le scénario 4, le développement s'accélérera de manière significative. En effet, le chapitre suivant traitera de la création du cycle complet de construction de l'application en introduisant l'exécution des tests fonctionnels.

III-E - Téléchargement

Les sources du projet de ce chapitre sont disponibles via ftp ou bien via http. Le jar iBatis prêt à être déposé dans le dépôt Maven est disponible via ftp ou bien via http.

IV - Chapitre 3 - Tests fonctionnels

Dans ce chapitre, notre travail sera, d'une part de produire et livrer l'application sur un environnement d'intégration et, d'autre part d'exécuter les tests fonctionnels (appelés également tests utilisateur ou tests de cas d'utilisation).

IV-A - Préparation du livrable

La préparation d'un livrable consiste à empaqueter l'application afin qu'elle puisse être installée dans un environnement cible (intégration, recette, etc.). Cela peut être un exécutable Windows ou Unix, un fichier jar, ear ou war. Dans notre cas, nous allons modifier notre projet pour produire un livrable de type application web (war).

IV-A-1 - Mise à jour du pom

Nous allons modifier les propriétés suivantes.

- Le packaging sera de type war

- Le nom final lors du build sera mynotes (cela évite d'avoir la version accolée au nom de l'application genre mynotes-1.0-SNAPSHOT).

- Les paramètres de connexion à la base de données seront configurables via l'utilisation d'un profile maven : soit développement (par défaut), soit intégration.

pom.xml

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>

<groupId>com.developpez.mynotes</groupId>
<artifactId>mynotes</artifactId>
<version>1.0-SNAPSHOT</version>

<packaging>war</packaging>
...
<name>mynotes</name>

<properties>
<!-- Database connection parameters for dev -->
<database.driver>org.hsqldb.jdbcDriver</database.driver>
<database.url>jdbc:hsqldb:mem:mynotes</database.url>
<database.username>sa</database.username>
<database.password></database.password>
</properties>

<profiles>
<profile>
<id>integration</id>
<activation>
<property>
<name>environment</name>
<value>integration</value>
</property>
</activation>
<properties>
<!-- Database connection parameters for integration -->
<database.driver>org.hsqldb.jdbcDriver</database.driver>
<database.url>jdbc:hsqldb:hsql://localhost/mynotes</database.url>
<database.username>sa</database.username>
<database.password></database.password>
</properties>
</profile>
</profiles>
<build>
<finalName>mynotes</finalName>
...
</build>
...
</project>

Afin que le fichier database.properties final puisse être généré par Maven, il devra être placé dans le répertoire src/main/resources-filtered :

database.properties

driver=${database.driver}
url=${database.url}
username=${database.username}
password=${database.password}

Dans le profile développement nous obtiendrons :

database.properties

driver=org.hsqldb.jdbcDriver
url=jdbc:hsqldb:mem:mynotes
username=sa
password=

Dans le profile integration, nous obtiendrons :

database.properties

driver=org.hsqldb.jdbcDriver
url=jdbc:hsqldb:hsql://localhost/mynotes
username=sa
password=

IV-A-2 - Écriture du fichier web.xml

L'application a besoin de fournir un descripteur au container de servlet afin que ce dernier puisse l'installer et la démarrer. Ce descripteur s'exprime sous la forme d'un fichier xml standardisé (web.xml) et selon la norme Maven, il devra être placé dans le répertoire src/main/webapp/WEB-INF.
Dans le fichier web.xml, nous déclarerons notre classe Wicket MyNotesApplication et nous configurerons le contexte Spring :

web.xml

<?xml version="1.0" encoding="UTF-8"?>
<web-app>
<display-name>mynotes</display-name>
<servlet>
<servlet-name>mynotes</servlet-name>
<servlet-class>org.apache.wicket.protocol.http.WicketServlet</servlet-class>
<init-param>
<param-name>applicationClassName</param-name>
<param-value>com.developpez.mynotes.web.MyNotesApplication</param-value>
</init-param>
<load-on-startup>1</load-on-startup>
</servlet>
<servlet-mapping>
<servlet-name>mynotes</servlet-name>
<url-pattern>/*</url-pattern>
</servlet-mapping>
<session-config>
<session-timeout>-1</session-timeout>
</session-config>

<context-param>
<param-name>contextConfigLocation</param-name>
<param-value>classpath:com/developpez/mynotes/dao/applicationContext-dao.xml
classpath:com/developpez/mynotes/business/applicationContext-business.xml
</param-value>
</context-param>
<context-param>
<param-name>log4jConfigLocation</param-name>
<param-value>classpath:log4j.properties</param-value>
</context-param>
<listener>
<listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
</listener>
</web-app>

Enfin, pour que notre servlet Wicket puisse utiliser Spring, il faut surcharger la méthode init de MyNotesApplication :

MyNotesApplication.java

public class MyNotesApplication extends WebApplication {

...
@Override
protected void init() {
super.init();
addComponentInstantiationListener(new SpringComponentInjector(this));
}
}

Comme nous avons modifié notre code, il faut que nous relancions les tests unitaires de la couche web :

Nous constatons que le test testRender de HomePageTest ne passe plus. Effectivement, nous instancions MyNotesApplication sans spécifier un contexte Spring. Il y a 2 solutions pour y remédier :

La première est la plus simple et la plus immédiate : il suffit de surcharger la méthode init lors de l'instanciation de MyNotesApplication afin de l'empêcher de charger le contexte Spring (le corps de la méthode sera vide) :

HomePage.java

public class HomePageTest {
...
@Before
public void setUp() throws Exception {
MyNotesApplication webApplication = new MyNotesApplication() {
@Override
protected void init() {
//surcharge pour ne pas charger le contexte Spring
}
};
tester = new WicketTester(webApplication);
}
}

L'inconvénient est que cela n'est pas flexible. Si jamais nous devions ajouter des dépendances de services qui seraient exécutés lors du chargement de HomePage, nous serions obligés de préciser un contexte Spring dans notre classe de test.

La deuxième solution consiste à extraire une classe de test abstraite qui serait héritée par HomePageTest et LoggedInPage. Cela permet d'homogénéiser l'écriture et d'éviter la redondance de code. Nous allons partir de la classe LoggedInPageTest puisqu'elle est la plus riche des deux.

- WicketTester est présent dans les deux classes, cette fixture sera remontée.

- La méthode setUp sera remontée, mais seulement la partie qui initialise le mock du contexte Spring. Ce dernier sera accessible dans les classes concrètes (visibilité protected) si jamais nous devions l'utiliser ou le modifier pour un besoin spécifique.

- L'initialisation des mocks de service étant spécifique à chaque test, nous écrirons une méthode doSetUp qui sera surchargée dans les classes concrètes de test.

Il en résulte des classes suivantes :

AbstractWicketTestCase

public abstract class AbstractWicketTestCase {

protected WicketTester tester;

protected ApplicationContextMock applicationContextMock;

@Before
public final void setUp() throws Exception {
applicationContextMock = new ApplicationContextMock();
doSetUp();
MyNotesApplication webApp = new MyNotesApplication() {
@Override
public void init() {
addComponentInstantiationListener(new SpringComponentInjector(this, applicationContextMock));
}
};

tester = new WicketTester(webApp);
}

protected abstract void doSetUp() throws Exception;
}

HomePageTest.java

public class HomePageTest extends AbstractWicketTestCase {

@Test
public void testRender() throws Exception {
...
}

@Override
protected void doSetUp() throws Exception {
SecurityService securityServiceMock = Mockito.mock(SecurityService.class);
applicationContextMock.putBean("securityService", securityServiceMock);
}
}

LoggedInPageTest.java

public class LoggedInPageTest extends AbstractWicketTestCase {

//méthodes de tests
...

@Override
protected void doSetUp() throws Exception {
SecurityService securityServiceMock = Mockito.mock(SecurityService.class);
Mockito.stub(securityServiceMock.authenticate("JohnDoe", "seven"))
.toReturn(UserFactory.buildUser("JohnDoe", "John", "Doe", "johndoe@abc.com", "seven", false));

applicationContextMock.putBean("securityService", securityServiceMock);
}
}

Le point positif de cette écriture est que cela allège la lecture du test. Le point négatif est que l'on doit initialiser nos mocks et les mettre dans le contexte Spring dans chaque classe de test alors que si nous étions passés par des mocks "fait maison", nous aurions simplement écrit une configuration Spring (voir solution 1 du mock dans la couche Service - chapitre 2).

IV-B - Configurer l'environnement d'intégration

L'environnement d'intégration représente l'environnement complet sur lequel les tests fonctionnels seront exécutés. L'application sera donc testée de bout en bout, impliquant l'interaction entre l'action utilisateur sur le client (navigateur dans notre cas), toutes les couches de l'application et la base de données (pas de mock). Nous allons configurer cet environnement en effectuant les tâches suivantes :

- Installation et configuration du serveur de base de données

- Installation et configuration du container de servlet

- Pilotage du cycle d'exécution du serveur de Bdd et du container de servlet par Maven

IV-B-1 - Installation et configuration du serveur de base de données

Nous allons installer et configurer Hsqldb en mode serveur autonome. Nous mettrons la distribution jar et les fichiers de commandes adéquats dans un répertoire de notre environnement d'intégration (par exemple c:\dev\bdd\hsqldb). Dans notre tutoriel, notre machine de développement fait office de machine d'intégration.

startDBServer.cmd

echo "Running HSQLDB server"
java -cp hsqldb.jar org.hsqldb.Server -database.0 file:mynotes -dbname.0 mynotes

stopDBServer.cmd

echo "Stopping HSQLDB server"
java -cp hsqldb.jar org.hsqldb.util.SqlTool --sql "shutdown;" localhost-sa

Le script stopDBServer.cmd utilise un identifiant de connexion à la base de données pour pouvoir interagir avec le serveur (localhost-sa). Cet identifiant est défini dans un fichier d'authentification (sqltool.rc) qui sera lu par la classe org.hsqldb.util.SqlTool. Par ailleurs, il devra être déposé dans le répertoire utilisateur (exemple : C:\Documents and Settings\USER):

sqltool.rc

# This is for a hsqldb Server running with default settings on your local
urlid localhost-sa
url jdbc:hsqldb:hsql://localhost/mynotes
username sa
password

Ces deux fichiers seront exécutés à la main. Dans un cas standard, la base de données d'intégration est active 24h/24 afin que l'on puisse construire à tout moment l'application (principe de l'Intégration Continue).

IV-B-2 - Installation et configuration du container de servlet

Dans l'idéal, le container Jetty devrait être installé sur la machine d'intégration (comme Hsqldb) et les applications web déployées sur celle-ci. Pour les besoins de notre tutoriel, nous allons simplifier la gestion de ce serveur en déléguant le contrôle de son cycle d'exécution par Maven. Jetty démarrera lorsque le profile sera integration et lors de la phase pre-integration-test. Ensuite, il pointera sur le war généré par Maven et l'accès se fera via le port classique 8080. Enfin, l'arrêt du serveur sera effectué lors de la phase post-integration-test.

pom.xml

<build>
<plugins>
<plugin>
<groupId>org.mortbay.jetty</groupId>
<artifactId>maven-jetty-plugin</artifactId>
<version>6.1.11</version>
<configuration>
<webApp>${basedir}/target/mynotes.war</webApp>
<contextPath>/mynotes</contextPath>
<port>8080</port>
<stopKey>arret</stopKey>
<stopPort>9090</stopPort>
</configuration>
<executions>
<execution>
<id>start-jetty</id>
<phase>pre-integration-test</phase>
<goals>
<goal>run-war</goal>
</goals>
<configuration>
<scanIntervalSeconds>0</scanIntervalSeconds>
<daemon>true</daemon>
</configuration>
</execution>
<execution>
<id>stop-jetty</id>
<phase>post-integration-test</phase>
<goals>
<goal>stop</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>

Dans la configuration du plugin jetty, nous avons mis les balises <stopKey> et <stopPort>. Ces deux balises sont obligatoires pour que le plugin puisse lancer le goal stop lors de la phase post-integration-test

IV-B-3 - Initialisation du schéma de la base de données

Cette opération s'effectuera à l'aide du plugin maven-antrun-plugin. Nous allons configurer la tache <java/> pour qu'il exécute la classe org.hsqldb.util.SqlTool avec comme argument l'identifiant de la base (cf fichier d'authentification mynotes.rc) et le fichier d'initialisation du schéma mynotes.ddl. Le déclenchement sera effectué lors de la phase compile afin que les tests unitaires de DAO puissent être exécutés sur la base de données d'intégration.

pom.xml

<build>
<plugins>
...
<plugin>
<artifactId>maven-antrun-plugin</artifactId>
<executions>
<execution>
<id>init-db</id>
<phase>compile</phase>
<configuration>
<tasks>
<java classname="org.hsqldb.util.SqlTool" classpathref="maven.runtime.classpath">
<arg value="localhost-sa"/>
<arg value="${project.build.directory}/test-classes/mynotes.ddl"/>
</java>
</tasks>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>

Le démarrage et l'arrêt du serveur de la base de données ont été omis du cycle pour des raisons technique et pratique.

- Technique, car le lancement de hsqldb doit se faire dans un processus séparé (au sens système). S'il faisait partie d'une phase Maven, il serait tué lors de l'exécution de la phase suivante ce qui serait plutôt gênant pour l'exécution des tests.

- Pratique, car en plus de la remarque évoquée à la fin de la section II-A, ce serveur de base de données peut très bien accueillir d'autres projets. De plus, arrêter puis couper le serveur après chaque build serait très coûteux en temps surtout si l'on décidait de passer à MySql, Oracle ou autre SGBD.

IV-C - Écriture et exécution du premier test fonctionnel

La phase d'écriture du test fonctionnel (en Anglais functional test ou release test) est cruciale pour valider le besoin à implémenter. Il devra couvrir l'ensemble des cas (séquençage des actions, contrôle des règles de gestion) décrits dans la spécification fonctionnelle. Selon le principe de la méthodologie XP, les tests fonctionnels doivent représenter la(les) spécification(s) fonctionnelle(s) du projet et c'est au client (MOA) de l'écrire avec le binôme de développeurs.

IV-C-1 - Introduction de Canoo Webtest

La librairie WebTest permet d'écrire des tests web automatisés dans lesquels des actions utilisateur seront décrites. Ils peuvent être écrits soit en utilisant soit une syntaxe XML, soit Groovy. Par exemple, l'utilisateur veut aller sur la page principale de Google. Il écrira le code suivant :

webtest avec la syntaxe XML

<invoke url="http://www.google.com" description="Aller sur la page de Google"/>
<verifyTitle text="Google" description="Verification du titre de la page de google"/>

ou bien

webtest avec la syntaxe Groovy

invoke "http://www.google.com", description: "Aller sur la page de Google"
verifyTitle "Google" description: "Verification du titre de la page de google"

Nous voyons que la syntaxe tente de se rapprocher du langage naturel, c'est pourquoi cette plateforme de test semble assez bien convenir pour l'écriture et la lecture de test par une personne non développeur.

Concernant le mode opératoire, Webtest se basant sur Ant, l'exécution s'effectue en arrière-plan (pas de navigateur affiché) ce qui permet de gagner énormément de temps sur l'exécution d'une suite de tests. À chaque nouveau rendu de page, une capture d'écran est créée et le résultat du test est décrit sous la forme d'un fichier XML qui pourra ultérieurement être interprété pour générer des états.

Quant à l'intégration de ce framework dans le projet, il peut être intégré dans le projet soit de manière externe via une distribution complète, soit par un artefact Maven 2.

IV-C-2 - Test de connexion utilisateur

Nous allons créer un package webtest/usecase sous le répertoire test/resources dans lequel nous écrirons notre premier test. Il contiendra 3 étapes : insertion des données de test, séquence d'actions de l'utilisateur et assertion des données de la base de données à la fin.
La syntaxe utilisée sera la version XML et nous décrirons les étapes suivantes :

1. Accès à l'application via l'url http://localhost:8080/mynotes.

2. Saisie des paramètres de connexion et validation du formulaire

3. Asserter que le message d'accueil est affiché.

La balise verifyText possède une limitation. Lorsqu'elle asserte le texte, les balises html sont également prises en compte alors qu'idéalement elle devrait asserter ce qui est affiché sur la page.

<verifyText text="Bonjour John Doe et bienvenue dans l'application MyNotes."/>
<!-- Ne marche pas, car Canoo trouve "Bonjour <span wicket:id="user">John Doe</span> et bienvenue dans l'application MyNotes." -->

Du coup nous sommes obligés de scinder l'assertion en 3.

UtilisateurNormalTest.xml

<?xml version="1.0"?>
<project default="test">
<target name="test">
<webtest name="Test connexion into MyNotes">
<group description="Login">
<invoke url="http://localhost:8080/mynotes" description="Go to MyNotes"/>
<setInputField name="userId" value="JohnDoe"/>
<setInputField name="password" value="seven"/>
<clickButton label="Connexion"/>
</group>
<group description="Verify welcome text">
<verifyText text="Bonjour"/>
<verifyText text="John Doe"/>
<verifyText text="et bienvenue dans l'application MyNotes."/>
</group>
</webtest>
</target>
</project>

Nous allons ajouter l'étape d'insertion des données en base en utilisant la tâche Ant de DBUnit.

UtilisateurNormalTest.xml

<?xml version="1.0"?>
<project default="test">

<target name="test">
<dbunit driver="org.hsqldb.jdbcDriver" url="jdbc:hsqldb:hsql://localhost/mynotes"
userid="sa" password=""
datatypeFactory="org.dbunit.ext.hsqldb.HsqldbDataTypeFactory">
<operation type="CLEAN_INSERT" src="usecase/UtilisateurNormalInputDataTest.xml" format="xml"/>
</dbunit>
<webtest ...>
</project>

Le fichier UtilisateurNormalInputDataTest.xml sera similaire à celui du test unitaire de SecurityDao. Il nous reste l'assertion des données de la base de données après la séquence des actions de l'utilisateur.

UtilisateurNormalTest.xml

<?xml version="1.0"?>
<project default="test">

<target name="test">
<dbunit ...>
<webtest ...>
<dbunit driver="org.hsqldb.jdbcDriver" url="jdbc:hsqldb:hsql://localhost/mynotes"
userid="sa" password=""
datatypeFactory="org.dbunit.ext.hsqldb.HsqldbDataTypeFactory">
<compare src="usecase/UtilisateurNormalOutputDataTest.xml" format="xml"/>
</dbunit>
</project>

Par rapport aux actions de l'utilisateur, les données de la base ne doivent pas changer. Le fichier DBUnit sera identique à UtilisateurNormalInputDataTest.xml. Cela peut sembler superflu au premier abord, mais il aura l'occasion d'évoluer dans les prochains scénarii. Notre premier test étant terminé, il ne reste plus qu'à configurer Maven pour l'exécuter.

IV-C-3 - Pilotage de l'exécution de tests fonctionnels par Maven

Nous rappelons les étapes suivantes qui seront exécutées par Maven lors du lancement de la construction du build avec le profile integration :

1. Initialisation du schéma de la base de données au préalable

2. Construction du war incluant l'exécution des tests unitaires

3. Lancement du container de servlet Jetty qui démarrera l'application

4. Exécution des tests fonctionnels

5. Arrêt du container de servlet Jetty

Comme Canoo se base sur Ant, nous allons réutiliser le plugin maven-antrun-plugin pour lancer notre test lors de la phase integration-test. La dépendance vers Canoo sera ajoutée dans la configuration du plugin. Il faut noter que la distribution Canoo Maven n'est pas présente dans les dépôts publics Maven. Comme pour iBatis, il faut la télécharger depuis le site officiel et l'installer dans le dépôt local (Attention au numéro de version de l'artefact).

pom.xml

<plugin>
<artifactId>maven-antrun-plugin</artifactId>
<executions>
...
<execution>
<id>run-webtest</id>
<phase>integration-test</phase>
<configuration/>
<goals>
<goal>run</goal>
</goals>
</execution>
</executions>
<dependencies>
<dependency>
<groupId>com.canoo.webtest</groupId>
<artifactId>webtest</artifactId>
<version>R_1689</version>
<exclusions>
<exclusion>
<groupId>javax.xml</groupId>
<artifactId>jsr173</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>
</plugin>

Le module jsr173 a été exclu de la dépendance, car il est obsolète depuis qu'il a été intégré dans la plateforme JDK5.

Ensuite, nous allons définir une propriété Ant runtime_classpath qui pointera vers le classpath de Maven en scope runtime. Enfin nous invoquerons la balise <ant> afin d'exécuter le fichier de lancement des tests allTests.xml qui contiendra notre test. Les attributs inheritAll et inheritRefs de <ant> permettront d'indiquer à Ant que tous les sous-fichiers Ant appelés par allTests.xml accèderont aux propriétés définies dans la configuration.

pom.xml

<plugin>
<artifactId>maven-antrun-plugin</artifactId>
<executions>
...
<execution>
<id>run-webtest</id>
<phase>integration-test</phase>
<configuration>
<property name="runtime_classpath" refid="maven.runtime.classpath"/>
<property name="webtest.tests"
value="${project.build.directory}/test-classes/webtest"/>

<ant antfile="${webtest.tests}/allTests.xml" inheritRefs="true"
dir="${webtest.tests}" inheritAll="true"/>

</configuration>
...

Il nous reste à écrire le fichier allTests.xml. Par défaut, Canoo ne reconnaît pas les balises Ant de DBUnit, aussi, il faut déclarer la tâche dans notre fichier (d'où l'utilité de notre propriété runtime_classpath). Par ailleurs pour ceux qui ont des problèmes avec les proxys installés sur leur réseau (entreprise ou particulier), il faut créer une petite configuration avec SetProxy. Nous pouvons également surcharger certaines propriétés de Canoo. Dans notre cas, nous voulons que le build stoppe dès la première erreur d'exécution (Exception levée par exemple) et dès le premier test en échec.

allTests.xml

<project default="runTests">

<taskdef resource="webtest_base_relaxed.taskdef">
<classpath path="${runtime_classpath}"/>
</taskdef>

<taskdef name="setproxy" classname="org.apache.tools.ant.taskdefs.optional.net.SetProxy"
classpath="${runtime_classpath}"/>
<taskdef name="dbunit" classname="org.dbunit.ant.DbUnitTask" classpath="${runtime_classpath}"/>

<setproxy nonproxyhosts="localhost" proxyhost="myproxy" proxyport="80"/>

<property name="wt.config.haltonerror" value="true"/>
<property name="wt.config.haltonfailure" value="true"/>
<property name="wt.config.resultpath" value="../webtest-results"/>

<target name="runTests" description="runs all the tests">
<ant antfile="usecase/UtilisateurNormalTest.xml"/>
</target>
</project>

Exécutons notre build avec la commande mvn clean install -Denvironment=integration (ne pas oublier de lancer le script startDBServer.cmd avant)

Nous pouvons voir que webtest a bien exécuté notre test fonctionnel.

Canoo "capture" les pages HTML lorsqu'une nouvelle page est rendue. Aussi, en jetant un coup d'oeil, nous nous rendons compte, que d'une part c'est atrocement moche, et que d'autre part il y a des soucis d'encodage de caractères (le lien Déconnexion est mal affiché). Nous y reviendrons dans le scénario suivant.

Vérifions que notre build échoue bien en modifiant notre test :

UtilisateurNormalTest.xml

...
<group description="Verify welcome text">
<verifyText text="Bonjour"/>
<verifyText text="Robert"/>
<verifyText text="et bienvenue dans l'application MyNotes."/>
</group>
...

En ouvrant le rapport d'exécution, nous constatons que l'assertion a échoué.

<failure message="Step[verifyText (2/2)]: Text not found in page. Expected &lt;Robert>">

IV-C-4 - Génération du rapport au format html

Dans la distribution de Webtest, la génération de rapport de test au format HTML est proposée afin que l'on puisse mieux exploiter les données. Afin de conserver la simplicité d'installation et configuration de notre projet, nous allons seulement "récupérer" les fichiers utiles à la génération et l'inclure dans notre répertoire webtest/report/resources.

Ressources récupérées de la distribution de Canoo 2.0.6

Ensuite nous allons récupérer les taches Ant liées à la génération HTML du fichier webtest.xml de la distribution et les adapter à nos besoins. 2 propriétés seront modifiées : wt.config.resultpath et ~wt.WebTest.dir. Elles pointeront vers des variables définies dans notre pom.xml. Par ailleurs il faudra définir la tâche groovy utilisée pour générer le fichier récapitulant les résultats des tests (en sortant ces cibles Ant de leur contexte d'exécution d'origine, certaines tâches ne seront pas reconnues par défaut lors du lancement de la génération du rapport).

generateReport.xml

<?xml version="1.0" encoding="ISO-8859-1"?>
<project name="wt.webtest" default="wt.htmlReports">

<target name="wt.init" description="Initialisation work">

<property name="wt.config.resultpath" value="${webtest.results}"/>

<dirname property="~wt.WebTest.dir" file="${webtest.report}/generateReport.xml"
description="the directory of the WebTest distribution used"/>

<taskdef name="groovyScript" classname="org.codehaus.groovy.ant.Groovy"
loaderref="wt.defineTasks.loader"
classpath="${runtime_classpath}"/>
...
</target>

<target name="wt.htmlReports.init" ...>

<target name="wt.htmlReports" depends="wt.init, wt.htmlReports.init" ...>
</project

Il ne reste plus qu'à définir les valeurs de webtest.results et webtest.report ainsi que l'exécution de generateReport.xml dans notre pom.

<plugin>
<artifactId>maven-antrun-plugin</artifactId>
<executions>
...
<execution>
<id>run-webtest</id>
<phase>integration-test</phase>
<configuration>
<tasks>
...
<property name="webtest.tests"
value="${project.build.directory}/test-classes/webtest"/>
<property name="webtest.report"
value="${webtest.tests}/report"/>
<property name="webtest.results"
value="${project.build.directory}/test-classes/webtest-results"/>

<ant antfile="${webtest.tests}/allTests.xml" inheritRefs="true"
dir="${webtest.tests}" inheritAll="true"/>

<ant antfile="${webtest.tests}/report/generateReport.xml"
inheritRefs="true"
dir="${webtest.tests}" inheritAll="true"/>
...

Réexécutons notre commande maven :

Nous observons que la cible Ant wt.htmlReport de génération a été exécutée. Il ne reste plus qu'à ouvrir la page index.html sous mynotes\target\test-classes\webtest-results.

IV-C-5 - Remaniement du test fonctionnel

Afin d'améliorer la lisibilité du test et de préparer le terrain pour les prochains tests, nous allons remanier quelques éléments. Nous allons créer un répertoire includes dans lequel nous allons déclarer nos tâches DBUnit d'insertion et d'assertion de données. Les noms de fichiers seront des variables positionnées dans le test et les paramètres de connexion à la base de données seront remontés dans le pom.xml.

pom.xml

<plugin>
<artifactId>maven-antrun-plugin</artifactId>
<executions>
<execution>
<id>run-webtest</id>
<phase>integration-test</phase>
<configuration>
<tasks>
<property name="runtime_classpath" refid="maven.runtime.classpath"/>
<property name="sql.jdbcdriver" value="${database.driver}"/>
<property name="sql.url" value="${database.url}"/>
<property name="sql.username" value="${database.username}"/>
<property name="sql.password" value="${database.password}"/>
<property name="sql.datatypefactory"
value="org.dbunit.ext.hsqldb.HsqldbDataTypeFactory"/>
...

AssertData.xml

<dbunit driver="${sql.jdbcdriver}" url="${sql.url}"
userid="${sql.username}" password="${sql.password}"
datatypeFactory="${sql.datatypefactory}">
<compare src="${outputDataFile}" format="xml"/>
</dbunit>

InsertData.xml

<dbunit driver="${sql.jdbcdriver}" url="${sql.url}"
userid="${sql.username}" password="${sql.password}"
datatypeFactory="${sql.datatypefactory}">
<operation type="CLEAN_INSERT" src="${inputDataFile}" format="xml"/>
</dbunit>

Dans le fichier de test UtilisateurNormalTest.xml, nous déclarerons en entête les fragments AssertData et InsertData. Puis nous préciserons les valeurs des propriétés outputDataFile et inputDataFile.

UtilisateurNormalTest.xml

<?xml version="1.0"?>

<!DOCTYPE project [
<!ENTITY insertData__xml SYSTEM "../includes/InsertData.xml">
<!ENTITY assertData__xml SYSTEM "../includes/AssertData.xml">
]>

<project default="test">

<property name="inputDataFile" value="usecase/UtilisateurNormalInputDataTest.xml"/>
<property name="outputDataFile" value="usecase/UtilisateurNormalOutputDataTest.xml"/>

<target name="test">
&insertData__xml;

<webtest name="Test connexion into MyNotes">
...
</webtest>

&assertData__xml;
</target>
</project>

Ensuite nous créerons un répertoire config dans lequel nous pourrons déclarer les balises non reconnues par défaut par Ant et modifier certaines propriétés générales de Canoo.

Config.xml

<taskdef resource="webtest_base_relaxed.taskdef">
<classpath path="${runtime_classpath}"/>
</taskdef>

<taskdef name="setproxy" classname="org.apache.tools.ant.taskdefs.optional.net.SetProxy"
classpath="${runtime_classpath}"/>
<taskdef name="dbunit" classname="org.dbunit.ant.DbUnitTask" classpath="${runtime_classpath}"/>

<setproxy nonproxyhosts="localhost" proxyhost="ehttp1" proxyport="80"/>

<property name="wt.config.haltonerror" value="true"/>
<property name="wt.config.haltonfailure" value="true"/>
<property name="wt.config.resultpath" value="../webtest-results"/>

Nous déclarerons Config.xml dans le fichier allTests.xml.

allTests.xml

<?xml version="1.0"?>

<!DOCTYPE project [
<!ENTITY config__xml SYSTEM "config/Config.xml">
]>

<project default="runTests">

&config__xml;

<target name="runTests" description="runs all the tests">
<ant antfile="usecase/UtilisateurNormalTest.xml"/>
</target>
</project>

Il en résulte alors la structure arborescente suivante :

IV-C-6 - Remarque sur l'exécution d'une suite de tests fonctionnels

Pour l'instant nous n'avons qu'un test fonctionnel. Lors de l'exécution des scénarii suivants, nous serons amenés à en écrire davantage. L'inconvénient actuel de Canoo est que la configuration de l'exécution d'une suite de test est limitée. Soit il s'arrête au premier test qui échoue, soit il exécute tous les tests, mais le build sera marqué SUCCESSFULL même si certains tests ont échoué. L'idéal serait que la suite de tests s'exécute à la manière de JUnit : tous les tests sont exécutés, si un a échoué alors un message d'erreur (Exception) est envoyé. Nous aborderons cet aspect à la fin du chapitre suivant et nous verrons qu'un petit développement externe sera de rigueur si l'on veut obtenir ce que nous souhaitons.

IV-D - Conclusion

Nous venons de voir que préparer notre application à subir les tests fonctionnels a nécessité un certain effort. Cet effort payera lors de l'exécution des scénarii suivants, car nous verrons que le temps accordé au développement pur (implémentation du besoin) par rapport aux autres tâches augmentera de manière considérable et rendra le développeur plus productif. Notre application pourrait à présent être construite via un outil d'intégration continue (Hudson pour ne pas nommer).

IV-E - Téléchargement

Les sources du projet de ce chapitre sont disponibles via ftp ou bien via http. Les librairies externes ont été regroupées dans une archive téléchargeable via ftp ou bien via http.

V - Chapitre 4 - Gestion des notes

Dans ce chapitre, nous allons donner à l'utilisateur la possibilité d'ajouter, de modifier et de supprimer ses notes. Nous rappelons qu'une note est définie par un titre et un contenu et qu'elle n'appartient qu'à un seul utilisateur.

Modèle de données

Ci-dessous le script de création de table :

CREATE TABLE NOTE (
ID         INTEGER GENERATED BY DEFAULT AS IDENTITY(START WITH 0) NOT NULL,
USER_ID    VARCHAR(20)  NOT NULL,
TITLE      VARCHAR(255) NULL,
CONTENT    LONGVARCHAR  NULL,
PRIMARY KEY(ID),
FOREIGN KEY (USER_ID) REFERENCES UTILISATEUR (USER_ID)
);

Nous allons commencer ce scénario par l'écriture du test fonctionnel avec la spécification sommaire suivante :

- Une fois connecté, l'utilisateur accèdera à la gestion de ses notes au travers d'un hyperlien Notes,

- Un hyperlien Ajouter sera disponible et permettra à l'utilisateur d'ajouter une nouvelle note au travers d'un écran spécifique,

- Un tableau listant toutes ses notes sera affiché et les opérations Supprimer et de Modifier seront accessibles pour chaque note. La première opération aura pour effet de supprimer en base la note sélectionnée. La deuxième permettra de modifier la note sélectionnée au travers d'un écran similaire à celui de l'opération Ajouter.

V-A - Écriture du test fonctionnel

Avant d'attaquer le vif du sujet, nous allons introduire un petit formalisme afin d'améliorer la lisibilité. Un test fonctionnel sera décomposé en trois fichiers :

- MyTest.input : fichier DBUnit de données de test en entrée en mode Flatten,

- MyTest.xml : fichier Webtest du test fonctionnel,

- MyTest.output : fichier DBUnit de données étalon (après l'exécution du test web) en mode Flatten.

Les balises AssertData et InsertData seront déclarées dans un fichier MyProject.dtd afin de ne pas avoir à les inclure dans chaque test.

MyWebTest.dtd

<?xml version="1.0" encoding="UTF-8" ?>

<!ENTITY insertData__xml SYSTEM "../includes/InsertData.xml">
<!ENTITY assertData__xml SYSTEM "../includes/AssertData.xml">

Le fichier modèle webtest sera défini de la manière suivante :

MyTest.xml

<?xml version="1.0" encoding="UTF-8"?>

<!DOCTYPE project SYSTEM "../config/MyWebTest.dtd">

<project default="test">

<property name="inputDataFile" value="usecase/MyTest.input"/>
<property name="outputDataFile" value="usecase/MyTest.output"/>

<target name="test">
&insertData__xml;

<webtest name="Test something">
...//test steps are here
</webtest>

&assertData__xml;
</target>
</project>

Appliquons ce formalisme dans l'écriture du test de gestion des notes :

Intitulé

Contenu

Fichier résultant

Données en entrée

[Note 0] Supermarché : Beurre, farine, lait
[Note 1] Ordinateur : Remplacer carte graphique

NotesManagement.input

Etapes du test webtest

1 - Se connecter à l'application,
2 - Asserter la liste des notes de l'utilisateur,
3 - Ajouter une note (Administration : Payer le premier tiers provisionnel),
4 - Asserter la liste des notes de l'utilisateur,
5 - Supprimer la note "Ordinateur",
6 - Asserter la liste des notes,
7 - Modifier la note 0 (Supermarché : Whisky, Vodka),
8 - Asserter la liste des notes.

NotesManagement.xml

Données en sortie

[Note 0] Supermarché : Whisky, Vodka
[Note 2] Administration : Payer le premier tiers provisionnel

NotesManagement.output

Concernant le fichier webtest, nous remarquons que l'assertion du contenu d'une cellule n'est pas très lisible.

<verifyText description="verify note title" text="Supermarché">
<table row="1" column="1" htmlid="noteList"/>
</verifyText>

Sous la balise verifyText, il faut préciser la table et les coordonnées de la cellule. Si nous devons asserter le contenu d'une table de 10 lignes par 10 colonnes, nous risquons de souffrir quelque peu :(.

Nous essayerons d'améliorer cela en fin de chapitre grâce à un petit développement.

V-B - Implémentation du besoin

Le besoin fonctionnel de ce scénario étant simple (opérations "CRUD" principalement), nous nous focaliserons sur l'écriture et l'exécution des tests afin de ne pas alourdir la lecture de ce tutoriel. Lorsque certains passages demanderont davantage de détails, des fragments du code de production seront présentés.

V-B-1 - Écriture de la couche Dao

V-B-1-a - Écriture du test de sélection des notes d'un utilisateur

Seules les opérations de sélection des notes d'un utilisateur, de sélection d'une note et d'insertion d'une note seront présentées.

Nous allons pouvoir réutiliser la classe AbstractDaoTestCase extraite dans le scénario 2 et le formalisme DBUnit introduit dans l'écriture du test fonctionnel sera également appliqué dans la couche DAO (format Flatten .input pour les données en entrée et .output pour les données en sortie).

Notre premier test sélectionnera les notes de l'utilisateur tylerdurden avec les données suivantes en entrée :

NoteDaoTest.input

<dataset>
<UTILISATEUR USER_ID="johndoe" FIRST_NAME="John" LAST_NAME="Doe"
PASSWORD="seven" EMAIL="john.doe@abc.com" IS_ADMIN="FALSE"/>
<UTILISATEUR USER_ID="tylerdurden" FIRST_NAME="Tyler" LAST_NAME="Durden"
PASSWORD="soap" EMAIL="tyler.durden@abc.com" IS_ADMIN="FALSE"/>

<NOTE ID="0" USER_ID="johndoe" TITLE="Livraison" CONTENT="Envoyer paquet à l'inspecteur Somerset"/>
<NOTE ID="1" USER_ID="tylerdurden" TITLE="Savon" CONTENT="Vendre tout le stock "/>
<NOTE ID="2" USER_ID="tylerdurden" TITLE="Fight Club" CONTENT="Recruter à Chicago"/>
</dataset>

Afin de pouvoir charger notre fichier DBunit en entrée, nous allons remanier la classe AbstractDaoTestCase en introduisant la méthode abstraite getDataSetFile().

AbstractDaoTestCase.java

...
private FlatXmlDataSet getDataSet() throws Exception {
return new FlatXmlDataSet(getClass().getResourceAsStream(getDataSetFile()));
}
...

protected abstract String getDataSetFile();
...

Dans notre test, nous asserterons que le contenu de la liste de notes retournée par la méthode selectAllByUser() :

NoteDaoTest.java

@ContextConfiguration(locations = {"applicationContext-dao.xml"})
public class NoteDaoTest extends AbstractDaoTestCase {

@Autowired
private NoteDao noteDao;

@Test
public void selectAllByUser() throws Exception {
List<Note> noteList = noteDao.selectAllByUser("tylerdurden");
assertThat(noteList, equalTo(buildNoteList(new Object[][]{
{1, "tylerdurden", "Savon", "Vendre tout le stock"},
{2, "tylerdurden", "Fight Club", "Recruter à Chicago"}
})));
}

@Override
protected String getDataSetFile() {
return "NoteDaoTest.input";
}

...
}

Nous représentons les données étalon sous la forme d'un tableau afin de garder une lecture simple et immédiate du test.

Nous introduisons dans ce test la méthode assertThat de JUnit 4 qui permet d'utiliser des Matchers (assertion par contrat, voir ce tutoriel) et donc de tendre vers une écriture proche du langage naturel. Dans notre cas nous avons utilisé le matcher IsEqual.equalTo pour comparer la liste des notes retournée et celle attendue. La méthode buildNoteList() utilisée dans l'assertion construira une liste de notes à partir d'un tableau de données :

NoteDaoTest.java

...
private static List<Note> buildNoteList(Object[][] datas) throws Exception {
String[] columnNames = new String[]{"id", "userId", "title", "content"};
List<Note> noteList = new ArrayList<Note>();
for (Object[] data : datas) {
Note note = new Note();
for (int j = 0; j < columnNames.length; j++) {
PropertyUtils.setSimpleProperty(note, columnNames[j], data[j]);
}
noteList.add(note);
}
return noteList;
}
...

V-B-1-b - Écriture du test de sélection d'une note

Nous utiliserons les mêmes données en entrée que le test de selectAllByUser() :

NoteDaoTest.java

@Test
public void selectById() throws Exception {
Note note = noteDao.selectById(1);
assertThat(note, equalTo(buildNote(
new Object[]{1, "tylerdurden", "Savon", "Vendre tout le stock})));
}

La méthode buildNote() créera un objet Note avec un tableau de valeurs.

NoteDaoTest.java

...
private static Note buildNote(Object[] data) throws Exception {
String[] columnNames = new String[]{"id", "userId", "title", "content"};
Note note = new Note();
for (int j = 0; j < columnNames.length; j++) {
PropertyUtils.setSimpleProperty(note, columnNames[j], data[j]);
}
return note;
}

V-B-1-c - Remaniement de la classe de test

Nous pouvons remanier le test en réutilisant la méthode buildNote(Object[]) dans la méthode buildNoteList(Object[][]). Ensuite, la description des colonnes peut être mise en commun au travers d'une constante.

NoteDaoTest.java

....
private static final String[] NOTE_COLUMN_NAMES = new String[]{"id", "userId", "title", "content"};
...
private static List<Note> buildNoteList(Object[][] datas) throws Exception {
List<Note> noteList = new ArrayList<Note>();
for (Object[] data : datas) {
Note note = buildNote(data);
noteList.add(note);
}
return noteList;
}

private static Note buildNote(Object[] data) throws Exception {
Note note = new Note();
for (int j = 0; j < NOTE_COLUMN_NAMES.length; j++) {
PropertyUtils.setSimpleProperty(note, NOTE_COLUMN_NAMES[j], data[j]);
}
return note;
}
....

V-B-1-d - Écriture du test d'insertion d'une note

Dans ce test, nous utiliserons les mêmes données en entrée que celles des tests précédemment écrits et nous assertons que la note Java : Refactorer classe Beer a été ajoutée en base.

NoteDaoTest.java

@Test
public void insertNote() throws Exception {
noteDao.insert(buildNote(new String[]{null, "johndoe", "Java", "refactorer classe Beer"}));
assertTableOutput("NoteDaoTest_insertNote.output", "NOTE");
}

NoteDaoTest_insertNote.output

<dataset>
<NOTE ID="0" USER_ID="johndoe" TITLE="Livraison" CONTENT="Envoyer paquet à l'inspecteur Somerset"/>
<NOTE ID="1" USER_ID="tylerdurden" TITLE="Savon" CONTENT="Vendre tout le stock"/>
<NOTE ID="2" USER_ID="tylerdurden" TITLE="Fight Club" CONTENT="Recruter à Chicago"/>
<NOTE ID="3" USER_ID="johndoe" TITLE="Java" CONTENT="refactorer classe Beer"/>
</dataset>

La méthode assertTableOutput consistera à comparer les données étalon d'une table au format DBUnit et les données en base. Nous aurons besoin de :

- Récupérer la connexion DBUnit,

- Construire le dataset avec le fichier étalon,

- Comparer les données de la table avec le dataset précédemment construit.

Tout d'abord, dans la classe abstraite AbstractDaoTestCase, la méthode getDataset() va changer de signature : elle sera visiblité protected, aura en paramètre le nom du fichier DBUnit et sera renommée buildDataSet(). En conséquence la méthode initDataSet() qui l'utilise sera impactée. Ensuite la connexion dbUnitConnection sera accessible aux classes concrètes, initialisée dans la méthode setUp() puis fermée dans la méthode tearDown().

AbstractDaoTestCase.java

public abstract class AbstractDaoTestCase {

@Autowired
private IDatabaseTester databaseTester;
protected IDatabaseConnection dbUnitConnection;

@Before
public void setUp() throws Exception {
dbUnitConnection = databaseTester.getConnection();

initDatabase();
initDataSet();
}

@After
public void tearDown() throws Exception {
dbUnitConnection.close();
}
...
private void initDataSet() throws Exception {
...
DatabaseOperation.CLEAN_INSERT.execute(dbUnitConnection, buildDataSet(getDataSetFile()));
}

protected FlatXmlDataSet buildDataSet(String dataSetFile) throws Exception {
return new FlatXmlDataSet(getClass().getResourceAsStream(dataSetFile));
}

...
}

La méthode assertTableOutput sera accessible depuis la classe AbstractDaoTestCase :

AbstractDaoTestCase.java

...
protected void assertTableOutput(String dataSetOutputFilename,
String tableName) throws Exception {
ITable actualNoteTable = dbUnitConnection.createDataSet().getTable(tableName);
ITable expectedNoteTable = buildDataSet(dataSetOutputFilename).getTable(tableName);
Assertion.assertEquals(expectedNoteTable, actualNoteTable);
}
...

La méthode Assertion.assertEquals utilisée ci-dessus est issue de la classe org.dbunit.Assertion (ne pas se tromper avec celle de JUnit).

V-B-2 - Ecriture de la couche Service

Dans cette section, nous nous focaliserons essentiellement sur l'utilisation de Mockito lors de l'écriture des tests.

L'interface de Mockito a bien évolué. Auparavant, l'enchaînement de méthodes utilisé était stub...toReturn. Depuis la version 1.6, il est conseillé d'utiliser when...thenReturn (consulter la javadoc).

Cette façon de nommer les méthodes rappelle fortement un article de Martin Fowler sur les Fluent Interfaces, dans lequel il explique que la combinaison du pattern Builder et de signature des méthodes chaînées qui se rapprochent du langage naturel permet d'améliorer la lisibilité du code. Ainsi dans Mockito, when(mock.aMethod()).thenReturn(Something) paraît plus lisible et plus naturel que stub(mock.aMethod()).toReturn(Something). Nous retrouvons également ce type de démarche au travers des Matchers utilisés dans JUnit 4 (assertThat(a, isGreaterThan(b)).

À l'identique du chapitre précédent, seule une partie des tests unitaires de la couche seront développés. Le reste sera disponible via les sources du projet.

V-B-2-a - Écriture du test de sélection des notes de l'utilisateur

Afin que notre test NoteServiceTest puisse bénéficier des méthodes buildNoteList() et buildNote() issues de NoteDaoTest, nous allons créer une classe utilitaire de test pour les tests liés aux notes et nous la mettrons dans le package model :

NoteTestUtil.java

public class NoteTestUtil {
public static final String[] NOTE_COLUMN_NAMES = new String[]{"id", "userId", "title", "content"};

private NoteTestUtil() {}

public static List<Note> buildNoteList(Object[][] datas) throws Exception {...}

public static Note buildNote (Object[] data) throws Exception {...}
}

Notre test se décomposera de la façon suivante :

1. Initialisation de NoteService et de NoteDao,

2. Simulation de l'appel de la méthode selectAllByUser(),

3. Assertion du résultat escompté,

4. Assertion du nombre d'appels de la méthode selectAllByUser() de NoteDao.

NoteServiceTest.java

public class NoteServiceTest {

@Autowired
private NoteService noteService;

@Mock
private NoteDao noteDaoMock;

@Test
public void selectAllByUser() throws Exception {
when(noteDaoMock.selectAllByUser("tylerdurden")).thenReturn(
buildNoteList(new Object[][]{
{1, "tylerdurden", "Savon", "Aller chercher de la graisse dans les centres de liposucion"},
{2, "tylerdurden", "Fight Club", "Recruter à Chicago"}
}));

List<Note> noteList = noteService.selectAllByUser("tylerdurden");
assertThat(noteList, equalTo(new Object[][]{
{1, "tylerdurden", "Savon", "Aller chercher de la graisse dans les centres de liposucion"},
{2, "tylerdurden", "Fight Club", "Recruter à Chicago"}
}));

verify(noteDaoMock, times(1)).selectAllByUser("tylerdurden");
}

@Before
public void setUp() throws Exception{
MockitoAnnotations.initMocks(this);
ReflectionTestUtils.setField(noteService, "noteDao", noteDaoMock);
}
}

Nous pouvons nous poser la question sur l'utilité de renvoyer la même liste de notes que celle attendue. Nous aurions pu déclarer une liste de note en tant que variable locale et asserter que le résultat était bien identique.

Autre manière d'écrire le test

@Test
public void selectAllByUser() throws Exception {
List<Note> expectedNoteList = buildNoteList(new Object[][]{
{1, "tylerdurden", "Savon", "Aller chercher de la graisse dans les centres de liposucion"},
{2, "tylerdurden", "Fight Club", "Recruter à Chicago"}
})
when(noteDaoMock.selectAllByUser("johndoe")).thenReturn(expectedNoteList);

List<Note> noteList = noteService.selectAllByUser("tylerdurden");
assertThat(noteList, equalTo(expectedNoteList));

verify(noteDaoMock, times(1)).selectAllByUser("tylerdurden");
}

À la lecture de ce test, nous avons l'impression que l'assertion et la méthode simulée partagent les mêmes données ce qui peut induire des erreurs d'interprétation (existe-t-il une dépendance de données entre l'assertion et la méthode simulée ?).
Bien que l'implémentation actuelle de NoteService ne fasse rien d'autre que d'appeler les méthodes de la couche Dao, si un besoin fonctionnel devait apparaître au cours du projet (exemple : convertir le titre de la note en majuscule), nous ne pourrions plus appliquer cela.
Il est donc nécessaire de séparer les données en entrée des données de sortie.

V-B-2-b - Écriture du test d'insertion d'une note

L'écriture est très similaire à ce que nous avons écrit précédemment.

NoteServiceTest.java

@Test
public void insertNote() throws Exception {
when(noteDaoMock.insert(any(Note.class))).thenAnswer(new Answer<Note>() {

public Note answer(InvocationOnMock invocationOnMock) throws Throwable {
Object[] arguments = invocationOnMock.getArguments();
final Note note = (Note)arguments[0];
note.setId(1);
return note;
}
});

Note noteInserted = noteService.insertNote(buildNote(
new Object[]{10, "deckard", "Travail", "Rapport d'activité"}));

assertThat(noteInserted, notNullValue());

assertThat(noteInserted.getId(), equalTo(1));

verify(noteDaoMock, times(1)).insert(any(Note.class));
}

- L'utilisation de la méthode any(Note.class) permet d'indiquer à Mockito d'intercepter l'appel de la méthode insert pour n'importe quelle instance de la classe Note, ce qui est très pratique dans notre cas de test.

- L'utilisation de la méthode thenAnswer(Answer) permet au développeur de contrôler les évènements qui surviennent lors de l'appel de la méthode simulée. Dans notre cas, nous nous contentons de renvoyer le bean Note avec un identifiant de note "codé en dur".

En conclusion de cette section, l'API Mockito nous simplifie l'écriture des tests et l'utilisation d'interfaces qui suivent la philosophie Fluent Interfaces permet d'améliorer la lisibilité des tests.

V-B-3 - Écriture de la couche web

Nous allons définir la hiérarchie et la navigation des pages web :

Navigation des pages

V-B-3-a - Page principale des notes

V-B-3-a-i - Écriture du test

Nous allons asserter que la liste de notes et le lien Ajouter sont affichés.

NotePageTest.java

public class NotePageTest extends AbstractWicketTestCase {

@Mock
private NoteService noteServiceMock;

@Test
public void testRender() throws Exception {
List<Note> noteList = buildNoteList(
new Object[][]{
{0, "johndoe", "Développement", "Java"},
{1, "johndoe", "Lean", "accepter l'échec"},
});

when(noteServiceMock.selectAllByUser("johndoe")).thenReturn(noteList);

tester.startPage(NotePage.class);
tester.assertListView("noteList", noteList);
tester.assertComponent("insertNoteLink", Link.class);

verify(noteServiceMock, times(1)).selectAllByUser("johndoe");
}

@Override
protected void doSetUp() throws Exception {
MockitoAnnotations.initMocks(this);
applicationContextMock.putBean("noteService", noteServiceMock);
}
}

Il nous manque donc la création de la classe NotePage qui héritera de la classe LoggedInPage (nous ne pouvons pas accéder à nos notes si nous ne sommes pas connectés).

V-B-3-a-ii - Exécution du test

Exécution du test render de NotePage

L'erreur indique que la fixture tester n'a pas pu instancier la classe NotePage. Inspectons le code du constructeur LoggedInPage.

LoggedInPage.java

public LoggedInPage() {
User user = getMyNotesSession().getUser();
add(new Label("user", user.getFullName()));
add(new LogoutLink());
}

Dans notre test, nous voyons qu'il manque la phase d'authentification. Dans un environnement de test unitaire, nous aimerions l'éviter afin de ne pas d'alourdir le test.

Ensuite, nous remarquons que le test a mis en évidence un bug. En effet, un accès direct à LoggedInPage lance une exception, car getMyNotesSession().getUser() renvoie null et par conséquent l'appel de user.getFullName() génère une exception de type NullPointerException.

Enfin, LoggedInPage n'autorise pas d'héritage de page (pas de balise <wicket:child/>), nous devons la rendre abstraite afin que NotePage puisse en dériver.

En résumé, nous avons trois tâches :

- dans LoggedInPage, rediriger l'utilisateur vers HomePage si jamais il ne s'est pas authentifié ;

- transformer LoggedInPage en classe abstraite. Une classe WelcomePage en dérivera ainsi que la classe NotePage ;

- pouvoir simuler la phase d'authentification de manière simple dans les tests unitaires.

V-B-3-a-iii - Remaniement de l'existant

Nous rajouterons la vérification de l'authentification utilisateur dans le constructeur de la classe LoggedInPage. Dans le mauvais cas, l'utilisateur sera redirigé vers la page de connexion.

LoggedInPage.java

public abstract class LoggedInPage extends MyNotesPage {

protected LoggedInPage() {
if (!getMyNotesSession().isAuthenticated()) {
throw new RestartResponseAtInterceptPageException(HomePage.class);
}
add(new LogoutLink());
}

private class LogoutLink extends Link {...}
}

LoggedInPage.html

<wicket:extend>
<div id="xo_alignement">
<!-- Menu -->
<div id="xo_menu">
<ul id="menuitems">
<li><a wicket:id="noteLink" id="noteLink"><img src="/mynotes/images/note.png"/>Notes</a></li>
</ul>
</div>

<!-- Contenu -->
<wicket:child/>
</div>

</wicket:extend>

Le design des pages s'appuie désormais sur du CSS et des DIV.

WelcomePage sera la page d'accueil de Mynotes et donc affichera le message de bienvenue.

WelcomePage.java

public class WelcomePage extends LoggedInPage {

public WelcomePage() {
User user = getMyNotesSession().getUser();
add(new Label("user", user.getFullName()));
}

WelcomePage.html

<wicket:extend>
<div id="welcomeText">
Bonjour <span wicket:id="user"/> et bienvenue dans l'application MyNotes.
</div>
</wicket:extend>

Du coup LoggedInPageTest sera renommé en WelcomePageTest et nous rajouterons un test qui vérifie bien la redirection vers la HomePage dans le cas où l'utilisateur ne s'est pas connecté.

WelcomePageTest.java

@Test
public void redirectToHomePageWhenTryingToAccess() throws Exception {
tester.startPage(WelcomePage.class);
tester.assertRenderedPage(HomePage.class);
}

Nous allons mettre à disposition au développeur une méthode de simulation de l'authentification de MyNotes dans la classe AbstractWicketTestCase.

AbstractWicketTestCase.java

public abstract class AbstractWicketTestCase {

protected WicketTester tester;

protected ApplicationContextMock applicationContextMock;

@Before
public final void setUp() throws Exception {
applicationContextMock = new ApplicationContextMock();

MyNotesApplication webApp = new MyNotesApplication() {
@Override
public void init() {
addComponentInstantiationListener(new SpringComponentInjector(this, applicationContextMock));
}
};

tester = new WicketTester(webApp);

doSetUp();
}

protected abstract void doSetUp() throws Exception;

protected void mockAuthentification(String userId) {
MockHttpSession session = tester.getServletSession();
MyNotesSession myNotesSession = MyNotesSession.session();
myNotesSession.setUser(UserTestUtil.buildSimpleUser(userId));
session.setAttribute("wicket:WicketMockServlet:session", myNotesSession);
}
}

La méthode mockAuthentification permet de stocker dans la session un objet user. C'est un peu "grouillesque", mais cela suffit à combler notre besoin pour l'instant.
Ensuite, nous déplacerons l'appel de la méthode doSetup() afin de pouvoir appeler si besoin mockAuthentification dans notre classe de test.
Enfin, nous renommerons la classe UserFactory en UserTestUtil par souci de cohérence avec la classe NoteTestUtil et nous lui rajouterons la méthode buildSimpleUser().

UserTestUtil.java

public class UserTestUtil {

public static final String[] USER_COLUMN_NAMES =
new String[]{"userId", "firstName", "lastName", "email", "password", "isAdmin"};

private UserTestUtil() {
}

public static User buildUser(Object[] data) throws Exception {
User user = new User();
for (int j = 0; j < USER_COLUMN_NAMES.length; j++) {
PropertyUtils.setSimpleProperty(user, USER_COLUMN_NAMES[j], data[j]);
}
return user;
}

public static User buildSimpleUser(String userId) {
User user = new User();
user.setUserId(userId);
return user;
}
}

Suite à notre remaniement, exécutons tous les tests de la couche web.

Nous voyons que WelcomePageTest ne passe plus. Après investigation, nous constatons que nous avons oublié de rediriger HomePage vers WelcomePage suite à une authentification valide.

LoginForm.java

public class LoginForm extends Form {

public LoginForm(String id) {
...
Button button = new Button("loginSubmit") {
@Override
public void onSubmit() {
User user;
try {
user = securityService.authenticate(getUserId(), getPassword());
if (user == null) {
error("Identifiant ou mot de passe invalide.");
}
else {
MyNotesSession session = getMyNotesSession();
session.setUser(user);
setResponsePage(WelcomePage.class); //changement de page suite à l'authentification
}
}
catch (Exception e) {
LOG.error(e.getMessage());
error(e.getMessage());
}
}
};
}
...
}

Réexécutons les tests :

Exécution des tests unitaires de la couche web

Nous voyons que seul le test NotePage ne passe pas. Nous pouvons enfin finaliser notre test et exécuter la suite de tests de la couche web :

NotePageTest.java

@Test
public void testRender() throws Exception {

List<Note> noteList = NoteTestUtil.buildNoteList(
new Object[][]{
{0, "johndoe", "Développement", "Java"},
{1, "johndoe", "Lean", "accepter l'échec"},
});

when(noteServiceMock.selectAllByUser("johndoe")).thenReturn(noteList);

tester.startPage(NotePage.class);
tester.assertListView("noteList", buildNoteList(
new Object[][]{
{0, "johndoe", "Développement", "Java"},
{1, "johndoe", "Lean", "accepter l'échec"},
}));
tester.assertComponent("insertNoteLink", Link.class);

verify(noteServiceMock, times(1)).selectAllByUser("johndoe");
}

@Override
protected void doSetUp() throws Exception {
noteServiceMock = Mockito.mock(NoteService.class);
applicationContextMock.putBean("noteService", noteServiceMock);

mockAuthentification();
}

Nous venons de constater l'importance des tests surtout lorsque des tâches de remaniement sont effectuées. L'application est minuscule, il faut imaginer l'impact de quelques modifications dans une application de plus grande envergure. L'exécution des tests nous donne un résultat immédiat sur les éventuelles régressions et nous évite certaines tâches de correction fastidieuses suite à un bug signalé en recette voire en production.

V-B-3-a-iv - Implémentation

NotePage.java

public class NotePage extends LoggedInPage {

@SpringBean
private NoteService noteService;

public NotePage() {
try {
add(new NoteListView("noteList", noteService.selectAllByUser(getUser().getUserId())));
add(new InsertNoteLink("insertNoteLink"));
}
catch (Exception ex) {
error(ex.getMessage());
}
}

private class NoteListView extends ListView {
private NoteListView(String id, List<Note> noteList) {
super(id, noteList);
}

@Override
protected void populateItem(ListItem item) {
final Note note = (Note)item.getModelObject();
item.add(new Label("id", note.getId().toString()));
item.add(new Label("title",
note.getTitle()));
item.add(new Label("content", note.getContent()));
}
}

class InsertNoteLink extends Link {

private InsertNoteLink(String id) {
super(id);
}

@Override
public void onClick() {
try {
//                TODO
}
catch (Exception ex) {
NotePage.this.error(ex);
}
}
}
}

NotePage.html

<wicket:extend>
<h1>Vos notes</h1>

<div>
<a href="#" wicket:id="insertNoteLink" id="insertNote">
<img src="/mynotes/images/note_add.png"/>Ajouter
</a>
</div>

<table id="noteList" border="1">
<thead>
<tr>
<td>#</td>
<td>Titre</td>
<td>Contenu</td>
</tr>
</thead>
<tbody>
<tr wicket:id="noteList">
<td wicket:id="id">#</td>
<td wicket:id="title">Titre</td>
<td wicket:id="content">Contenu</td>
</tr>
</tbody>
</table>
</wicket:extend>

Nous notons que la gestion des listes de données sous la forme de tableau est aisée avec Wicket.

V-B-3-a-v - Réexécution du test

À présent, il nous reste la mise à jour et la suppression d'une note (l'insertion ne sera pas présentée).

V-B-3-b - Mise à jour d'une note

D'après le diagramme de navigation des pages, Modifier implique la page NoteEdit. Nous nous contenterons d'asserter que le fait de cliquer sur Modifier amène l'utilisateur sur cette page.

NoteDaoTest

@Test
public void updateLink() throws Exception {
List<Note> noteList = NoteTestUtil.buildNoteList(
new Object[][]{
{0, "johndoe", "Développement", "Java"},
{1, "johndoe", "Lean", "accepter l'échec"},
});

when(noteServiceMock.selectAllByUser("johndoe")).thenReturn(noteList);

tester.startPage(NotePage.class);
tester.clickLink("noteList:0:update");
tester.assertRenderedPage(NoteEditPage.class);

verify(noteServiceMock, times(1)).selectNoteById(0);
}

La notation noteList:0:update indique le lien update de la ligne 0 de noteList.

NoteEditPage.java

public class NoteEditPage extends LoggedInPage{
}

NoteEdit.html

<wicket:extend>
</wicket:extend>

V-B-3-b-i - Exécution du test

V-B-3-b-ii - Implémentation

NotePage.java

private class NoteListView extends ListView {
private NoteListView(String id, List<Note> noteList) {
super(id, noteList);
}

@Override
protected void populateItem(ListItem item) {
final Note note = (Note)item.getModelObject();
...
item.add(new UpdateNoteLink("update", note));
}
}

private class UpdateNoteLink extends Link {
private final Note note;

private UpdateNoteLink(String id, Note note) {
super(id);
add(new AttributeModifier("id", new Model("update_" + note.getId())));
this.note = note;
}

@Override
public void onClick() {
try {
setResponsePage(new NoteEditPage( noteService.selectNoteById(note.getId())));
}
catch (Exception ex) {
NotePage.this.error(ex);
}
}
}

NotePage.html

<wicket:extend>
<h1>Vos notes</h1>

...

<table id="noteList" border="1">
...
<tr wicket:id="noteList">
...
<td>
<a href="#" wicket:id="update" id="updateLinkId" ><img src="/mynotes/images/note_edit.png" alt="Modifier"/></a>
</td>
</tr>
</tbody>
</table>
</wicket:extend>

Sur l'évènement onClick du lien updateNoteLink, l'utilisateur sera redirigé vers la page NotePageEdit.

Pour chaque note affichée dans la NoteListView, un lien Modifier sera disponible. Lorsque l'utilisateur cliquera sur celui-ci, il sera redirigé vers la page NoteEdit avec les informations de la note à modifier. Nous notons l'utilisation de la classe AttributeModifier qui permet de modifier la valeur d'une propriété html. Dans notre cas, la valeur de l'attribut id de l'hyperlien update aura comme valeur update_[noteId]. Cela permet une bonne lecture du test fonctionnel sans que le code de production soit "altéré".

NoteEditPage

public NoteEditPage(Note note) {
//    TODO
}

V-B-3-b-iii - Réexécution du test

V-B-3-c - Suppression d'une note

V-B-3-c-i - Écriture du test

NotePageTest.java

@Test
public void deleteNote() throws Exception {
// Stub noteService method invocations
final List<Note> noteList = buildNoteList(
new Object[][]{
{0, "johndoe", "Développement", "Java"},
{1, "johndoe", "Lean", "accepter l'échec"},
});

when(noteServiceMock.selectAllByUser("johndoe")).thenReturn(noteList);
when(noteServiceMock.deleteNote(any(Note.class))).thenAnswer(new Answer<Integer>() {

public Integer answer(InvocationOnMock invocationOnMock) throws Throwable {
Object[] args = invocationOnMock.getArguments();
Note deleteNote = (Note)args[0];
noteList.remove(deleteNote);
return 1;
}
});

// Test steps
tester.startPage(NotePage.class);
tester.clickLink("noteList:0:delete");
tester.assertListView("noteList", buildNoteList(
new Object[][]{
{1, "johndoe", "Lean", "la chasse au muda"},
}));

// Assert note service method calls
verify(noteServiceMock, times(1)).deleteNote(Mockito.any(Note.class));
verify(noteServiceMock, times(2)).selectAllByUser("johndoe");
}

V-B-3-c-ii - Implémentation

NotePage.java

...
private class DeleteNoteLink extends Link {
private final Note note;

private DeleteNoteLink(String id, Note note) {
super(id);
add(new AttributeModifier("id", new Model("delete_" + note.getId())));
this.note = note;
}

@Override
public void onClick() {
try {
noteService.deleteNote(note);
setResponsePage(NotePage.class);
}
catch (Exception ex) {
NotePage.this.error(ex);
}
}
}
}

NotePage.html

<wicket:extend>
...
<table id="noteList" border="1">
...
<tr wicket:id="noteList">
...
<td>
<a href="#" wicket:id="update" id="updateLinkId" ><img src="/mynotes/images/note_edit.png" alt="Modifier"/></a>
<a href="#" wicket:id="delete" id="deleteLinkId"><img src="/mynotes/images/note_delete.png" alt="Supprimer"/></a>
</td>
</tr>
</tbody>
</table>
</wicket:extend>

À présent il nous reste le lien entre la page principale et la page des notes. Nous rajouterons un lien dans la classe abstraite LoggedInPage et nous mettrons à jour le test WelcomePage

LoggedInPage.java

private static class NoteLink extends Link {

private NoteLink() {
super("noteLink");
}

@Override
public void onClick() {
setResponsePage(NotePage.class);
}
}

LoggedInPage.html

<wicket:extend>
<div id="xo_alignement">
<!-- Menu -->
<div id="xo_menu">
<ul id="menuitems">
<li><a wicket:id="noteLink" id="noteLink"><img src="/mynotes/images/note.png"/>Notes</a></li>
<li><a wicket:id="logoutLink" id="logoutLink"><img src="/mynotes/images/door_out.png"/>D&eacute;connexion</a></li>
</ul>
</div>

<!-- Contenu -->
<wicket:child/>
</div>
</wicket:extend>

WelcomePageTest.java

@Test
public void testRender() throws Exception {
tester.startPage(HomePage.class);

...

tester.clickLink("noteLink");
tester.assertRenderedPage(NotePage.class);
}

Le test et le code de NoteEditPage ne seront pas présentés, car la démarche est similaire. Relançons tous les tests unitaires de l'application :

À la fin de cette section, nous ressentons une certaine maîtrise dans l'exécution de nos tâches. Les tests nous permettent de remanier le code en toute confiance. Ce dernier prend de la maturité au fur et à mesure. Il vit tout simplement ;).

V-B-4 - Exécution des tests fonctionnels

Nous allons rajouter le test des erreurs de connexion dans le test fonctionnel :

ConnexionTest.xml

<webtest name="Test connexion into MyNotes">
<group description="Bad Login : missing input fields">
<invoke url="${webapp.home}" description="Go to MyNotes"/>
<clickButton label="Connexion"/>
</group>
<group description="Verify we are still on the login page with the error message">
<verifyText text="Le champ .*userId.* est obligatoire" regex="true"/>
<verifyText text="Le champ .*password.* est obligatoire" regex="true"/>
</group>

<group description="Bad Login : wrong input fields">
<setInputField name="userId" value="JohnDoe"/>
<setInputField name="password" value="six"/>
<clickButton label="Connexion"/>
</group>
<group description="Verify we are still on the login page with the error message">
<verifyText text="Identifiant ou mot de passe invalide."/>
</group>

<group description="Good Login">
<invoke url="${webapp.home}" description="Go to MyNotes"/>
<setInputField name="userId" value="JohnDoe"/>
<setInputField name="password" value="seven"/>
<clickButton label="Connexion"/>
</group>
<group description="Verify welcome text">
<verifyText text="Bonjour"/>
<verifyText text="John Doe"/>
<verifyText text="et bienvenue dans l'application MyNotes."/>
</group>
<group description="Disconnect and verify that we are on the login page">
<clickLink label="Déconnexion"/>
<verifyText text="Connexion"/>
</group>
</webtest>

Dans le deuxième groupe, afin de pouvoir valider le message d'erreur des champs obligatoire, le caractère apostrophe (#039;) semble être mal géré par la balise verifyText. Du coup, nous devons utiliser l'attribut regex et appliquer le pattern .*<nom du champ>.*.

Relançons un build complet avec les tests fonctionnels et vérifions qu'ils passent bien tous.

Rapport d'exécution des tests fonctionnels

V-C - Allons un peu plus loin ...

Les sections suivantes concernent l'amélioration de la plateforme de tests que nous avons mis en place dans le chapitre 3. Il s'agit de pouvoir exécuter de bout en bout une batterie de tests fonctionnels quel que soit le résultat et d'améliorer l'écriture/lecture de l'assertion des tableaux HTML.

V-C-1 - Amélioration de l'exécution des tests fonctionnels

Le processus d'exécution des tests webtest est interrompu dès lors qu'une exception BuildException est levée. Si une assertion échoue lors de l'exécution d'un test i, le suivant ne sera exécuté. Ceci est fâcheux si la suite comporte plus de 200 tests, car nous ne pouvons pas mesurer l'impact global du code modifié.

Nous allons créer une tâche Ant WebTestSuite qui, à la manière d'une suite de tests unitaires, lorsqu'un test échouera, journalisera l'erreur soulevée puis passera au test suivant. À la fin de l'exécution, elle génèrera un rapport d'exécution et lancera une exception si au moins un test a échoué.

V-C-1-a - Écriture du test

Un projet webtest-contrib sera créé à l'occasion et nous développerons sous le package com.webtest.ant.

Après plusieurs essais infructueux sur la simulation de la méthode execute() de la classe Ant, je n'ai pu tester que la méthode qui génèrera la liste des fichiers webtest. Les autres tests concernent la vérification des paramètres de la balise.

WebTestSuiteTaskTest.java

public class WebTestSuiteTaskTest extends TestCase {

private WebTestSuiteTask task;

public void test_buildWebTestListWithBasicTestSuite() throws Exception {
File webtestdir = new File(getClass().getResource("basictestsuite").toURI());

task.setWebtestdir(webtestdir);
List<FileResource> fileList = task.buildWebTestList();
assertFileList(fileList, new String[]{
"Test1.xml",
"Test2.xml",
});
}

private void assertFileList(List<FileResource> actualfileList,
String[] expectedFileList) {
assertEquals(expectedFileList.length, actualfileList.size());

for (int i = 0; i < expectedFileList.length; i++) {
String expectedFile = expectedFileList[i];
FileResource actualFile = actualfileList.get(i);
assertEquals(expectedFile, actualFile.getName());
}
}

@Override
public void setUp() {
Project project = new Project();
task = new WebTestSuiteTask();
task.setProject(project);
}
}

Sachant que les fichiers Test1.xml et Test2.xml sont dans un répertoire nommé basictestsuite :

WebTestSuiteTask.java

public class WebTestSuiteTask extends Task {

private File webtestdir;

@Override
public void execute() throws BuildException {
// TODO
}

List<FileResource> buildWebTestList() {
List<FileResource> webTestFiles = new ArrayList<FileResource>();
return webTestFiles;
}

public void setWebtestdir(File webtestdir) {
this.webtestdir = webtestdir;
}
}

V-C-1-b - Exécution du test

V-C-1-c - Implémentation

L'implémentation sera assez simple. Nous utiliserons un FileSet pour récupérer les fichiers qui nous intéressent (**/*.xml). Puis nous retournerons le résultat sous la forme d'une liste de FileRessource. Par ailleurs, nous journaliserons l'exécution de cette méthode en imprimant le répertoire de tests puis le nombre de tests trouvés.

WebTestSuiteTask.java

...
List<FileResource> buildWebTestList() {
LOG.info("Webtest dir = " + webtestdir.getAbsolutePath());
FileSet fileSet = new FileSet();
fileSet.setDir(webtestdir);
fileSet.setIncludes("**/*.xml");

fileSet.setProject(getProject());
List<FileResource> webTestFiles = new ArrayList<FileResource>();
Iterator resources = fileSet.iterator();
while (resources.hasNext()) {
Resource resource = (Resource)resources.next();
if (!resource.isExists()) {
continue;
}

if (resource instanceof FileResource) {
FileResource fr = (FileResource)resource;
webTestFiles.add(fr);
LOG.debug("test added = '" + fr.getFile().getPath() + "'");
}
}
LOG.info("Found  " + webTestFiles.size() + " webtests");
return webTestFiles;
}

Ensuite, nous rendrons paramétrable le pattern d'inclusion puis nous rajouterons la possibilité d'exclure certains fichiers/répertoires.
Enfin, nous donnerons la possibilité de générer le rapport d'exécution des tests avec la feuille XSL de canoo.

Il en résultera les fichiers source suivants : WebTestSuiteTaskTest.java, WebTestSuiteTask.java.

V-C-1-d - Intégration dans le projet mynotes

Installons la petite librairie webtest-contrib dans le dépôt local puis référençons-la dans le profile integration de mynotes.

pom.xml

<plugin>
<artifactId>maven-antrun-plugin</artifactId>
<executions>
...
<execution>
<id>run-webtest</id>
<phase>integration-test</phase>
<configuration>
<tasks>
<property name="runtime_classpath" refid="maven.test.classpath"/>
...
<ant antfile="${webtest.tests}/allTests.xml" inheritRefs="true"
dir="${webtest.tests}" inheritAll="true"/>
</tasks>
</configuration>
<goals>
<goal>run</goal>
</goals>
</execution>
</executions>
<dependencies>
<dependency>
<groupId>com.canoo</groupId>
<artifactId>webtest-contrib</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
</dependencies>
</plugin>

Ensuite, rajoutons la balise WebTestSuiteTask dans le fichier Config.xml.

Config.xml

<taskdef name="webtestsuite" classname="com.canoo.ant.WebTestSuiteTask" classpath="${runtime_classpath}"/>

Enfin, mettons à jour le fichier AllTests.xml.

allTests.xml

<?xml version="1.0" encoding="UTF-8"?>

<!DOCTYPE project SYSTEM "config/MyWebTest.dtd">

<project default="runTests">

&config__xml;

<target name="runTests" description="runs all the tests">
<echo message="Starting runnning test suite"/>
<webtestsuite webtestdir="usecase" reportgeneratorfile="report/generateReport.xml"/>
</target>
</project>

Si l'on exécute notre suite de tests, tout se passe comme prévu. Il n'y a aucune régression.

Rajoutons une erreur dans un test fonctionnel ConnectionTest.xml.

ConnectionTest.xml

...
<group description="Good Login">
<invoke url="${webapp.home}" description="Go to MyNotes"/>
<setInputField name="userId" value="johndoe"/>
<setInputField name="password" value="seven"/>
<clickButton label="Connexion"/>
</group>
<group description="Verify welcome text">
<verifyText text="Bonjour"/>
<verifyText text="John Connors"/> <!-- Erreur volontaire -->
<verifyText text="et bienvenue dans l'application MyNotes."/>
</group>
...

Une erreur a bien été trouvée dans ConnectionTest.xml.

En ouvrant le rapport généré, nous constatons que le test NotesManagement.xml a été exécuté.

V-C-2 - Amélioration de l'assertion des tableaux HTML

Nous avons vu lors de l'écriture du test fonctionnel de gestion des notes que l'assertion des cellules d'un tableau HTML n'est pas très aisée et pas très lisible :

NotesManagement.xml

<group description="Assert that a table is displayed with 2 rows">
<verifyText description="verify note #" text="0">
<table row="1" column="0" htmlid="noteList"/>
</verifyText>
<verifyText description="verify note title" text="Supermarché">
<table row="1" column="1" htmlid="noteList"/>
</verifyText>
<verifyText description="verify note content" text="Beurre, farine, lait">
<table row="1" column="2" htmlid="noteList"/>
</verifyText>

<verifyText description="verify note #" text="1">
<table row="2" column="0" htmlid="noteList"/>
</verifyText>
<verifyText description="verify note title" text="Ordinateur">
<table row="2" column="1" htmlid="noteList"/>
</verifyText>
<verifyText description="verify note content" text="Remplacer carte graphique">
<table row="2" column="2" htmlid="noteList"/>
</verifyText>
</group>

Nous pourrions asserter le contenu avec une représentation ASCII des données :

<verifyTable htmlid="noteList" excludedColumns="3">
| # | Titre       | Contenu                   |
| 0 | Supermarché | Beurre, farine, lait      |
| 1 | Ordinateur  | Remplacer carte graphique |
</verifyTable>

Si nous appliquons cette représentation sur tout le test, cela nous donne une lecture plus compacte et aisée à lire : NotesManagement.xml.

V-C-2-a - Préparatifs

Nous réutiliserons le projet webtest-contrib et nous mettrons notre future balise VerifyTableStep sous com.canoo.webtest.extension.
Nous aurons besoin de classes de test de Canoo et étant donné que le module de tests de webtest n'est pas "mavenifié", il faudra l'importer dans le dépôt local à l'aide de la commande suivante (depuis le répertoire lib de la distribution de canoo) :

mvn install:install-file -DgroupId=com.canoo.webtest -DartifactId=webtest -Dclassifier=tests -Dversion=R_1689 -Dpackaging=jar -Dfile=webtest_T.jar

V-C-2-b - Écriture du test

Notre test unitaire étendra la classe com.canoo.webtest.steps.BaseStepTestCase. En entrée, nous aurons un tableau HTML 'tbl' basique à 3 colonnes avec un entête et une ligne de données. Notre premier test sera le cas nominal où l'assertion réussit.

VerifyTableStepTest.java

public class VerifyTableStepTest extends BaseStepTestCase {

private static final String HTML_PAGE_WITH_TABLE =
"  <html>"
+ "    <head>"
+ "        <title>Test</title>"
+ "    </head>"
+ "    <body>"
+ "        <table id='tbl'>"
+ "            <tr>"
+ "                <th>Denomination</th><th>Prix unitaire</th><th>Qté</th><th>Total</th>"
+ "            </tr>"
+ "            <tr>"
+ "                <td>carotte</td><td>1.00</td><td>5</td><td>5.00</td>"
+ "            </tr>"
+ "        </table>"
+ "    </body>"
+ "</html>";

public void testOk() throws Exception {
VerifyTableStep myStep = (VerifyTableStep)getStep();

getContext().saveResponseAsCurrent(getDummyPage(HTML_PAGE_WITH_TABLE));

myStep.setHtmlid("tbl");
String expected = "  |Denomination |Prix unitaire |Qté |Total |\n"
+ "|carotte      |1.00          |5   |5.00  |";
myStep.addText(expected);

executeStep(myStep);
}

@Override
protected Step createStep() {
return new VerifyTableStep();
}
}

Volontairement, nous introduisons une assertion fausse dans l'implémentation , sinon l'exécution du test va réussir alors que l'implémentation n'est pas encore effectuée.

VerifyTableStep

public class VerifyTableStep extends Step {
private String content = "";

private String htmlid;

@Override
public void doExecute() throws Exception {
Assert.assertTrue("non implémenté donc le test doit échouer", false) ;
}
}

V-C-2-c - Exécution du test

Test unitaire de la classe VerifyTableStep

V-C-2-d - Implémentation

L'implémentation consiste à parser d'un côté l'étalon et de l'autre côté le contenu du tableau HTML afin de les comparer.
Par ailleurs, nous rajouterons la possibilité d'exclure des colonnes (pratique dans notre tableau de notes où la colonne des opérations possibles sur les lignes n'est pas nécessaire à la comparaison). Enfin, nous génèrerons des messages d'erreurs explicites en cas d'échec.

Il en résulte les fichiers source suivants : VerifyTableStepTest.java, VerifyTableStep.java.

V-C-2-e - Intégration dans le projet mynotes

Une fois webtest-contrib redéployé sur le dépôt local maven, nous allons déclarer la balise VerifyTableStep dans le fichier Config.xml.

Config.xml

...
<taskdef name="verifyTable" classname="com.canoo.webtest.extension.VerifyTableStep" classpath="${runtime_classpath}"/>
...

Enfin relançons les tests en mode intégration et vérifions que le build ait réussi.

V-C-3 - Téléchargement

Les sources de ces extensions sont disponibles via ftp ou bien via http. Les librairies externes ont été regroupées dans une archive téléchargeable via ftp ou bien via http.

V-D - Conclusion

Ce chapitre fut une nouvelle fois riche en développement. Nous avons davantage exploité Mockito. Dans une démarche "agile", nous avons pris l'initiative d'effectuer des développements externes au projet afin de nous faciliter l'exécution des tâches. Grâce aux tests unitaires, nous avons confiance en notre travail et la motivation ne fléchit pas. Le chapitre suivant montrera que nous encore gagnerons en vélocité grâce aux efforts déployés jusqu'ici.

V-E - Téléchargement

Les sources du projet de ce chapitre sont disponibles via ftp ou bien via http.

VI - Remerciements

Je tiens à remercier chaleureusement ricky81, Bruno Orsier, ra77, djo.mos pour leurs conseils de rédaction et pour le temps qu'ils ont consacré à la relecture ainsi qu'à la correction orthographique. Je remercie également ridan pour la réutilisation de sa feuille de style xo_css.css dans cette application web.