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 :L'application à tester consiste en un datastore REST permettant de stocker des couples (clé, valeur) en s'appuyant sur l'entitée JPA suivante :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
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
- 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é
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 :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
- 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
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