Test et spring MVC (15 avril 2015)

Pour les nouveaux développements du projet Ramsès, je me suis mis à tester systématiquement. Du coup, je me lance aussi dans le test de l'interface web, avec le framework ‛spring-test‛.

Voilà quelques remarques sur la chose:

Création des tests

La classe ‛MockMvcBuilders‛ permet de construire deux types de tests:

Test stand-alone

En gros, pour tester un ‛@Controller‛ de Spring MVC, on le crée à la main (avec un new), et on simule des requêtes1:

import org.junit.*;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.hamcrest.Matchers.*;

public class UserManagementControllerTest1 {

    @Test
    public void testAskForAccountGet() throws Exception {
        MockMvcBuilders.standaloneSetup(new UserManagementController())
                .build()
                .perform(MockMvcRequestBuilders.get("/user/ask").accept("text/html"))
                .andExpect(status().isOk())
                .andExpect(view().name("template"))
                .andExpect(model().attribute("content", hasProperty("main", equalTo("user/ask"))));
    }
}

Quelques remarques sur le test:

  1. On construit le MockMvc, and utilisant ‛standaloneSetup‛ et en passant le contrôleur ; l'appel à build termine la construction ;
  2. on exécute la requête (construite avec ‛MockMvcRequestBuilders‛). Notez comment j'ai précisé le mode, et le type de résultat attendu ;
  3. On peut ensuite tester le résultat.
  4. Dans le cas d'un contrôleur plus complexe, il aurait fallu l'initialiser avec en fournissant des Mock pour remplacer ses propriétés.

Dans le cas de pages rendues en JSP (j'y reviendrai) nous avons un problème: ce qu'on peut tester, c'est le nom de la vue, et les données renvoyées dans model.

Vous remarquerez que, dans mon cas, je peux même tester les propriétés d'un objet passé dans le modèle (notez :

.andExpect(model()
  .attribute("content", 
              hasProperty("main", equalTo("user/ask"))));

Dans le cas où l'API fluide ne vous convient pas (par exemple, vous voulez tester des propriétés un peu complexes des objets du domaine, vous pouvez terminer votre appel par ‛.andReturn()‛, et récupérer l'objet, de type ‛MvcResult‛, qui vous est renvoyé. Il donne accès à la vue, au modèle, etc.

Test en contexte

Ça ressemble au test "stand-alone", sauf qu'on doit mettre en place l'environnement Spring normal.

Chez moi, ça donne ça:

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = {TestConfig.class, AppConfig.class, MyWebConfig.class, })
@TestExecutionListeners({
    DependencyInjectionTestExecutionListener.class,
    TransactionalTestExecutionListener.class
})
@WebAppConfiguration
public class UserManagementControllerTest {

    @Autowired
    private WebApplicationContext webApplicationContext;

    @Test
    public void testAskForAccountGet() throws Exception {
        MockMvcBuilders.webAppContextSetup(webApplicationContext)
                .build()
                .perform(MockMvcRequestBuilders.get("/user/ask").accept("text/html"))
                .andExpect(status().isOk())
                .andExpect(view().name("template"))
                .andExpect(model().attribute("content", hasProperty("main", equalTo("user/ask"))))
                ;
    }
}

C'est beaucoup plus long à démarrer (même si la configuration que j'utilise prend une base de données de test). La logique est un peu différente : on ne crée pas un objet lié au contrôleur, mais un objet qui simule l'interface web; on lui envoie ensuite des requêtes.

Du coup, on peut simuler une série de requêtes sur des pages diverses.

Notez que les problèmes liés aux JSP restent intacts.

Le problème des JSP

Le problème des JSP, c'est qu'elles ne sont pas traitées par Spring. En gros, la ‛DispatcherServlet‛ de Spring, quand elle a déterminé le nom de la vue et injecté les données du modèle, a terminé son travail. Le couple requête/réponse est transmis à une JSP, qui est gérée par le serveur et plus par Spring.

Ce qui signifie que, quand l'affichage final est réalisé par une servlet, tout ce que Spring construit, c'est le ‛ModelAndView‛. Le reste n'est pas encore construit.

Dans les méthodes de ‛MockMvcResultMatchers‛, vous avez les méthodes ‛view‛, ‛model‛ et ‛content‛, qui vous donnent respectivement accès à la vue et au modèle au sens SpringMVC du terme (c'est à dire au nom de la vue, et aux données injectées dans celle-ci), ainsi qu'au contenu de la réponse avec ‛content‛.

Mais si votre vue est une JSP, ce contenu ne sera pas encore accessible au moment où vous testez (parce qu'il n'aura pas encore été transmis à la JSP en charge de faire le travail). Même le codage des caractères sera probablement faux.

Morale (version 1) : ne testez que ‛model()‛ et ‛view()‛, pas ‛content()‛.

Une servlet Spring qui décide de prendre les choses en main, sans passer par une JSP, n'aura pas ce problème.

Solutions alternatives au problème des JSP

Je vois trois solutions:

  1. être très content comme ça. On a bien fait le test unitaire du contrôleur, après tout.
  2. passer par un framework de test web plus classique, du type httpUnit. Ça se défend.
  3. ne pas utiliser de JSP, et remplacer par un système de templates spécialisé comme Thymeleaf ou FreeMarker.

La dernière approche est la plus "moderne", dans la mesure où les JSP sont trop puissantes pour nos besoins, si on reste dans l'optique qu'elles ne font pas de calcul, seulement de l'affichage (et si on n'est pas dans cette optique, on se demande pourquoi on se fatigue à utiliser Spring). Bref, on prend un outil plus adapté, et là, on peut tester le résultat.

D'un point de vue logique, on peut combiner les deux premières approches : on a fait le test unitaire du contrôleur, et, avec un framework plus classique, on peut faire le test unitaire des JSP: on leur envoie un modèle bidon, et on teste l'affichage obtenu. Le test contrôleur + jsp n'est pas vraiment unitaire !

On complète aussi par un test d'intégration, tant qu'à faire.


  1. (J'ai laissé les imports, parce que ça n'est pas complètement inutile de les voir, surtout avec l'usage de ‛import static‛). Tant que j'y pense, j'ai dû explicitement ajouter la bibliothèque ‛org.hamcrest.hamcrest-library‛ pour disposer des Matchers.