MVC test framework dans un context securisé - abenhamda/demo GitHub Wiki

La phase de test est une étape obligatoire et importante dans le cycle de développement de logiciels quel que soit la méthodologie utilisée.

Spring fournit pour cette étape un ensemble de modules permettant de faciliter le travail de développeur pour la création de tests avec des interactions simples entre les services de Spring (Injection, Sécurité, Transaction...). Parmi ces modules on trouve le MockMvc qui permet de tester des services REST.

Tester un service Rest

La façon standard pour tester un service Rest consiste à déployer ce service sous un conteneur permettant de le rendre disponible pour test (serveur dédié, mode embarqué avec plugin maven ,...), puis utiliser le RestTemplate de Spring pour simuler le client.

Ce prérequis rend le test d’un service Rest plus complexe que le test d’un bean Spring standard :

  • Nécessite un tiers supplémentaire (tomcat, plugin maven,...).
  • Temps d’exécution plus lent (démarrage et arrêt du conteneur).
  • L’exécution d’un seul cas de test nécessite le déploiement de toute l’application.
Depuis la version 3.2 Spring introduit le framework de test MVC (MockMvc).

MockMvc est un module de SpringTest permettant de simplifier la création de tests Rest et retirer la dépendance explicite à un conteneur web. Une démonstration complété est disponible sous github.

Initialisation

Le MockMvc fournit un support JUnit pour tester un code Spring MVC d’une manière simple.
  • Introduit dans spring-test.jar.
  • Requête traitée via DispatcherServlet.
  • Pas besoin d’un WEB container pour tester.
On va commencer par une manipulation simple de ce module :

Afin de simplifier l’écriture de nos classes de test, on peut commencer par importer statiquement trois classes clés :

/**
*Contenant un ensemble de static method permettant de logger 
*le résultat de test (print(), log() )
**/
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*;
/**
*Contenant un ensemble de static method permettant d’accéder aux assertions de 
*différents éléments de la réponse (status(), header(), content(), cookie(),...)  
**/
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
/**
*Contenant un ensemble de static method permettant de builder une requête MVC
* (get(), post(), put(),...). 
**/
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;

Maintenant on peut configurer notre test pour que spring puisse démarrer le contexte associé :

/**
*Test runner.
**/
@RunWith(SpringJUnit4ClassRunner.class)
@WebAppConfiguration
@ContextConfiguration(classes = {RestConfigTest.class })
public final class MySimpleTest {}
Que font ces annotations:
  • @WebAppConfiguration : Demander au test runner d'instancier le WebAppConfiguration comme un bean Spring sous le nom 'webApplicationContext'. La présence de cette annotation garantit qu'un web context sera chargé pour le test en utilisant un chemin d’accès par défaut pour la racine de l'application web, ce chemin peut être modifié via l'attribut 'value'.
  • @ContextConfiguration : Une annotation de Spring Test, permettant de configurer le contexte web de l'application (DataSource, beans, Security,...) chargée pour le test. Possible d'injecter une config xml via l'attribut 'locations'. A partir de la version 3.1 Spring introduit la possibilité d'utiliser des classes de configuration (@Configuration) via l'attribut 'classes'.
Le bean utilisé pour configurer le WebApplicationContext :
/**
* Configuration de module MVC dans l'application.
**/
@Configuration
@EnableWebMvc
@ComponentScan(basePackages = Constant.PACKAGE + ".controller")
public class RestConfigTest  {
}
Il ne reste qu’à initialiser le mockMvc, cet objet représente un point d’entrée au serveur MVC et de manipulation de différentes entrées REST de l'application. Cet objet est instancié a partir du contexte web crée précédemment par annotation (@WebAppConfiguration), du coup on aura besoin de récupérer cette instance par injection Spring:
/**
*Injecter l'instance de WebApplicationContext. 
**/
@Autowired
private WebApplicationContext webApplicationContext;
/**
*Sauvegarder l'objet mockMvc.
**/
private MockMvc mockMvc;
/**
*Initialiser et builder notre mockMVC.
**/
@Before
public void setup(){
 mockMvc= MockMvcBuilders.webAppContextSetup(webApplicationContext).build();   
}
Pour plus de détail: La classe MockMvcBuilders utilisée précédemment possède deux méthodes permettant d’instancier le MockMvc:
  • Une méthode permettant de builder une instance de MockMvc liée à un web context passé en paramètre. Le DispatcherServlet utilisera ce contexte pour définir l'ensemble de contrôleurs REST de l'application.
    public static DefaultMockMvcBuilder webAppContextSetup(WebApplicationContext context);
    
  • Une méthode permettant de builder une instance de MockMvc en mode Standalone et enregistrer un ou plusieurs contrôleurs de l'application dans le web context par programmation. Cela est très utile pour tester une partie de l'application.
    public static StandaloneMockMvcBuilder standaloneSetup(Object... controllers);
    
Finalement tout est prêt pour implémenter notre test:
@Test
public void simpleTest() throws Exception {
         //Lancer une requête Rest de type GET pour l'url '/data'
  mockMvc.perform(get("/data"))
        //Assert le statut de la réponse http est égal a OK.
         .andExpect(status().isOk())                                                 
        //Assert le type de contenue de réponse. 
        .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) 
        //Assert l’existence d'une réponse json.
        .andExpect(jsonPath("$").exists())   
        //Assert l’existence d'un attribut 'java-version' dans la réponse json.                                       
        .andExpect(jsonPath("$.java-version").exists())
        //Assert le type de l'attribut 'java-version'
        .andExpect(jsonPath("$.java-version").isString())
        //Assert la valeur de l'attribut 'java-version' dans la réponse json. 
        .andExpect(jsonPath("$.java-version").value("8.24"))   
        //Afficher la réponse.                     
        .andDo(print());                                                            
    }
Pour plus de détail:
  • La méthode perform permet d'envoyer la requête (RequestBuilder ) au serveur Rest puis de retourner un objet de type ResultActions encapsulant la réponse.
  • L' objet ResultActions permet d'effectuer des assertions enchaînées via la méthode andExpect(),  effectuer des actions (loguer ,...) via la méthode andDo() et finalement retourner la réponse via la méthode andReturn()  qui renvoi un objet MVCResult .
  • L'objet MVCResult permet d’accéder directement à la réponse (contenue, statut, exceptions,...) , par contre son contenue est de type String (json, xml,..) en fonction de format de retour de service Rest, donc on peut mapper manuellement cette réponse pour obtenir un objet:
    //Mapper Jackson.
    @Autowired
    private ObjectMapper jsonMapper;
    //Récupérer résultat sous format json.
    String stringResult= mockMvc.perform(get("/data")) 
                                .andReturn()
                                .getResponse() 
                                .getContentAsString();
    //Mapper manuellement le résultat vers un objet. 
    Response objectResult= jsonMapper.readValue(stringResult, Response.class);
  • La méthode jsonPath permet d’accéder au contenue de la réponse json a l'aide d'une expression JsonPath afin d'effectuer des assertions sur ses attributs, une dépendance maven est indispensable pour utilisé cet api:
    <dependency>
        <groupId>com.jayway.jsonpath</groupId>
        <artifactId>json-path</artifactId>
        &ltversion>2.2.0</version>
    </dependency>
    Une méthode xpath est aussi disponible pour faire la même chose mais avec un contenu de la réponse en xml.

Tester une application sécurisée avec Spring Security

On va changer la configuration de l'application afin d’intégrer Spring Security. Dans notre exemple, chaque utilisateur de l'application est identifié par un token communiqué via le cookie au serveur. Un petit aperçu sur la configuration de sécurité de l'application (La config Spring Security n'est pas couvert par cet article).
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, proxyTargetClass = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private AuthenticationFilter authenticationFilter;
@Override
protected void configure(final HttpSecurity http) throws Exception {
  http.sessionManagement()
          .sessionCreationPolicy(SessionCreationPolicy.STATELESS);
  http.addFilterBefore(authenticationFilter, UsernamePasswordAuthenticationFilter.class);
  http.authorizeRequests()
         .antMatchers(Constant.ROOT_PATH, Constant.VERSION_PATH)
         .permitAll()
         .anyRequest().fullyAuthenticated();
  http.csrf().disable();
}

}

Le filtre d'authentification:
@Component
public class AuthenticationFilter extends GenericFilterBean {
    @Autowired
    private AuthenticationService authenticationService;
    public void doFilter(final ServletRequest servletRequest, 
                         final ServletResponse servletResponse,
                         final FilterChain chain) throws IOException, ServletException {
        final HttpServletRequest request = (HttpServletRequest) servletRequest;
        final HttpServletResponse response = (HttpServletResponse) servletResponse;
        final String authToken = Cookies.getValue(request);
        UsernamePasswordAuthenticationToken auth = authenticationService.authenticate(authToken);
        SecurityContextHolder.getContext().setAuthentication(auth);
        chain.doFilter(request, response);
    }
}

Maintenant on revient a la configuration de notre test afin d'introduire cette nouvelle fonctionnalité. Pour que le contexte (WebApplicationContext) de notre test le découvre on devrait intégrer la configuration de sécurité dans le contexte de test :

@RunWith(SpringJUnit4ClassRunner.class)
@WebAppConfiguration
@ContextConfiguration(classes = {RestConfigTest.class, SecurityConfig.class })
public final class MySimpleTest {}

Jusque là, le MockMvcBuilder n'est pas encore configuré pour appliquer la config de Spring Security. Pour cela une petite modification à introduire au moment de la création de MockMvc:

@Autowired
private WebApplicationContext webApplicationContext;
private MockMvc mockMvc;
@Before
public void setup(){
 mockMvc= MockMvcBuilders.webAppContextSetup(webApplicationContext)
                         //Utiliser l'object mockMvc avec un contexte sécurisé par SpringSecurity.
                         .apply(springSecurity()).build();   
}
Pour plus de détail:
  • La méthode apply() permet d'appliquer une configuration (MockMvcConfigurer) spécifique pour notre environnement de test (exemple la sécurité).
  • La méthode springSecurity() permet de configurer le MockMvcBuilder pour une utilisation avec Spring Security, elle crée un bean Spring sous le nom "springSecurityFilterChain" comme un filtre, et rajoute aussi un TestSecurityContextHolderPostProcessor afin de supporter l'annotation sur la méthode test @WithMockUser>qui permet de créer un utilisateur de test.
Lancer le test avec un utilisateur authentifié par username et password et par annotation:
@Test
@WithMockUser(username = "user", password = "pwd")
public void authTest() throws Exception {
  mockMvc.perform(get("/data"));
}
Il est possible aussi de créer l'utilisateur authentifié par programmation :
@Test
@WithMockUser(username = "user", password = "pwd")
public void simpleTest() throws Exception {
 @Test
 public void authTest() throws Exception {
   mockMvc.perform(
            get("/data")
            //Créer l'utilisateur authentifié pour la requête, possible de l'attribuer
            //des rôles aussi.   
            .with(user("user").password("pwd").roles("ROLE1","ROLE2"))
        );
    }
}
Dans notre cas, l'utilisateur de l'application est authentifié par le biais d'un token, donc le builder de la requête permet aussi de créer des cookies :
@Test
public void authTest() throws Exception {
 mockMvc.perform(
        //Créer une requête Rest de type PUT. 
        put("/data")
         //Attribuer le type de contenue de la requête.
        .contentType(MediaType.APPLICATION_JSON)
         //Attribuer le contenue de la requête sous forme json. 
        .content("{\"value\": \"My message\"}")
         //Attribuer le token d'authentification dans un Cookie.
        .cookie(new Cookie("AUTH_TOKEN", "TOKEN_SUPER_USER_VALUE")))
         //Assert la réponse.
        .andExpect(status().isOk())
        .andDo(print());
    }
Le MockHttpServletRequestBuilder fournit encore d'autres moyens pour créer et authentifier un utilisateur de test :
  • authentication(authentication): Utiliser une authentification customisée.
  • securityContext(securityContext): Utiliser un contexte de sécurité customisé.
  • httpBasic("user","password"): Utiliser une authentification http basique.
  • ....

Conclusion

Pour conclure, le framework de Spring Test MVC est un outil complet pour la mise en place de tests unitaires et tests d’intégration pour des services Rest, et c'est un bon moyen pour que le test soit indépendant de son implémentation (déjà il n'y a aucune dépendance de code source de l'application). Du coup on peut tester nos services Spring MVC sans intégrer le framework Spring MVC lui même.
⚠️ **GitHub.com Fallback** ⚠️