Spring Boot Web application to provide REST API in JSON
$ git clone [email protected]:AgileSpirit/spring-boot-sample.git
$ mvn spring-boot:run
Method | Path | Description |
---|---|---|
GET | /articles | retrieve all the articles |
GET | /articles/{id} | retrieve one article by its ID |
POST | /articles | store a new article |
PUT | /articles | update an existing article |
DELETE | /articles/{id} | remove an article byt its ID |
// GET /articles
$ curl -X GET http://localhost:8080/api/articles -i -H "Content-Type: application/json"
// GET /articles/{id}
$ curl -X GET http://localhost:8080/api/articles/1 -i -H "Content-Type: application/json"
// POST /articles
$ curl -X POST http://{host}:{port}/api/articles -i
-H "Accept: application/json"
-H "Content-Type: application/json"
-d '{"title": "Hello world!", "author": "J. Doe", "publicationDate": "20/05/2015", "excerpt": "This is a simple hello world.", "content": "Lorem ipsum dolor sit amet etc."}'
// PUT /articles
$ curl -X PUT http://{host}:{port}/api/articles -i -H "Accept: application/json" -H "Content-Type: application/json"
// DELETE /articles/{id}
$ curl -X DELETE http://{host}:{port}/api/articles/{id} -i
Il est possible de voir une utilisation de l'API au travers une IHM web accessible à l'adresse : http://localhost:8080/articles.html
Spring Boot est un framework pour construire rapidement des applications Java/JEE riches (web ou standalone).
Spring Boot accélère le développement logiciel en proposant un ensemble de conventions, d'abstraction et de mécanismes prêt à l'emploi.
Concrètement, Spring Boot se présente sous la forme d'un POM parent et de dépendances -- a.k.a. des "starters" -- (Maven ou Gradle).
Dans ce tutoriel, nous allons voir comment :
- mettre en place, configurer et démarrer une application Web avec Spring Boot
- intégrer la librairie Lombok, pour éviter le code boilerplate (ex: getters / setters / equals / hashCode)
- déclarer une Entity JPA (pour la persistence) ainsi qu'un Repository associé complet (un CRUD complet !) en moins de 10 lignes (!!!)
- définir une Resource REST/JSON/HTTP (GET, POST, PUT, DELETE)
- faire des tests unitaires et d'intégration facilement avec Spring Test et MockMVC
- packager l'application à destination de serveurs J2E externes (ex: Tomcat / Jetty)
Dans le réertoire de votre choix, créez l'arborescence suivante :
spring-boot-sample (${basedir})
|
+-- src
|
+-- main
. |
. +-- java
. |
. + com.acme.app
.
|
+-- test
. |
. +-- java
. |
. + com.acme.app
Créez le fichier ./pom.xml
(pour Maven) :
<?xml version="1.0" encoding="UTF-8"?>
<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/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<name>Spring Boot Sample</name>
<groupId>com.acme.app</groupId>
<artifactId>spring-boot-sample</artifactId>
<version>1.0-SNAPSHOT</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.2.3.RELEASE</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
Créez la classe ./src/main/java/com/acme/app/Application.java
:
package com.acme.app;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class Application {
public static void main(String[] args) throws Exception {
SpringApplication.run(Application.class, args);
}
}
L'annotation @SpringBootApplication
signifie qu'il s'agit d'une application Spring Boot.
L'instruction SpringApplication.run(Application.class, args);
permet de lancer l'application via un serveur
embarqué (par défaut, Tomcat 7), directemnt depuis le main (clic-droit -> lancer l'application depuis la méthode main).
Lancez l'application :
- soit avec la ligne de commande :
$ mvn spring-boot:run
- soit depuis le main (cf. ci-dessus)
. ____ _ __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v1.2.3.RELEASE)
2015-04-13 19:56:54.272 INFO 66176 --- [lication.main()] com.acme.app.Application : Starting Application on localhost with PID 66176 (/Users/Works/SpringBootSample/spring-boot-sample/target/classes started by jbuget in /Users/Works/SpringBootSample/spring-boot-sample)
2015-04-13 19:56:54.315 INFO 66176 --- [lication.main()] ationConfigEmbeddedWebApplicationContext : Refreshing org.springframework.boot.context.embedded.AnnotationConfigEmbeddedWebApplicationContext@4485b66d: startup date [Mon Apr 13 19:56:54 CEST 2015]; root of context hierarchy
2015-04-13 19:56:54.936 INFO 66176 --- [lication.main()] o.s.b.f.s.DefaultListableBeanFactory : Overriding bean definition for bean 'beanNameViewResolver': replacing [Root bean: class [null]; scope=; abstract=false; lazyInit=false; autowireMode=3; dependencyCheck=0; autowireCandidate=true; primary=false; factoryBeanName=org.springframework.boot.autoconfigure.web.ErrorMvcAutoConfiguration$WhitelabelErrorViewConfiguration; factoryMethodName=beanNameViewResolver; initMethodName=null; destroyMethodName=(inferred); defined in class path resource [org/springframework/boot/autoconfigure/web/ErrorMvcAutoConfiguration$WhitelabelErrorViewConfiguration.class]] with [Root bean: class [null]; scope=; abstract=false; lazyInit=false; autowireMode=3; dependencyCheck=0; autowireCandidate=true; primary=false; factoryBeanName=org.springframework.boot.autoconfigure.web.WebMvcAutoConfiguration$WebMvcAutoConfigurationAdapter; factoryMethodName=beanNameViewResolver; initMethodName=null; destroyMethodName=(inferred); defined in class path resource [org/springframework/boot/autoconfigure/web/WebMvcAutoConfiguration$WebMvcAutoConfigurationAdapter.class]]
2015-04-13 19:56:55.645 INFO 66176 --- [lication.main()] s.b.c.e.t.TomcatEmbeddedServletContainer : Tomcat initialized with port(s): 8080 (http)
2015-04-13 19:56:55.838 INFO 66176 --- [lication.main()] o.apache.catalina.core.StandardService : Starting service Tomcat
2015-04-13 19:56:55.840 INFO 66176 --- [lication.main()] org.apache.catalina.core.StandardEngine : Starting Servlet Engine: Apache Tomcat/8.0.20
2015-04-13 19:56:55.929 INFO 66176 --- [ost-startStop-1] o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring embedded WebApplicationContext
2015-04-13 19:56:55.929 INFO 66176 --- [ost-startStop-1] o.s.web.context.ContextLoader : Root WebApplicationContext: initialization completed in 1618 ms
2015-04-13 19:56:56.628 INFO 66176 --- [ost-startStop-1] o.s.b.c.e.ServletRegistrationBean : Mapping servlet: 'dispatcherServlet' to [/]
2015-04-13 19:56:56.632 INFO 66176 --- [ost-startStop-1] o.s.b.c.embedded.FilterRegistrationBean : Mapping filter: 'characterEncodingFilter' to: [/*]
2015-04-13 19:56:56.632 INFO 66176 --- [ost-startStop-1] o.s.b.c.embedded.FilterRegistrationBean : Mapping filter: 'hiddenHttpMethodFilter' to: [/*]
2015-04-13 19:56:56.852 INFO 66176 --- [lication.main()] s.w.s.m.m.a.RequestMappingHandlerAdapter : Looking for @ControllerAdvice: org.springframework.boot.context.embedded.AnnotationConfigEmbeddedWebApplicationContext@4485b66d: startup date [Mon Apr 13 19:56:54 CEST 2015]; root of context hierarchy
2015-04-13 19:56:56.911 INFO 66176 --- [lication.main()] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped "{[/error],methods=[],params=[],headers=[],consumes=[],produces=[text/html],custom=[]}" onto public org.springframework.web.servlet.ModelAndView org.springframework.boot.autoconfigure.web.BasicErrorController.errorHtml(javax.servlet.http.HttpServletRequest)
2015-04-13 19:56:56.912 INFO 66176 --- [lication.main()] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped "{[/error],methods=[],params=[],headers=[],consumes=[],produces=[],custom=[]}" onto public org.springframework.http.ResponseEntity<java.util.Map<java.lang.String, java.lang.Object>> org.springframework.boot.autoconfigure.web.BasicErrorController.error(javax.servlet.http.HttpServletRequest)
2015-04-13 19:56:56.934 INFO 66176 --- [lication.main()] o.s.w.s.handler.SimpleUrlHandlerMapping : Mapped URL path [/**] onto handler of type [class org.springframework.web.servlet.resource.ResourceHttpRequestHandler]
2015-04-13 19:56:56.934 INFO 66176 --- [lication.main()] o.s.w.s.handler.SimpleUrlHandlerMapping : Mapped URL path [/webjars/**] onto handler of type [class org.springframework.web.servlet.resource.ResourceHttpRequestHandler]
2015-04-13 19:56:56.973 INFO 66176 --- [lication.main()] o.s.w.s.handler.SimpleUrlHandlerMapping : Mapped URL path [/**/favicon.ico] onto handler of type [class org.springframework.web.servlet.resource.ResourceHttpRequestHandler]
2015-04-13 19:56:57.035 INFO 66176 --- [lication.main()] o.s.j.e.a.AnnotationMBeanExporter : Registering beans for JMX exposure on startup
2015-04-13 19:56:57.090 INFO 66176 --- [lication.main()] s.b.c.e.t.TomcatEmbeddedServletContainer : Tomcat started on port(s): 8080 (http)
2015-04-13 19:56:57.092 INFO 66176 --- [lication.main()] com.acme.app.Application : Started Application in 3.064 seconds (JVM running for 6.686)
Lombok est une lib Java bien pratique qui permet de s'économiser l'écriture de code BoilerPlate tel que : getters,
setters, méthodes equals / hascode, grâce à l'utilisation d'annotations (@Data
, @Getter
, @Setter
,
@equals
, @hashCode`, etc.).
Pour activer la librairie, il faut ajouter dans le fichier Maven pom.xml :
- le repository Lombok
- la dépendance Maven
<repositories>
<repository>
<id>projectlombok.org</id>
<url>http://projectlombok.org/mavenrepo</url>
</repository>
</repositories>
<dependencies>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.16.2</version>
</dependency>
</dependencies>
Notre modèle du domaine sera simple, à savoir, un simple objet Article (en vrai une Entité JPA), avec pour propriétés :
- l'identifiant de l'entité
- le titre de l'article
- le nom de l'auteur
- la date de publication
- un résumé de l'article
- le contenu de l'article
package com.acme.app;
import lombok.Data;
import java.io.Serializable;
@Data
public class Article implements Serializable {
private Long id;
private String title;
private String author;
private String publicationDate;
private String excerpt;
private String content;
}
La classe est préfixée @Data
pour signifier à Lombok d'injecter de lui-même les méthodes boilerplate (getters /
setters / equals / hashCode) lors de la génération du bytecode Java.
Hibernate est l'ORM de référence du monde Java.
JPA est la norme Java standard couvrant la problématique de la persistence de données.
Spring Data JPA est une abstraction de JPA dans l'univers Spring. Par défaut, Spring Data JPA utilise Hibernate comme implémentation de JPA.
La première chose à faire est d'ajouter dans le pom.xml la dépendance du starter Spring Boot pour Spring Data JPA :
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
Il faut par ailleurs spécifier à Spring Boot quelle type de base de données sera utilisée. Dans notre cas, nous allons faire simple avec H2, une base SQL embarquée.
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>1.4.186</version>
<scope>runtime</scope>
</dependency>
On notera au passage l'utilisation du scope runtime
pour la dépendance H2.
A présent que nous avons accès aux fonctionnalités JPA, nous pouvons "décorer" notre classe Article pour la rendre persistante :
package com.acme.app;
import lombok.Data;
import javax.persistence.*;
import java.io.Serializable;
import java.util.Date;
@Data
@Entity
public class Article implements Serializable {
@Id
@GeneratedValue
private Long id;
private String title;
private String author;
@Temporal(TemporalType.TIMESTAMP)
private Date publicationDate;
@Lob
@Column(columnDefinition = "VARCHAR", length = 65535)
private String excerpt;
@Lob
@Column(columnDefinition = "VARCHAR", length = 65535)
private String content;
}
L'annotation @Entity
indique que la classe Article est managée par JPA. Par défaut, la stratégie adoptée par Spring / Hibernate est d'associer une table SQL pour une entité JPA.
Les annotations @Id
et GeneratedValue
définissent respectivement la clé primaire de la table Article et la stratégie de gestion/génération de l'identifiant. Par défaut, les identifiants sont gérés automatiquement par le framework et générés au format Number
de façon incrémentale (+1 à chaque nouvel Article).
L'annotation @Temporal
permet d'indiquer au SGBD que le champ publicationDate
est de type DateTime (date + time) (cf article Dealing with Date, Time and Timestamp).
Les annotations @Lob
et @Column
permettent d'associer un type SQL CLOB
aux attributs concernés.
Remarque : par défaut, Hibernate se base sur les getters pour faire le mapping Java / SQL. Dans notre cas, les getters sont générés par Lombok au moment de la génération du bytecode.
Un Repository
est l'équivalent d'un objet DAO (Data Access Object) à la sauce DDD (Domain Driven Design). Le terme fait partie du vocabulaire choisi par Spring Data pour désigner un objet dont le rôle est d'accéder et de stocker des entités JPA via une DataSource
.
L'une des fonctionnalités majeures de Spring Data JPA est de permettre de déclarer un objet Repository de CRUD complet pour une entité donnée en une seule ligne !
Pour ce faire, nous allons simplement créer l'interface ÀrticleRepository` :
package com.acme.app;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ArticleRepository extends JpaRepository<Article, Long> {
}
La première chose à remarquer est qu'on déclare une interface plutôt qu'une classe : Spring et Spring Data vont se charger eux-même de l'injection de dépendance.
On remarque aussi qu'on étend de l'interface JpaRepository
qui offre par défaut tout un tas de méthodes de CRUD telles que #findOne()
, #findAll()
, #delete()
, #save()
, etc.). Celle-ci prend en paramètres le type de l'entité concernée (ici la classe Article
) et le type de l'ID (un Long
).
A ce stade de l'application, nous avons un modèle de données et un repository (une DAO) pour l'alimenter et y accéder.
La prochaine étape consiste à créer la Resource
(aussi appelé Controller) qui va exposer nos services REST.
Le premier service REST que nous allons créer et exposer est la méthode GET /articles
qui permet de récupérer et de retourner tous les articles en base de données.
Pour cela, il faut ajouter la classe ArticleResource
:
package com.acme.app;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/articles")
public class ArticleResource {
@Autowired
private ArticleRepository articleRepository;
@RequestMapping(method = RequestMethod.GET, produces = MediaType.APPLICATION_JSON_VALUE)
@ResponseBody
public List<Article> getArticles() {
return articleRepository.findAll();
}
}
L'annotation @RestController
permet de déclarer notre classe comme étant une classe Controller (au sens Spring MVC) avec un comportement de ressource REST.
L'annotation @RequestMapping
, placé au niveau de la classe, définie le chemin de base d'accès à la ressource. Tous les services situés au sein de la classe hériteront de ce chemin.
@Autowired
permet d'injecter le Repository
créée plus tôt (cf. section §2.6.3. ci-dessus).
On retrouve l'annotation @RequestMapping
au niveau d'une métode cette fois. Elle nous permet de définir plus finement le service exposé (en l'occurence #getArticles()
). L'argument method
défini le verbe HTTP associé au service (NDLR : on aurait pu l'ommettre étant donné quel verbe par défaut proposé par Spring MVC est GET), et l'argument produces
définie le format de la donnée qui sera émise (dans notre cas, de la donnée JSON au format media "application/json").
Remarque : le fait de définir l'attribut produces va ajouter dans la réponse HTTP le header Content-Type="application/json".
Enfin l'annotation @ResponseBody va signifier à Spring MVC qu'il doit effectuer une transformation JSON de l'objet passé en retour (ici, une liste d'Articles). Si on oulie cette annotation, alors Spring MVC tentera de retourner par défaut une String (au format "text/plain").
Pour pouvoir tester (en live, et pas encore de façon automatiser) notre ressource REST, nous devons au préalable ajouter des données dans l'application.
Pour ce faire, nous allons créer une méthode d'initialisation au lancement de l'application (après que Spring ce soit occupé de la création des beans et de l'injection de dépendances).
Dans la classe Application
, il suffit de rajouter la méthode #initialize()
annotée PostConstruct
, laquelle va peupler la base de quelques articles.
package com.acme.app;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import javax.annotation.PostConstruct;
import java.util.Date;
@SpringBootApplication
public class Application {
public static void main(String[] args) throws Exception {
SpringApplication.run(Application.class, args);
}
@Autowired
private ArticleRepository articleRepository;
@PostConstruct
public void onStartup() {
articleRepository.save(newArticle("Hello world !"));
articleRepository.save(newArticle("Lorem ipsum dolor sit amet"));
articleRepository.save(newArticle("Foo Bar Power"));
}
private Article newArticle(String title) {
Article article = new Article();
article.setTitle(title);
article.setAuthor("J. Doe");
article.setPublicationDate(new Date());
article.setExcerpt("Ceci est un résumé de mon article");
article.setContent("<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</p>");
return article;
}
}
On peut maintenant lancer l'application ($ mvn spring-boot:run
) et accéder à l'URL http://localhost:8080/articles depuis notre navigateur pour voir remonter la liste des articles.
Et voilà ! Nous avons notre première ressource REST opérationnelle :-)
Nous pouvons maintenant implémenter les services restants, mais avant cela, nous allons voir comment...
Une application logicielle est un système complexe faisant intervenir et interagir plusieurs composants entre eux, chacun ayant son domaine de responsabilités et d'actions.
Il existe différentes typologies de tests : unitaires, d'intégration, fonctionnels, etc. Une bonne pratique est d'avoir un harnais de tests riche et si possible, mixant (quite à ce qu'ils se recoupent un peu) les typologies de tests.
Dans le POM Maven, il faut ajouter le start Spring Boot pour Spring Test :
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>
Nous allons aussi ajouter une autre dépendance, AssertJ, qui est une librairie Java permettant d'écrire des assertions JUnit de façon courrante (fluent).
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>2.0.0</version>
<scope>test</scope>
</dependency>
AssertJ permet par exemple d'écrire des assertions de ce type (extrait de la doc officielle) :
assertThat(frodo.getName()).isEqualTo("Frodo");
assertThat(frodo).isNotEqualTo(sauron)
.isIn(fellowshipOfTheRing);
assertThat(frodo.getName()).startsWith("Fro")
.endsWith("do")
.isEqualToIgnoringCase("frodo");
Pour notre premier test, nous voulons quelque chose de simple, avec un contexte Spring initialisé et l'injection de dépendances réalisée.
Notre premier cas de test consiste à valider que l'injection de dépendances s'effectue bien, ainsi que le traitement @PostConstruct
.
Pour cela, nous commençons par crééer la classe de test ApplicationTest :
package com.acme.app;
import org.assertj.core.api.Assertions;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.slf4j.Logger;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.SpringApplicationConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import java.util.List;
import static org.slf4j.LoggerFactory.getLogger;
@RunWith(SpringJUnit4ClassRunner.class)
@SpringApplicationConfiguration(classes = Application.class)
public class ApplicationTest {
@Autowired
private ArticleRepository articleRepository;
@Autowired
private ArticleResource articleResource;
@Test
public void testDependencyInjection() {
Assertions.assertThat(articleRepository).isNotNull();
Assertions.assertThat(articleResource).isNotNull();
}
@Test
public void testInitialize() {
List<Article> articles = articleResource.getArticles();
Assertions.assertThat(articles).isNotNull().hasSize(3);
}
}
L'annotation @RunWith(SpringJUnit4ClassRunner.class)
indique à JUnit que nos tests s'appuient et chargent un contexte Spring.
L'annotation @SpringApplicationConfiguration(classes = Application.class)
indique au runner qu'il s'agit d'une application Spring Boot, dont le point d'entrée (qui contient la configuration) est la classe Application
. Le runner va alors se baser sur cette dernière pour charger le contexte Spring.
L'annotation @Autowired
permet de récupèrer depuis le contexte Spring les beans articleRepository
et articleResource
.
L'annotation @Test
indique à JUnit que la méthode décorée est un scénario de test.
Le premier test -- #testDependencyInjection() -- vérifie que l'injection de dépendances est bien réalisée. Le second -- #testInitialize() -- vérifie que la méthode Application#initialize()
a été appelée avec succès.
Pour lancer le test, il suffit :
- d'exécuter la commande Maven
$ mvn test
- ou bien de lancer le test depuis votre IDE (clic-droit sur la classe de test -> "lancer en tant que test JUnit")
Vous devriez avoir la stack trace suivante :
. ____ _ __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v1.2.3.RELEASE)
2015-05-04 19:40:26.087 INFO 8336 --- [ main] c.i.rt.execution.junit.JUnitStarter : Starting JUnitStarter on localhost with PID 8336 (started by OCTO-JBU in /Users/OCTO-JBU/Works/AgileSpirit/SpringBootSample/spring-boot-sample)
2015-05-04 19:40:26.389 INFO 8336 --- [ main] s.c.a.AnnotationConfigApplicationContext : Refreshing org.springframework.context.annotation.AnnotationConfigApplicationContext@27ea3262: startup date [Mon May 04 19:40:26 CEST 2015]; root of context hierarchy
2015-05-04 19:40:28.591 INFO 8336 --- [ main] j.LocalContainerEntityManagerFactoryBean : Building JPA container EntityManagerFactory for persistence unit 'default'
2015-05-04 19:40:30.450 INFO 8336 --- [ main] c.i.rt.execution.junit.JUnitStarter : Started JUnitStarter in 4.733 seconds (JVM running for 5.416)
2015-05-04 19:40:30.792 INFO 8336 --- [ Thread-1] s.c.a.AnnotationConfigApplicationContext : Closing org.springframework.context.annotation.AnnotationConfigApplicationContext@27ea3262: startup date [Mon May 04 19:40:26 CEST 2015]; root of context hierarchy
2015-05-04 19:40:30.796 INFO 8336 --- [ Thread-1] j.LocalContainerEntityManagerFactoryBean : Closing JPA EntityManagerFactory for persistence unit 'default'
Process finished with exit code 0
Une ressource REST (i.e. un Controller
Spring MVC) est normalement un point d'entrée du système, lequel dépend d'autres composants (Beans Spring).
Spring propose (via le module Spring Test) des outils pour facilement tester des controllers, a.k.a. MockMVC.
Par exemple, pour tester notre API "GET /articles/" de façon unitaire, sans avoir à lancer toute l'application, il suffit de déclarer la classe MockMvcUnitTest
:
package com.acme.app;
import org.junit.Before;
import org.junit.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.MockitoAnnotations;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import java.util.ArrayList;
import java.util.List;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
public class MockMvcUnitTest {
@Mock
private ArticleRepository articleRepository;
@InjectMocks
private ArticleResource articleResource;
private MockMvc mockMvc;
@Before
public void setUp() throws Exception {
// Préparation de génération des Mock Objects
MockitoAnnotations.initMocks(this);
// Gestion de l'injection de dépendances
mockMvc = MockMvcBuilders.standaloneSetup(articleResource).build();
// Simulation des comportements pour les composants bouchonnés
List<Article> articles = new ArrayList<>();
articles.add(ArticleFactory.newArticle("Article 1"));
articles.add(ArticleFactory.newArticle("Article 2"));
articles.add(ArticleFactory.newArticle("Article 3"));
Mockito.when(articleRepository.findAll()).thenReturn(articles);
}
@Test
public void testGetArticles() throws Exception {
mockMvc.perform(get("/articles"))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andReturn().getResponse().getContentAsString();
}
}
On remarque qu'il n'y a pas besoin de décorer la classe avec une quelconque annotation. On se situe vraiment dans un test unitaire : on veut vérifier le comportement d'un composant, isolé de toutes ses dépendances (via l'annotation @InjectMocks
), sur lesquelles on prend la main, via des Mock Objects (@Mock
).
A noter : MockMVC se base sur Mockito pour générer et prendre la main sur les Mock Objects.
Nous allons maintenant faire des tests d'intégration un peu plus haut niveau. Nous allons à nouveau tester notre ressource REST, mais dans des conditions proche du Runtime. Autrement dit, nous n'allons plus mocker les composants / dépendances Spring. Pour ce faire, nous allons quand même nous appuyer sur MockMVC qui propose là encore des fonctionnalités adaptées.
Nous allons ainsi crééer la classe MockMvcIntegrationTest
:
package com.acme.app;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.SpringApplicationConfiguration;
import org.springframework.http.MediaType;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.context.web.WebAppConfiguration;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@RunWith(SpringJUnit4ClassRunner.class)
@SpringApplicationConfiguration(classes = Application.class)
@WebAppConfiguration
public class MockMvcIntegrationTest {
@Autowired
private WebApplicationContext context;
private MockMvc mockMvc;
@Before
public void setUp() throws Exception {
mockMvc = MockMvcBuilders.webAppContextSetup(context).build();
}
@Test
public void testGetArticles() throws Exception {
mockMvc.perform(get("/articles"))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andReturn().getResponse().getContentAsString();
}
}
Par rapport au tests précédent, nous devons à nouveau décorer notre classe de test pour indiquer à JUnit que nous allons nous situer dans un contexte Spring.
Par rapport à notre premier test, nous devons ajouter l'annotation @WebAppConfiguration
qui permet à MockMVC de (faire le lien et d'utiliser le contexte Spring).
The context loader guesses whether you want to test a web application or not (e.g. with MockMVC) by looking for the @WebIntegrationTest or @WebAppConfiguration annotations. (MockMVC and @WebAppConfiguration are part of spring-test).
On notera par ailleurs, que cette fois nous configurons MockMvcBuilders
sur la base du contexte Spring et plus depuis un bean Spring.
REST-assured est une bibliothèque Java proposant un "fluent DSL" basé sur la syntaxe Gherkin -- given(), when(), then() et and() -- spécialement pensé pour tester des API REST.
Il est possible d'utiliser REST-assured avec Spring MVC pour effectuer (et non pas simuler) des appels REST dans les conditions identiques au Runtime, c'est-à-dire avec l'application finale qui tourne et est accessible.
Pour ce faire, nous allons utiliser l'annotation @WebIntegrationTest
fournie par Spring test et qui permet de lancer l'application comme en live.
Mais avant, nous devons déclarer la dépendance Maven REST-assured dans le fichier POM du projet :
<dependency>
<groupId>com.jayway.restassured</groupId>
<artifactId>rest-assured</artifactId>
<version>2.4.0</version>
<scope>test</scope>
</dependency>
Nous allons ensuite créée notre classe de test, ``RestAssuredIntegrationTest``` :
package com.acme.app;
import com.jayway.restassured.http.ContentType;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.SpringApplicationConfiguration;
import org.springframework.boot.test.WebIntegrationTest;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import static com.jayway.restassured.RestAssured.when;
import static org.hamcrest.CoreMatchers.containsString;
@RunWith(SpringJUnit4ClassRunner.class)
@SpringApplicationConfiguration(classes = Application.class)
@WebIntegrationTest("server.port:0")
public class RestAssuredIntegrationTest {
private final static Logger LOGGER = LoggerFactory.getLogger(RestAssuredIntegrationTest.class);
@Value("${local.server.port}")
private int port;
@Test
public void testApplication() {
LOGGER.info("L'application tourne sur le port : " + port);
}
@Test
public void testGetArticles() {
when()
.get("http://localhost:" + port + "/articles").
then()
.body(containsString("Hello world !"))
.body(containsString("Lorem ipsum dolor sit amet consectetur adipiscing"))
.body(containsString("Foo Bar Power"))
.assertThat()
.statusCode(200)
.contentType(ContentType.JSON).
extract()
.response()
.asString();
}
}
L'annotation @WebIntegrationTest
permet de lancer l'application comme en live via un conteneur de servlet embarqué (par défaut Tomcat).
Il est possible de paramétrer le serveur, par exemple avec la propriété server.port
. En spécifiant la valeur à "0", nous laissons Spring définir un port aléatoire (par défaut c'est 8080), qu'il est ensuite possible de récupérer via l'attribut annoté de @Value("${local.server.port}")
.
Nous pouvons alors faire un appel en précisant le nom de l'hôte ainsi que le port et l'URI de la ressource testée.
REST-assured et MockMVC peuvent être utilisés de façon complémentaire, par exemple pour tester une API en bénéficiant de la syntaxe Gherkin et des assertions proposées par REST-assured, tout en bouchonnant les dépendances Spring de l'API testée (et donc en n'ayant plus à charger et lancer toute une application Spring Boot).
Par exemple, nous allons créer la classe RestAssuredMockMvcTest
suivante :
package com.acme.app;
import com.jayway.restassured.http.ContentType;
import com.jayway.restassured.module.mockmvc.RestAssuredMockMvc;
import org.junit.Before;
import org.junit.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.MockitoAnnotations;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.List;
import static com.jayway.restassured.module.mockmvc.RestAssuredMockMvc.when;
import static org.hamcrest.CoreMatchers.containsString;
public class RestAssuredMockMvcTest {
@Mock
private ArticleRepository articleRepository;
@InjectMocks
private ArticleResource articleResource;
@Before
public void setUp() throws Exception {
MockitoAnnotations.initMocks(this);
RestAssuredMockMvc.standaloneSetup(articleResource);
List<Article> articles = new ArrayList<>();
articles.add(ArticleFactory.newArticle("Article 1"));
articles.add(ArticleFactory.newArticle("Article 2"));
articles.add(ArticleFactory.newArticle("Article 3"));
Mockito.when(articleRepository.findAll()).thenReturn(articles);
}
@Test
public void testGetArticles() throws Exception {
when()
.get("/articles").
then()
.body(containsString("Article 1"))
.body(containsString("Article 2"))
.body(containsString("Article 3"))
.assertThat()
.statusCode(200)
.contentType(ContentType.JSON).
extract()
.response()
.getMockHttpServletResponse()
.getContentAsString();
}
}
Nous voyons à travers ce test que nous gérons les dépendances "normalement" avec Mockito / MockMVC et que nous appelons directement REST-assured pour ce qui est de l'appel de la méthode testée et des assrtions.