Nous allons aborder dans ce billet la problématique des tests unitaires pour une application WEB
JPA en générale, et une application WEB
JAX-RS utilisant Jersey en particulier. L'application WEB à tester est une application Maven et les tests seront intégrés à la
phase de tests du cycle de vie Maven.
L'objectif est de parvenir à automatiser le déploiement de l'application en s'appuyant sur une base
HSQLDB in memory et un fichier de configuration
persistence.xml spécifiques à la phase de tests. Il sera alors possible de construire une campagne de tests unitaires via l'utilisation de la librairie
Jersey Client API.
Application à tester
Nous utiliserons
Hibernate 3.6,
MySQL et
Jersey 1.4 :
org.hibernate
hibernate-entitymanager
3.6.0.Final
mysql
mysql-connector-java
5.1.6
com.sun.jersey
jersey-bundle
1.4
javax.servlet
servlet-api
2.5
provided
L'application à tester consiste en un datastore
REST permettant de stocker des couples (clé, valeur) en s'appuyant sur l'entitée
JPA suivante :
package com.googlecode.avianey.jersey;
import javax.persistence.Entity;
import javax.persistence.Id;
/**
* Simple bean for storing (key, value) pairs
*/
@Entity
public class JerseyJPABean {
@Id
private String key;
private String value;
public String getKey() {
return key;
}
public void setKey(String key) {
this.key = key;
}
public String getValue() {
return value;
}
public void setValue(String value) {
this.value = value;
}
}
La persistance des données est réalisée par une base de données relationnelle
MySQL. Le fichier
persistence.xml de paramétrage
JPA de "production" est le suivant :
<persistence
version="2.0"
xmlns="http://java.sun.com/xml/ns/persistence"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="
http://java.sun.com/xml/ns/persistence
http://java.sun.com/xml/ns/persistence/persistence_2_0.xsd">
<persistence-unit name="pu" transaction-type="RESOURCE_LOCAL">
<provider>org.hibernate.ejb.HibernatePersistence</provider>
<class>com.googlecode.avianey.jersey.JerseyJPABean</class>
<exclude-unlisted-classes>true</exclude-unlisted-classes>
<properties>
<property name="hibernate.connection.driver_class" value="com.mysql.jdbc.Driver" />
<property name="hibernate.dialect" value="org.hibernate.dialect.MySQLDialect" />
<property name="hibernate.connection.url" value="jdbc:mysql://localhost:3306/prod" />
<property name="hibernate.connection.username" value="mysql" />
<property name="hibernate.connection.password" value="mysql" />
<property name="hibernate.hbm2ddl.auto" value="update" />
<property name="hibernate.show_sql" value="false" />
</properties>
</persistence-unit>
</persistence>
Les services
REST sont exposés par
Jersey à l'adresse "/" :
<?xml version="1.0" encoding="UTF-8"?>
<web-app
version="2.5"
xmlns="http://java.sun.com/xml/ns/javaee"
xmlns:web="http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://java.sun.com/xml/ns/javaee
http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd">
<!-- SERVICES REST -->
<servlet>
<servlet-name>Jersey</servlet-name>
<servlet-class>
com.sun.jersey.spi.container.servlet.ServletContainer
</servlet-class>
<init-param>
<param-name>com.sun.jersey.config.property.packages</param-name>
<param-value>com.googlecode.avianey.jersey</param-value>
</init-param>
<load-on-startup>1</load-on-startup>
</servlet>
<servlet-mapping>
<servlet-name>Jersey</servlet-name>
<url-pattern>/*</url-pattern>
</servlet-mapping>
<session-config>
<session-timeout>30</session-timeout>
</session-config>
</web-app>
L'implémentation des services est un CRUD permettant de :
- Stocker une valeur (PUT) :
- Stocke une valeur à l'adresse correspondant à l'URL de la méthode HTTP PUT utilisée
- Mettre à jour une valeur (POST) :
- Met à jour la valeur stockée à l'adresse correspondant à l'URL de la méthode HTTP POST utilisée
- Récupérer une valeur (GET) :
- Récupère la valeur stockée à l'adresse correspondant à l'URL de la méthode HTTP GET utilisée
- Supprimer une valeur (DELETE) :
- Supprime la valeur à l'adresse correspondant à l'URL de la méthode HTTP DELETE utilisée
Les
codes retours HTTP suivants sont utilisées afin de gérer les différentes erreurs et conflits rencontrés lors du traitement d'une requête :
- 200
- Le traitement de la requête s'est correctement déroulé
- 400
- La requête est refusée par le serveur : tentative de stockage d'une valeur indéfinie.
- 404
- La donnée n'existe pas : pas de valeur stockée pour la clé demandée.
- 409
- Conflit de données : création d'une valeur pour une clé déjà existante.
- 500
- Erreur technique du serveur.
package com.googlecode.avianey.jersey;
import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityNotFoundException;
import javax.persistence.Persistence;
import javax.persistence.PersistenceException;
import javax.persistence.Query;
import javax.ws.rs.Consumes;
import javax.ws.rs.DELETE;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.PUT;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.WebApplicationException;
@Path("/")
@Produces("application/json")
@Consumes("application/json")
public class JerseyJPAService {
private static final EntityManagerFactory EMF =
Persistence.createEntityManagerFactory("pu");
/**
* Add a new element
* @param value
*/
@PUT
@Path("/{key: \\w+}")
public void put(@PathParam("key") String key, String value) {
if (value == null) {
// HTTP 400 : Bad Request
throw new WebApplicationException(400);
}
JerseyJPABean bean = new JerseyJPABean();
bean.setKey(key);
bean.setValue(value);
EntityManager em = EMF.createEntityManager();
try {
em.getTransaction().begin();
em.persist(bean);
em.getTransaction().commit();
} catch (PersistenceException e) {
if (get(key) != null) {
// HTTP 409 : Conflict
throw new WebApplicationException(409);
} else {
// HTTP 500 : Internal Server Error
throw new WebApplicationException(500);
}
} finally {
if (em.getTransaction().isActive()) {
try {
em.getTransaction().rollback();
} catch (Exception e) {}
}
em.close();
}
}
/**
* Update an existing element
* @param bean
*/
@POST
@Path("/{key: \\w+}")
public void update(@PathParam("key") String key, String value) {
if (value == null || value.trim().length() == 0) {
// Delete existing stored value
delete(key);
} else {
EntityManager em = EMF.createEntityManager();
try {
em.getTransaction().begin();
JerseyJPABean bean = (JerseyJPABean) em.getReference(JerseyJPABean.class, key);
bean.setValue(value);
em.merge(bean);
em.getTransaction().commit();
} catch (EntityNotFoundException e) {
// HTTP 404 : Not Found
throw new WebApplicationException(404);
} catch (PersistenceException e) {
// HTTP 500 : Internal Server Error
throw new WebApplicationException(500);
} finally {
if (em.getTransaction().isActive()) {
try {
em.getTransaction().rollback();
} catch (Exception e) {}
}
em.close();
}
}
}
/**
* Retrieve a persisted element
* @param key
* @return
*/
@GET
@Path("/{key: \\w+}")
public String get(@PathParam("key") String key) {
EntityManager em = EMF.createEntityManager();
try {
JerseyJPABean bean = (JerseyJPABean) em.getReference(JerseyJPABean.class, key);
return bean.getValue();
} catch (EntityNotFoundException e) {
// HTTP 404 : Not Found
throw new WebApplicationException(404);
} finally {
em.close();
}
}
/**
* Delete a persisted element
* @param key
*/
@DELETE
@Path("/{key: \\w+}")
public void delete(@PathParam("key") String key) {
EntityManager em = EMF.createEntityManager();
try {
em.getTransaction().begin();
Query q = em.createQuery("DELETE JerseyJPABean WHERE key = :key");
q.setParameter("key", key);
if (q.executeUpdate() == 0) {
// HTTP 404 : Not Found
throw new WebApplicationException(404);
}
em.getTransaction().commit();
} catch (Exception e) {
// HTTP 500 : Internal Server Error
throw new WebApplicationException(500);
} finally {
if (em.getTransaction().isActive()) {
try {
em.getTransaction().rollback();
} catch (Exception e) {}
}
em.close();
}
}
}
Configuration des tests
L'une des problématique principale concernant les tests unitaires d'une application WEB persistant ses données en base est de pouvoir disposer d'une base de données réservée aux tests qui permette d'accueillir des jeux de test divers et variés. Dans un contexte
JPA, il convient de créer un fichier de configuration
persistence.xml spécifique, stocké dans le répertoire
src/test/resources du projet, et utilisant une base
HSQLDB in memory :
<persistence
version="2.0"
xmlns="http://java.sun.com/xml/ns/persistence"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="
http://java.sun.com/xml/ns/persistence
http://java.sun.com/xml/ns/persistence/persistence_2_0.xsd">
<persistence-unit name="pu" transaction-type="RESOURCE_LOCAL">
<provider>org.hibernate.ejb.HibernatePersistence</provider>
<class>com.googlecode.avianey.jersey.JerseyJPABean</class>
<exclude-unlisted-classes>true</exclude-unlisted-classes>
<properties>
<property name="hibernate.connection.driver_class" value="org.hsqldb.jdbcDriver" />
<property name="hibernate.dialect" value="org.hibernate.dialect.HSQLDialect" />
<property name="hibernate.connection.url" value="jdbc:hsqldb:mem:test" />
<property name="hibernate.connection.username" value="sa" />
<property name="hibernate.connection.password" value="" />
<property name="hibernate.hbm2ddl.auto" value="update" />
<property name="hibernate.show_sql" value="true" />
</properties>
</persistence-unit>
</persistence>
L'arborescence du projet Maven est la suivante :
|
Arborescence du projet Maven de test |
Les librairies supplémentaires à ajouter au CLASSPATH de test contiennent notamment :
- JUnit : pour monter les jeux de test et définir pour chaque test le résultat attendu
- HSQLDB : pour disposer d'une base de données de test in memory
- Jetty : pour déployer les sources à tester dans un serveur embarqué
org.hibernate
hibernate-entitymanager
3.6.0.Final
mysql
mysql-connector-java
5.1.6
com.sun.jersey
jersey-bundle
1.4
javax.servlet
servlet-api
2.5
provided
org.hsqldb
hsqldb
2.0.0
test
junit
junit
4.8.2
jar
test
com.sun.jersey.jersey-test-framework
jersey-test-framework-core
1.4
jar
test
org.mortbay.jetty
jetty
6.1.17
jar
test
servlet-api
org.mortbay.jetty
Lors de l'exécution de la phase de test par Maven, le CLASSPATH utilisé contient les librairies applicatives ainsi que les classes JAVA du répertoire
src/main auquelles sont ajoutées les librairies de test (celles dont les scope est <test>) et les classes JAVA du répertoire
src/test. Le CLASSPATH contient donc deux fichiers différents nommés tous deux
META-INF/persistence.xml :
- src/main/resources/META-INF/persistence.xml
- Fichier de configuration JPA utilisé pour le packaging final
- src/main/resources/META-INF/persistence.xml
- Fichier de configuration JPA utilisé pour les tests unitaires
Pour s'assurer de l'utilisation du fichier de configuration de test lors de l'exécution de la phase de tests par Maven, il faut utiliser un
ClassLoader personnalisé dont l'effet est de ne mettre en visibilité que le fichier M
ETA-INF/persistence.xml du répertoire
src/test/resources :
public static class ClassLoaderProxy extends ClassLoader {
public ClassLoaderProxy(final ClassLoader parent) {
super(parent);
}
@Override
public Enumeration<url> getResources(final String name) throws IOException {
if (!"META-INF/persistence.xml".equals(name)) {
return super.getResources(name);
} else {
System.out.println("Redirecting to specific persistence.xml");
return new Enumeration<url>() {
private URL persistenceUrl = (new File(
"src/test/resources/META-INF/persistence.xml"))
.toURI().toURL();
@Override
public boolean hasMoreElements() {
return persistenceUrl != null;
}
@Override
public URL nextElement() {
final URL url = persistenceUrl;
System.out.println("Using custom persistence.xml : " +
url.toString());
persistenceUrl = null;
return url;
}
};
}
}
}
Lors de la mise en place des tests dans la méthode
setUp() du
TestCase JUnit, la base
HSQLDB in memory est démarrée à l'adresse indiquée dans le fichier persistence.xml de test. Une base vierge de toute donnée est ainsi disponible pour y déployer des jeux de tests et données spécifiques. Un serveur Jetty embedded est quant à lui lancé sur le port
8888 et configuré pour exposer notre application WEB sur le context "/" en utilisant le
ClassLoader personnalisé définit précédemment :
@Override
protected void setUp() throws Exception {
super.setUp();
// Start HSQLDB
try {
Class.forName("org.hsqldb.jdbcDriver");
con = DriverManager.getConnection(
"jdbc:hsqldb:mem:test",
"sa", "");
} catch (Exception e) {
e.printStackTrace();
fail("Failed to start HSQLDB");
}
// Start Jetty
try {
System.setProperty("org.mortbay.log.class",
"com.citizenmind.service.rest.TestRestService.CustomLogger");
server = new Server(8888);
WebAppContext context = new WebAppContext();
context.setResourceBase("src/main/webapp");
context.setContextPath("/");
Thread.currentThread().setContextClassLoader(
new ClassLoaderProxy(
Thread.currentThread().getContextClassLoader()));
context.setClassLoader(Thread.currentThread().getContextClassLoader());
context.setParentLoaderPriority(true);
server.setHandler(context);
server.start();
} catch (Exception e) {
e.printStackTrace();
server.stop();
fail("Failed to start embedded Jetty");
}
}
Lors de l'arrêt des tests dans la méthode
tearDown() du
TestCase JUnit, la base de données
HSQLDB ainsi que le serveur
Jetty embedded sont arrêtés :
@Override
protected void tearDown() throws Exception {
super.tearDown();
// Stop HSQLDB
try {
con.createStatement().execute("SHUTDOWN");
} catch (Exception e) {
e.printStackTrace();
}
// Stop jetty
try {
server.stop();
} catch (Exception e) {
e.printStackTrace();
System.err.println("Jetty must be killed manually");
}
}
La librairie Jersey Client API est utilisée pour dérouler les tests en interrogeant les services REST exposés par le serveur Jetty embedded :
/**
* Test REST Services using Jersey client API
*/
public void testJerseyJPAService() {
// Create a Jersey client
Client c = Client.create();
WebResource r;
// Insert two values :
// - (A, 1)
// - (B, 2)
try {
r = c.resource(URL + "/A");
r.type(MediaType.APPLICATION_JSON_TYPE)
.accept(MediaType.APPLICATION_JSON_TYPE)
.put("1");
r = c.resource(URL + "/B");
r.type(MediaType.APPLICATION_JSON_TYPE)
.accept(MediaType.APPLICATION_JSON_TYPE)
.put("2");
} catch (UniformInterfaceException e) {
e.printStackTrace();
fail("Could not insert values");
}
// Store a value with an existing key
try {
r = c.resource(URL + "/B");
r.type(MediaType.APPLICATION_JSON_TYPE)
.accept(MediaType.APPLICATION_JSON_TYPE)
.put("2");
} catch (UniformInterfaceException e) {
// HTTP 409 : Conflict
assertTrue(e.getResponse()
.getClientResponseStatus()
.getStatusCode() == 409);
}
// Verify values
r = c.resource(URL + "/A");
String A = r.type(MediaType.APPLICATION_JSON_TYPE)
.accept(MediaType.APPLICATION_JSON_TYPE)
.get(String.class);
assertTrue("1".equals(A));
r = c.resource(URL + "/B");
String B = r.type(MediaType.APPLICATION_JSON_TYPE)
.accept(MediaType.APPLICATION_JSON_TYPE)
.get(String.class);
assertTrue("2".equals(B));
// Update B
r = c.resource(URL + "/B");
r.type(MediaType.APPLICATION_JSON_TYPE)
.accept(MediaType.APPLICATION_JSON_TYPE)
.post("3");
B = r.type(MediaType.APPLICATION_JSON_TYPE)
.accept(MediaType.APPLICATION_JSON_TYPE)
.get(String.class);
assertTrue("3".equals(B));
// Update C
try {
r = c.resource(URL + "/C");
r.type(MediaType.APPLICATION_JSON_TYPE)
.accept(MediaType.APPLICATION_JSON_TYPE)
.post("3");
} catch (UniformInterfaceException e) {
// HTTP 404 : Resource C does not exists
assertTrue(e.getResponse()
.getClientResponseStatus()
.getStatusCode() == 404);
}
// Delete A
r = c.resource(URL + "/A");
r.type(MediaType.APPLICATION_JSON_TYPE)
.accept(MediaType.APPLICATION_JSON_TYPE)
.delete();
try {
Statement st = con.createStatement();
ResultSet rs = st.executeQuery(
"Select * From JerseyJPABean Where key = 'A'");
assertTrue(!rs.next());
rs.close();
st.close();
} catch (SQLException e) {
e.printStackTrace();
fail("Could not execute SQL Select");
}
// Get A
try {
A = r.type(MediaType.APPLICATION_JSON_TYPE)
.accept(MediaType.APPLICATION_JSON_TYPE)
.get(String.class);
} catch (UniformInterfaceException e) {
// HTTP 404 : Resource A does not exists enymore
assertTrue(e.getResponse()
.getClientResponseStatus()
.getStatusCode() == 404);
}
}
Résultat final
Le projet complet Maven utilisé pour illustrer ce billet est disponible sous licence
Apache License 2.0 à cette adresse :
Un petit test :
mvn test
Et l'on remarque le déploiement des services sous Jetty, la redirection du ClassLoader personnalisé ainsi que le détail des requêtes Hibernate :
-------------------------------------------------------
T E S T S
-------------------------------------------------------
Running com.googlecode.avianey.jersey.JerseyJPATest
2011-03-20 17:10:18.427::INFO: Logging to STDERR via org.mortbay.log.StdErrLog
2011-03-20 17:10:18.463::INFO: jetty-6.1.17
2011-03-20 17:10:18.531::INFO: NO JSP Support for /, did not find org.apache.jasper.servlet.JspServlet
20 mars 2011 17:10:18 com.sun.jersey.api.core.PackagesResourceConfig init
INFO: Scanning for root resource and provider classes in the packages:
com.googlecode.avianey.jersey
20 mars 2011 17:10:18 com.sun.jersey.api.core.ScanningResourceConfig logClasses
INFO: Root resource classes found:
class com.googlecode.avianey.jersey.JerseyJPAService
20 mars 2011 17:10:18 com.sun.jersey.api.core.ScanningResourceConfig init
INFO: No provider classes found.
20 mars 2011 17:10:18 com.sun.jersey.server.impl.application.WebApplicationImpl _initiate
INFO: Initiating Jersey application, version 'Jersey: 1.4 09/11/2010 10:41 PM'
2011-03-20 17:10:19.323::INFO: Started SocketConnector@0.0.0.0:8888
SLF4J: Failed to load class "org.slf4j.impl.StaticLoggerBinder".
SLF4J: Defaulting to no-operation (NOP) logger implementation
SLF4J: See http://www.slf4j.org/codes.html#StaticLoggerBinder for further details.
Redirecting to specific persistence.xml
Using custom persistence.xml : file:/C:/projets/jersey-jpa-test/src/test/resources/META-INF/persistence.xml
Hibernate: insert into JerseyJPABean (value, key) values (?, ?)
Hibernate: insert into JerseyJPABean (value, key) values (?, ?)
Hibernate: insert into JerseyJPABean (value, key) values (?, ?)
Hibernate: select jerseyjpab0_.key as key0_0_, jerseyjpab0_.value as value0_0_ from JerseyJPABean jerseyjpab0_ where jerseyjpab0_.key=?
Hibernate: select jerseyjpab0_.key as key0_0_, jerseyjpab0_.value as value0_0_ from JerseyJPABean jerseyjpab0_ where jerseyjpab0_.key=?
Hibernate: select jerseyjpab0_.key as key0_0_, jerseyjpab0_.value as value0_0_ from JerseyJPABean jerseyjpab0_ where jerseyjpab0_.key=?
Hibernate: select jerseyjpab0_.key as key0_0_, jerseyjpab0_.value as value0_0_ from JerseyJPABean jerseyjpab0_ where jerseyjpab0_.key=?
Hibernate: update JerseyJPABean set value=? where key=?
Hibernate: select jerseyjpab0_.key as key0_0_, jerseyjpab0_.value as value0_0_ from JerseyJPABean jerseyjpab0_ where jerseyjpab0_.key=?
Hibernate: select jerseyjpab0_.key as key0_0_, jerseyjpab0_.value as value0_0_ from JerseyJPABean jerseyjpab0_ where jerseyjpab0_.key=?
Hibernate: delete from JerseyJPABean where key=?
Hibernate: select jerseyjpab0_.key as key0_0_, jerseyjpab0_.value as value0_0_ from JerseyJPABean jerseyjpab0_ where jerseyjpab0_.key=?
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.343 sec