IdentifiantMot de passe
Loading...
Mot de passe oublié ?Je m'inscris ! (gratuit)

Tester une API REST Spring MVC avec le Spring TestContext Framework

Image non disponible

Ce tutoriel montre comment utiliser les outils fournis par le Spring TestContext Framework et jUnit pour tester une API REST Spring MVC.

Pour réagir au contenu de cet article, un espace de dialogue vous est proposé sur le forum Commentez Donner une note à l´article (5).

Article lu   fois.

Les deux auteurs

Profil ProSite personnel

Liens sociaux

Viadeo Twitter Facebook Share on Google+   

I. Introduction

Avec l'adoption croissante des architectures SPA-REST (Single Page Application - Representational State Transfer) dopée par les frameworks populaires comme AngularJS pour la partie client et Node.js pour la partie serveur, la nécessité d'assurer la robustesse de ces applications est plus forte que jamais. On observe une évolution des technologies plus anciennes telles que Java avec notamment le très populaire Spring pour s'adapter à ces nouveaux besoins. Et comme, ici, nous faisons du Java, nous allons garantir cette robustesse grâce à la mise en place de tests d'intégration. Par tests d'intégration, il faut comprendre un ensemble de tests qui vérifient le bon fonctionnement du webservice au travers de toutes les couches, puisqu'on ne mock aucun composant du système lui-même. La finalité étant de les intégrer à un système d'intégration continue via des outils comme Maven, pour garantir l'intégrité des développements.

La première étape pour garantir une bonne qualité de code est de mettre en place des tests unitaires. Ici même, sur ce blog, nous avons de très bons articles qui traitent le sujet :

Ces incontournables font très souvent partie de la DoD (Definition of Done) dans le fonctionnement agile et sont même au cœur de la méthodologie TDD (Tests Driven Development). Cependant, on peut rapidement se retrouver face à des difficultés d'implémentation (code legacy aux dépendances fortement couplées, délais tendus, etc.) et il peut alors être intéressant de les compléter avec des tests de portée plus large.

Les tests d'intégration vont nous permettre, non plus de tester une règle de gestion de manière unitaire qui pourrait correspondre à une méthode seule, mais plutôt un service en entier. Ce dernier peut être constitué de nombreuses règles de gestion et donc mécaniquement couvrir une plus grande partie du code. On pourra détecter plus facilement les impacts indirects de modifications. Par exemple, une méthode appelée dans deux services A et B peut, suite à une évolution, créer une régression dans A si le développement et les TU sont focalisés sur le service B. Les TU passeront au vert alors que les tests d'intégration peuvent tomber en erreur.

Ici nous allons utiliser les outils fournis par le Spring TestContext Framework et jUnit dont la dépendance Maven est :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
<dependency>
       <groupId>org.springframework</groupId>
       <artifactId>spring-test</artifactId>
       <version>4.2.3</version>
       <scope>test</scope>
</dependency>

Il fournit principalement les annotations @WebAppConfiguration, @ContextConfiguration et @TestExecutionListeners ainsi que les classes et interfaces AbstractTestExecutionListener, WebApplicationContext, MockMvc, MockMvcBuilders, SpringJUnit4ClassRunner.

Le code de cet article est disponible étape par étape sur le github de SOAT. Le code complet se situe sur le master.

II. Notre cas de test, un CRUD

Afin de construire nos tests d'intégration, nous allons prendre l'application suivante :

une API REST sur Json qui permet de créer, lire, supprimer ou mettre à jour des commandes constituées de liste de produits. On ajoute une gestion des commandes non trouvées ou contenant plus de produits que la limite imposée, arbitrairement à 2. Toutes les données sont enregistrées dans une base de données MongoDB. L'accès à cette BDD se fait au travers des modules spring data et spring data mongo.

L'API liée à l'URL « /order » est construite selon le modèle classique en couches avec :

  • une couche de webservice via le controller OrderWebService :
 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
40.
41.
42.
43.
44.
45.
46.
47.
48.
49.
50.
51.
52.
53.
54.
55.
56.
57.
58.
59.
60.
61.
62.
63.
64.
65.
66.
67.
68.
69.
70.
71.
72.
73.
74.
75.
76.
77.
78.
79.
80.
81.
82.
83.
84.
85.
package fr.soat.java.webservices;
 
import fr.soat.java.dto.OrderDto;
import fr.soat.java.dto.ProductDto;
import fr.soat.java.exceptions.OrderNotFoundException;
import fr.soat.java.exceptions.TooManyProductsException;
import fr.soat.java.payload.Order;
import fr.soat.java.payload.Product;
import fr.soat.java.payload.wrappers.ResponseWrapper;
import fr.soat.java.services.IOrderService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
 
@RestController
@RequestMapping(value= "/order")
public class OrderWebService {
 
    @Autowired
    private IOrderService orderService;
 
    @RequestMapping(value = "/{orderId}", method = RequestMethod.GET)
    public ResponseWrapper<Order> getOrder(
            @PathVariable String orderId) throws OrderNotFoundException{
        ResponseWrapper<Order> response = new ResponseWrapper<>();
        response.setData(fromOrderDto(orderService.getOrder(orderId)));
        return response;
    }
 
    @RequestMapping(method = RequestMethod.POST)
    public ResponseWrapper<Order> saveOrder(
            @RequestBody Order order) throws TooManyProductsException {
        ResponseWrapper<Order> response = new ResponseWrapper<>();
        response.setData(fromOrderDto(orderService.saveOrder(toOrderDto(order))));
        return response;
    }
 
    @RequestMapping(value = "/{orderId}", method = RequestMethod.PUT)
    public ResponseWrapper<Order> updateOrder(
            @PathVariable String orderId,
            @RequestBody Order order) throws TooManyProductsException {
        order.setId(orderId);
        ResponseWrapper<Order> response = new ResponseWrapper<>();
        response.setData(fromOrderDto(orderService.updateOrder(toOrderDto(order))));
        return response;
    }
 
    @RequestMapping(value = "/{orderId}", method = RequestMethod.DELETE)
    public ResponseWrapper<Order> deleteOrder(
            @PathVariable String orderId) {
        ResponseWrapper<Order> response = new ResponseWrapper<>();
        orderService.deleteOrder(orderId);
        response.setData(null);
        return response;
    }
 
    private Order fromOrderDto(OrderDto dto) {
        Order order = new Order();
        order.setCreationDate(dto.getCreationDate());
        order.setId(dto.getId());
        order.setModificationDate(dto.getModificationDate());
        dto.getProducts().forEach((productDto -> order.getProducts().add(fromProductDto(productDto))));
        return order;
    }
 
    private Product fromProductDto(ProductDto dto) {
        Product product = new Product();
        product.setName(dto.getName());
        return product;
    }
 
    private OrderDto toOrderDto(Order order) {
        OrderDto dto = new OrderDto();
        dto.setCreationDate(order.getCreationDate());
        dto.setId(order.getId());
        dto.setModificationDate(order.getModificationDate());
        order.getProducts().forEach((product -> dto.getProducts().add(toProductDto(product))));
        return dto;
    }
 
    private ProductDto toProductDto(Product product) {
        ProductDto dto = new ProductDto();
        dto.setName(product.getName());
        return dto;
    }
}
  • une couche service via la classe OrderService :
 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
40.
41.
42.
43.
44.
45.
46.
47.
48.
49.
50.
51.
52.
53.
54.
55.
56.
57.
58.
59.
60.
61.
62.
63.
64.
65.
66.
67.
68.
69.
70.
71.
72.
73.
74.
75.
76.
77.
78.
79.
80.
81.
82.
package fr.soat.java.services.impl;
 
import fr.soat.java.dao.IOrderRepository;
import fr.soat.java.dto.OrderDto;
import fr.soat.java.dto.ProductDto;
import fr.soat.java.exceptions.OrderNotFoundException;
import fr.soat.java.exceptions.TooManyProductsException;
import fr.soat.java.model.OrderEntity;
import fr.soat.java.model.ProductEntity;
import fr.soat.java.services.IOrderService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
 
import java.time.Instant;
 
@Service
public class OrderService implements IOrderService {
 
    @Autowired
    private IOrderRepository orderRepository;
 
    private static final int NB_MAX_PRODUCTS = 2;
 
    @Override
    public OrderDto saveOrder(OrderDto orderDto) throws TooManyProductsException {
        if (orderDto.getProducts().size() > NB_MAX_PRODUCTS) {
            throw new TooManyProductsException();
        }
        orderDto.setCreationDate(Instant.now().toString());
        return fromOrderEntity(orderRepository.save(toOrderEntity(orderDto)));
    }
 
    @Override
    public OrderDto updateOrder(OrderDto orderDto) throws TooManyProductsException {
        orderDto.setModificationDate(Instant.now().toString());
        return saveOrder(orderDto);
    }
 
    @Override
    public OrderDto getOrder(String orderId) throws OrderNotFoundException{
        OrderEntity found = orderRepository.findOne(orderId);
        if(found == null){
            throw new OrderNotFoundException();
        }
        return fromOrderEntity(found);
    }
 
    @Override
    public void deleteOrder(String orderId) {
        orderRepository.delete(orderId);
    }
 
    private OrderDto fromOrderEntity(OrderEntity entity) {
        OrderDto order = new OrderDto();
        order.setCreationDate(entity.getCreationDate());
        order.setId(entity.get_id());
        order.setModificationDate(entity.getModificationDate());
        entity.getProductList().forEach((productEntity -> order.getProducts().add(fromProductEntity(productEntity))));
        return order;
    }
 
    private ProductDto fromProductEntity(ProductEntity entity) {
        ProductDto product = new ProductDto();
        product.setName(entity.getName());
        return product;
    }
 
    private OrderEntity toOrderEntity(OrderDto order) {
        OrderEntity entity = new OrderEntity();
        entity.setCreationDate(order.getCreationDate());
        entity.set_id(order.getId());
        entity.setModificationDate(order.getModificationDate());
        order.getProducts().forEach((product -> entity.getProductList().add(toProductEntity(product))));
        return entity;
    }
 
    private ProductEntity toProductEntity(ProductDto product) {
        ProductEntity entity = new ProductEntity();
        entity.setName(product.getName());
        return entity;
    }
}
  • une couche dao via le repository spring data IorderRepository :
 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
package fr.soat.java.dao;
 
import fr.soat.java.model.OrderEntity;
import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.stereotype.Repository;
 
@Repository
public interface IOrderRepository extends MongoRepository<OrderEntity, String> {
}

On enverra les payloads au format suivant :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
{
    id : "orderId",
    creationDate : "20151204",
    modificationDate: "20151231",
    products: [
        {
            name: "tv"
        },
        {
            name: "phone"
        }
    ]
}

et les réponses au même format dans un wrapper :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
{
    status : "OK",
    data : {
        id : "orderId",
        creationDate : "20151204",
        modificationDate: "20151231",
        products: [
            {
                name: "tv"
            },
            {
                name: "phone"
            }
        ]
    }
}

L'article ne portant pas sur les détails d'implémentation, j'invite le lecteur à explorer les documentations associées en cas de besoin.

III. Les tests d'intégration

III-A. Notre premier test

Commençons par créer notre classe OrderWebServiceTI avec les quelques annotations de base.

On a ici le test d'intégration le plus basique possible. On retrouve les annotations classiques des TU avec jUnit @Before, @RunWith et @Test.

 
Sélectionnez
1.
2.
3.
4.
@RunWith(SpringJUnit4ClassRunner.class)
@WebAppConfiguration
@ContextConfiguration("/applicationContext.xml")
public class OrderWebServiceTest {

SpringJUnit4ClassRunner permet l'utilisation de spring-test pour l'exécution du test, @WebAppConfiguration permet de précharger une configuration propre aux applications web (ça tombe bien, on fait du Spring MVC !) et @ContextConfiguration permet d'indiquer la configuration du contexte Spring. Ici, on veut charger toutes les dépendances de l'application (dao, service, etc.).

Comme préconisé par la documentation officielle, on charge la mock servlet à partir du webcontext fourni par Spring dans le before du test :

 
Sélectionnez
1.
2.
3.
4.
@Before
public void setUp() throws Exception {
    this.mockMvc = MockMvcBuilders.webAppContextSetup(this.wac).build();
}

Et enfin le test en lui-même :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
@Test
public void testSaveOrder() throws Exception {
    String payload = "{ \"products\": [{ \"name\": \"Mon produit\" }]}";
    MockHttpServletRequestBuilder req = post(SERVICE_URI).contentType(MediaType.APPLICATION_JSON)
            .accept(MediaType.APPLICATION_JSON_UTF8)
            .content(payload);
    this.mockMvc.perform(req).andExpect(status().isOk());
}

Nous sommes dans le cas le plus simple. Il n'y a rien dans la base MongoDB. On fait donc une création de commande via le webservice POST /order. Notre requête au format json contient un produit. Elle est construite grâce à la méthode post (importée de manière statique) de la classe MockMvcRequestBuilders. Cette classe permet de construire notre requête à la demande. Une bonne partie des méthodes de cette classe renvoyant « this », on peut chaîner les méthodes directement. Ici on précise le content-type ainsi que l'accept. À la place de renseigner directement les strings adéquats, on peut utiliser la classe MediaType directement fournie par Spring. Enfin, on utilise la méthode content() pour préciser le corps de la requête (ce qui n'est bien sûr pas nécessaire pour des opérations telles que le GET, DELETE, etc.).

L'appel proprement dit se fait à l'aide de la méthode perform() de l'objet mockMvc. Cette méthode renvoie un objet sur lequel on peut chaîner les méthodes. Ici on utilise andExpect() qui permet d'effectuer directement des assertions sur la réponse. On vérifie, par exemple, que le HTTP status est 200. Cette méthode prend en paramètres des objets de type ResultMatcher. MockMvcResultMatchers permet d'avoir accès à un grand nombre d'assertions communes. Charge au développeur d'explorer toutes les possibilités.

Enfin, si on veut récupérer le corps de la réponse de manière brute, on peut le faire grâce aux méthodes andReturn() et getResponse() qui permettent de le récupérer sous forme binaire (getContentAsByteArray()) ou bien sous forme de chaîne de caractères (getContentAsString()).

Il existe un grand nombre de méthodes complémentaires pour effectuer toutes les opérations d'assertions d'un coup, mais je n'y ai jamais eu recours. Je préfère récupérer la réponse brute et effectuer les assertions dans un second temps. Cela me permet de choisir mon framework d'assertion.

Il est temps d'exécuter notre test ! D'abord, on démarre notre serveur de BDD mongo, et ensuite :

mvn clean test

On a bien nos trois tests exécutés et qui se terminent sans erreur :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
Results: Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
 
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 5.079s
[INFO] Finished at: Wed Dec 02 09:57:01 CET 2015
[INFO] Final Memory: 18M/221M
[INFO] ------------------------------------------------------------------------

Pour ceux qui aiment les logs, on peut voir qu'on a bien nos deux TU exécutés et notre TI, comme en témoignent les logs de chargement du contexte Spring. D'ailleurs, pour les plus courageux, en mode debug, on voit bien chaque étape du chargement du contexte de la même manière que si l'on déployait la webapp dans un conteneur de servlet ou serveur d'application. Le chargement du contexte prenant un peu de temps, on peut se rassurer en voyant qu'il ne se charge qu'une seule fois par build et non une fois par test ou classe de tests.

Code disponible sur la branche step1 du repository git.

III-B. Ça se complique…

Ajoutons un second test. Ou plutôt, ajoutons deux tests qui couvriront le GET de notre CRUD. D'abord, testons le cas où on ne trouve pas de commande en base. On cherche une commande qui aurait pour id « test », qui n'existe pas en base. On teste donc que le service renvoie bien un 404 (status().isNotFound()). L'objet req ne servant pas à grand-chose au-delà de la lisibilité, on imbrique directement tous les appels de méthodes (avec une indentation lisible s'il vous plait !).

Maintenant on aimerait tester que le GET remonte bien un objet existant en base. Comme on veut des tests indépendants les uns des autres, on ne peut pas se baser sur le test du POST pour faire un GET juste après et vérifier que la donnée créée par le premier test est remontée par le second. On a donc besoin de récupérer une DAO pour interagir avec la BDD. Ça tombe bien, on en a une dans le contexte Spring de l'application ! On peut donc l'ajouter en tant que membre de notre classe de test, @Autowired par Spring :

 
Sélectionnez
1.
2.
@Autowired
private IOrderRepository dao;

On se crée également un objet Order à sauvegarder dans le setUp()

 
Sélectionnez
1.
2.
3.
4.
5.
6.
@Before
public void setUp() throws Exception {
    this.mockMvc = MockMvcBuilders.webAppContextSetup(this.wac).build();
    orderDataset = new OrderEntity();
    orderDataset = dao.save(orderDataset);
}

… et la suppression de cet objet dans le tearDown() pour ne pas polluer la base.

 
Sélectionnez
1.
2.
3.
4.
@After
public void tearDown() throws Exception {
    dao.delete(orderDataset.get_id());
}

Notre test :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
@Test
public void testGetNotFoundOrder() throws Exception {
    this.mockMvc.perform(get(SERVICE_URI + "/" + "test")
            .contentType(MediaType.APPLICATION_JSON)
            .accept(MediaType.APPLICATION_JSON_UTF8_VALUE)
    ).andExpect(status().isNotFound());
}

On exécute le tout avec Maven :

Results : Tests run: 5, Failures: 0, Errors: 0, Skipped: 0

Code disponible sur la branche step2 du repository git.

III-C. … Encore un peu plus

Jusqu'à maintenant, on a eu des cas très simples qui, au-delà du code HTTP, ne vérifient pas grand-chose, si ce n'est qu'il n'y ait pas de crash des services.

Étant donné que l'API de services renvoie les ressources avec lesquelles le client est en interaction (pour ceux qui veulent le mot savant pour briller au prochain meetup, les opérations PUT et POST sont dites « isomorphiques »), on pourrait vérifier que les données renvoyées sont cohérentes en sortie de chaque service.

Pour ce faire, la documentation officielle préconise d'utiliser la librairie jsonpath. On peut donc ajouter à notre pom.xml :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
<dependency>
    <groupId>com.jayway.jsonpath</groupId>
    <artifactId>json-path</artifactId>
    <version>${jsonpath.version}</version>
    <scope>test</scope>
</dependency>

En fait, on n'est pas du tout obligé de l'utiliser. Personnellement, j'apprécie la facilité et la flexibilité d'utilisation grâce à l'intégration avec Spring. Je vais donc montrer deux façons de traiter la réponse qui exploitent cette librairie.

Mais juste avant, regardons ce qu'on peut faire avec. La principale fonctionnalité est de pouvoir récupérer une sous-partie d'un fichier json à l'aide de requêtes composées d'expressions régulières. La documentation propose une liste d'exemples dont on retiendra ici principalement les plus simples. Le principe général est le suivant :

Le symbole '$' désigne l'élément racine, toutes les requêtes commencent donc avec celui-ci. Le symbole '.' permet d'aller dans le niveau de profondeur suivant dans l'arborescence du json. Le symbole '..' permet de chercher un élément, quel que soit son emplacement dans la hiérarchie de l'élément en cours.

Un exemple avec nos commandes :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
{
    order: {
        products: [{
            name: "tv"
        },
        {
            name: "phone"
        }]
    }
}

$.order.products on récupère la liste des produits ;

$..products a le même résultat ;

$.order.products[0].name récupère “tv” ;

$.order.products[*].name récupère la liste ['tv','phone'] ;

$.order.products..name récupère la même liste.

Attention, si on ajoutait un attribut name à l'objet order ayant la valeur “nom de commande”, la requête $..name renverrait ['tv','phone','nom de commande'].

On peut également utiliser des requêtes qui possèdent des assertions du type : “je veux tous les produits dont le nom commence par un 't'”.

Ce qui a fini de me convaincre avec cette librairie, c'est également le « requêteur » web indiqué par la documentation, qui permet de tester ses requêtes sur son jeu de données en ligne.

Appliqué à nos tests, on peut directement intégrer le jsonpath en paramètre de la méthode andExpect(). On va ajouter la récupération de l'id lors du POST pour vérifier que MongoDB a bien attribué un id à notre commande et que lorsqu'on GET une ressource par son id, c'est bien celle-ci qui est remontée par le service. En voici le test :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
@Test
public void testGetOrder() throws Exception {
    this.mockMvc.perform(get(SERVICE_URI + "/" + orderDataset.get_id())
            .contentType(MediaType.APPLICATION_JSON)
            .accept(MediaType.APPLICATION_JSON_UTF8_VALUE)
    ).andExpect(status().isOk())
    .andExpect(jsonPath("$.data.id").value(orderDataset.get_id()));
}

Code disponible sur la branche step3 du repository git.

Une autre façon de faire consiste à utiliser jsonpath pour uniquement récupérer un POJO correspondant à la commande, ce qui donne :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
@Test
public void testGetOrder() throws Exception {
    String jsonResponse = this.mockMvc.perform(get(SERVICE_URI + "/" + orderDataset.get_id())
            .contentType(MediaType.APPLICATION_JSON)
            .accept(MediaType.APPLICATION_JSON_UTF8_VALUE)
    ).andExpect(status().isOk()).andReturn().getResponse().getContentAsString();
    Order responseOrder = JsonPath.parse(jsonResponse).read("$.data",Order.class);
    Assert.assertNotNull(responseOrder.getId());
    Assert.assertFalse(responseOrder.getId().isEmpty());
}

On voit qu'ici, on récupère la réponse sous forme de String, on utilise JsonPath indépendamment de Spring MVC et surtout, on fait des assertions directement sur les POJO.

Personnellement, je préfère cette solution, car en cas de réponse volumineuse ou cas de tests compliqués, lors des assertions, je pourrais réutiliser du code peut-être déjà présent dans les packages utilitaires de mon application, voire des librairies tierces qui savent manipuler des POJO. En fait, j'enlève la contrainte du json pour traiter la donnée. De plus, si je veux utiliser une autre librairie qui manipule le json comme jackson, je peux tout à fait le faire.

Code disponible sur la branche step3-2 du repository git.

III-D. Les TestExecutionListener

Nous avons donc un ensemble de tests pour nos webservices. Pour l'instant, on s'est concentré sur les tests en tant que tels, cependant, si l'on regarde la classe dans son intégralité, on observe que tous les jeux de données enregistrés dans la base de données sont exécutés dans la fonction setUp(). Or celle-ci est exécutée avant chaque méthode de test (dû au @Before jUnit). On effectue donc des opérations inutiles en base. Par exemple, pour un test de mise à jour de prospect, on crée le jeu de données correspondant autant de fois qu'il y a de tests. Ce n'est pas une approche très intuitive, ni très efficace.

Pour corriger cela, on peut envisager deux solutions. La première est de déplacer le code du setUp() correspondant aux différents cas au début de chaque méthode de test. Cela peut faire grossir les méthodes de tests en fonction de la taille des jeux de données, on perd en lisibilité, mais on cantonne toutes les variables au test, la logique d'exécution est plus simple.

La deuxième solution est de déplacer toute la création des jeux de données en base dans une méthode exécutée une seule fois avant l'ensemble des tests. L'avantage est qu'on gagne en lisibilité entre les jeux de données et les tests. L'inconvénient est que, pour faciliter certaines assertions, on peut être obligé de garder une référence vers chaque objet en base en tant qu'attribut de la classe de test.

Libre à chacun de choisir, mais nous allons continuer avec la deuxième solution, pour explorer un peu plus les possibilités de Spring test…

Tout d'abord, on pourrait avoir envie d'utiliser l'annotation @BeforeClass fournie par jUnit, mais celle-ci présente une limitation, notamment lorsqu'on travaille avec un framework qui instancie les objets pour nous, comme Spring. Comme les méthodes annotées @BeforeClass doivent être statiques, tous les objets que l'on veut référencer dans la classe et qui sont initialisés à cet endroit doivent être statiques. Conceptuellement, on ne le veut pas forcément. De plus, @Autowired ne fonctionne pas sur des attributs statiques. En fait, c'est normal, puisqu'au chargement de la classe de test, le contexte Spring n'existe pas encore. On ne peut donc pas récupérer la DAO paramétrée dans l'application. À cet instant, elle vaudrait null.

C'est maintenant que rentrent en scène les TestExecutionListener. Comme pour tous les listeners, cela permet d'exécuter du code à certains moments lors de l'exécution des tests. Avant de créer notre propre listener, ils sont là dès qu'on utilise la classe SpringJUnit4ClassRunner pour lancer les tests. Par défaut, Spring utilise les listeners suivants dans cet ordre (extrait de la documentation officielle) :

  • ServletTestExecutionListener : configures Servlet API mocks for a WebApplicationContext
  • DependencyInjectionTestExecutionListener : provides dependency injection for the test instance
  • DirtiesContextTestExecutionListener : handles the @DirtiesContext annotation
  • TransactionalTestExecutionListener : provides transactional test execution with default rollback semantics
  • SqlScriptsTestExecutionListener : executes SQL scripts configured via the @Sql annotation

Dans notre cas, les trois derniers ne sont pas utiles.

Pour accéder à notre DAO avant les tests, on va faire de notre classe de test un listener. Pour cela, deux choses à faire :

  1. Étendre la classe AbstractTestExecutionListener qui fournit les méthodes à surcharger ;
  2. Ajouter l'annotation @TestExecutionListeners pour préciser quels listeners nous intéressent. Ici la classe de test se référence elle-même et on précise qu'on l'ajoute à la liste des listeners par défaut à l'aide du flag MergeMode.MERGE_WITH_DEFAULTS.
 
Sélectionnez
1.
2.
@TestExecutionListeners(listeners = OrderWebServiceTest.class, mergeMode = TestExecutionListeners.MergeMode.MERGE_WITH_DEFAULTS)
public class OrderWebServiceTest extends AbstractTestExecutionListener {

Pour une version de Spring antérieure à 4.1, l'enum MergeMode n'existe pas. Il faut donc déclarer l'ensemble des listeners souhaités, car par défaut, la liste passée à l'annotation annule et remplace la liste des listeners par défaut.

On ajoute la surcharge de la méthode beforeTestClass() :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
@Override
public void beforeTestClass(TestContext testContext) throws Exception {
    getOrderDataset = dao.save(new OrderEntity());
    updateOrderDataset = dao.save(new OrderEntity());
    deleteOrderDataset = dao.save(new OrderEntity());
}

On exécute et… surprise ! Une bonne vielle NPE sur la référence à notre DAO dans la méthode beforeTestClass(). « Ce n'est pas normal, on est dans un contexte Spring là » me direz-vous. Sauf que si l'on regarde la classe DependencyInjectionTestExecutionListener qui active l'injection de dépendance, on se rend compte qu'elle n'implémente pas la méthode beforeTestClass(). On est donc obligé de récupérer la DAO à la main depuis le contexte Spring passé en paramètre :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
@Override
public void beforeTestClass(TestContext testContext) throws Exception {
    dao = testContext.getApplicationContext().getBean(IOrderRepository.class);
    getOrderDataset = dao.save(new OrderEntity());
    updateOrderDataset = dao.save(new OrderEntity());
    deleteOrderDataset = dao.save(new OrderEntity());
}

Maintenant nos tests s'exécutent sans encombre.

Autre détail qui peut paraître bizarre, nos objets « Dataset » sont déclarés en « static » pour pouvoir garder la référence créée dans le beforeTestClass() dans le @Test. L'explication vient du fonctionnement de jUnit. Ce dernier instancie la classe de test une fois par test, alors que la méthode beforeTestClass() est exécutée une seule fois avant l'exécution du premier test. On est donc obligé de déclarer static ces jeux de données.

Cela fait réfléchir sur l'intérêt des listeners. Ici, on ne s'en sert que pour accéder à un objet créé par le contexte Spring avant l'exécution des tests. La raison première des listeners est de factoriser du code dont on aurait besoin dans plusieurs classes de test. Une description plus poussée est disponible entre les possibilités offertes par Spring test en comparaison à jUnit dans ce post stackOverflow, le tout expliqué par l'auteur du framework Spring test.

Code disponible sur la branche step4 du repository git.

III-E. Un mot sur les transactions

Dans cet article, le cas de test communique avec une base de données MongoDB. Seulement, en MongoDB, il n'y a pas de transaction. La seule façon de les gérer est de mettre en place la méthode du «two phase commit  », qui consiste à implémenter les états des transactions à la main.

Si on avait une base de données SQL type Oracle à la place de MongoDB, on pourrait utiliser quelques annotations fournies par Spring test comme @Rollback, @Commit,@BeforeTransaction, @AfterTransaction ou encore @TransactionConfiguration (obsolète au profit des deux premières depuis Spring 4.2). L'utilisation se fait via le TransactionalTestExecutionListener et les annotations comme @Transactional, qui permettent de gérer les transactions. D'une manière générale, le sujet n'est pas propre aux tests et ne sera donc pas détaillé ici.

IV. Le mot de la fin

Spring nous offre ici un moyen avancé de tirer parti de ses outils lors de la mise en place de tests. On a l'avantage de pouvoir tester des fonctionnalités indépendamment de leur implémentation, cas de figure qui peut survenir si la dette technique est trop élevée pour entreprendre un chantier de refactoring. On peut également tester une API de webservices sans mock et sans s'encombrer d'un conteneur de servlet ou un serveur d'application. En revanche, on prend le risque d'augmenter le temps de build avec une phase de tests plus longue, puisque c'est l'application réelle qui est lancée. De plus, une bonne maîtrise du fonctionnement de jUnit et Spring est requise pour comprendre l'intrication des deux frameworks. Il faudra donc bien réfléchir aux fonctionnalités à tester par ce mode pour éviter l'effet « usine à gaz ».

Pour une mise en œuvre décollée de l'exécution des tests unitaire, il peut être intéressant d'exploiter le plugin maven « failsafe ». Il fonctionne globalement comme « surefire » et fournit les goals « integration-test » et « verify ». La description est disponible ici.

V. Conclusion

Cet article a été publié avec l'aimable autorisation de la société SoatSoat.

Nous tenons à remercier f-leb pour sa relecture orthographique et Malick SECK pour la mise au gabarit.

Vous avez aimé ce tutoriel ? Alors partagez-le en cliquant sur les boutons suivants : Viadeo Twitter Facebook Share on Google+   

Copyright © 2016 Nordwin HOFF. Aucune reproduction, même partielle, ne peut être faite de ce site ni de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.