dimanche 20 mars 2011

Tests unitaires d'un application Jersey JAX-RS + JPA

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 META-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

Aucun commentaire:

Enregistrer un commentaire

Fork me on GitHub