Tests unitaires et d’intégration avec Spring Boot : le “test slicing”

December 09, 2019

Intro

Lorsque l’on met en place une application Spring Boot, de nombreuses briques entrent en jeu. Par exemple : la gestion des Contrôleurs REST avec la brique SPRING-WEB; la gestion de la persistance avec la brique SPRING-DATA, et la base de données sous-jacente ( MongoDB ici); n’oublions pas l’aspect authentification et autorisation via la brique SPRING-SECURITY. Et on peut imaginer d’autres vu la taille de l’écosystème SPRING!

Comment faire en sorte de tester les différentes briques Spring efficacement, en environnement mocké (test unitaire) ou non (test d’intégration).

Nous allons donc voir la technique de test slicing au travers de 2 scénarios, afin d’isoler la brique à tester. Puis nous verrons un scénario de test d’intégration où toutes les briques seront actives!

Test unitaire brique WEB : Test de la brique WEB avec @WebMvcTest. Mocking de la brique Sécurité et DATA.

Test unitaire brique DATA : Test de la brique DATA avec @DataMongoTest. Mocking de la brique WEB et Sécurité. Base locale Mongo en mémoire. Des connaissances de SPRING DATA sont bienvenues, je vous laisse regarder la doc.

Test d’intégration final : tout est démocké et lancement réel du serveur via @SpringBootTest. Base locale Mongo en mémoire.

Présentation de notre cas d’exemple

Voici les éléments de l’application que nous allons mettre en place :

  • Un utilisateur authentifié et autorisé appelle un contrôleur REST (/users/123) afin d’obtenir l’adresse d’un autre utilisateur, dont l’identifiant est passé en paramètre. Ce endpoint REST est sécurisé avec SPRING SECURITY : on doit fournir un login + mot de passe en Basic Authentication (détail ci-après).
  • On va utiliser Mongo pour la persistance. Nous souhaitons que l’utilisateur qui effectue la requête en Basic Authentication soit inscrit dans la base Mongo (table UTILISATEUR). Pour cela, nous mettons en place une implémentation de UserDetailsService de Spring Security;
  • On va désactiver la gestion de session et la mise en place du JSESSIONID pour plus de simplicité via .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)

Voici le code de la configuration Spring Security associé au vue des règles ci-dessus :

@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private CustomUserDetailsService userService;

    @Override
    protected void configure(HttpSecurity http) throws Exception {

        http
            .httpBasic()
            .and()
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .csrf().disable()
            .exceptionHandling()
            .and()
            .authorizeRequests()
            .anyRequest().authenticated();
    }

    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(this.userService).passwordEncoder(passwordEncoder());
    }

    @Bean
    public PasswordEncoder passwordEncoder(){
        PasswordEncoder encoder = new BCryptPasswordEncoder();
        return encoder;
    }

Nous avons donc 2 collections dans Mongo :

  • UTILISATEUR (userId, email, password) : pour la gestion de l’authentification

  • CONTACT (userId, address) : stockage des adresses de chaque utilisateur, appelé par contrôleur REST

Passons maintenant dans le vif du sujet,avec l’implémentation de nos trois scénarios de tests.

Scénario 1 : Test unitaire brique WEB

Dans ce scénario, nous voulons simplement tester notre contrôleur REST, qui fait un simple appel à la base de données Mongo. Voici notre contrôleur REST :

@RestController
public class MyController {

    @Autowired
    private ContactService contactService;

    @RequestMapping(value = "/users/{userId}", method = RequestMethod.GET)
    public String test(@PathVariable("userId") String userId ) throws Exception {
        return contactService.getAdress(userId);
    }

Voici comment on teste cela :

@RunWith(SpringRunner.class)
@WebMvcTest(MyController.class)
public class Controller_Only_Test {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private ApplicationContext appContext;

    // need to mock this beans, will not be used anyway (we use @WithMockUser below)
    @MockBean
    CustomUserDetailsService service;

    @MockBean
    ContactService contactService;

    @Test
    @WithMockUser // use with mockMvc only
    public void testController() throws Exception {
        // given
        String userId = "123";
        when(contactService.getAdress(anyString())).thenReturn("myaddress");

        // when + then
        mockMvc.perform(get("/users/" + userId)).andExpect(content().string(containsString("myaddress")));

    }

Explications :

  • On utilise @WebMvcTest(MyController.class) afin d’indiquer à Spring de charger uniquement le contexte WEB;
  • Nous utilisons MockMvc pour effectuer l’appel REST;
  • Sachant que les services SPRING (@Service) ne sont pas chargés, il faut les mocker via l’annotation @MockBean
  • MockMvc va initialiser un contexte de sécurité par défaut du fait que nous ayons déclaré la dépendance SPRING SECURITY. Nous devons donc mocker un utilisateur via @WithMockUser

Scénario 2 : Test unitaire brique DATA

Dans ce scénario, nous voulons simplement tester notre repository MONGO. Pour cela, nous devons mettre en place une base locale en mémoire grâce à cette librairie. Voici notre repository à tester :

public interface MongoContactRespository extends MongoRepository<Contact, String> {

    Optional<Contact> findByUserId(String userId);
}

Voici notre test unitaire :

@RunWith(SpringRunner.class)
@DataMongoTest
public class DB_Only_Test {

    @Autowired
    private MongoTemplate mongoTemplate;

    @Autowired
    MongoContactRespository mongoContactRespository;

    @Test
    public void testWithFrenchIsbn() {

        // given
        Contact contact = new Contact();
        contact.setUserId("123");
        contact.setAddress("address1");
        mongoTemplate.save(contact);

        // when + then
        Optional<Contact> address = mongoContactRespository.findByUserId("123");
        assertEquals(address.get().getAddress(), "address1");
    }
}

Explications:

  • On utilise @DataMongoTest pour notre test; cette annotation va uniquement charger le contexte SPRING DATA.
  • On injecte un MongoTemplate pour initialiser notre jeu de données;
  • On injecte notre repository à tester.

Scénario 3 : Test d’intégration

Pour ce dernier scénario plus complexe, nous allons mettre en place un vrai test d’intégration qui a toutes les briques de SPRING actives : SECURITY, DATA, WEB. Ceci est rendu possible grâce à l’annotation @SpringBootTest qui va charger TOUT le contexte SPRING, comme en conditions réelles!

Voici notre d’intégration :

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
public class Full_With_Server_Test {

    @LocalServerPort
    private int port;

    @Autowired
    private TestRestTemplate testRestTemplate;

    @Autowired
    private ApplicationContext appContext;

    @Autowired
    private WebApplicationContext webAppContext;

    @Autowired
    private MongoTemplate mongoTemplate;

    @Before
    public void setup() {
        // save user who makes authentication
        Utilisateur user = new Utilisateur();
        user.setEmail("toto");
        BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();
        String p = bCryptPasswordEncoder.encode("tutu");
        user.setPassword(p);
        mongoTemplate.save(user);

        // save contact
        Contact contact = new Contact();
        contact.setAddress("adress2");
        contact.setUserId("456");
        mongoTemplate.save(contact);
    }

    @Test
    public void getUserAdress() {
        String adress = testRestTemplate.withBasicAuth("toto", "tutu").getForObject("http://localhost:" + port + "/users/456", String.class);
        assertEquals(adress, "adress2");
    }
}

Explications:

  • Afin de démarrer un vrai serveur en conditions réelles il faut déclarer : @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) qui va lancer le serveur sur un port aléatoire;
  • Très important : Vu que @SpringBootTest charge le contexte SPRING par défaut, il ne faut surtout pas charger la vraie base Mongo mais la base en mémoire. Pour cela il y a plusieurs options, mais dans notre cas, j’ai choisi les profils SPRING. J’ai déclaré un profil test afin de ne pas charger la configuration réelle de MONGO, mais utiliser la base en mémoire. Voici comment : @ActiveProfiles(test) sur la classe de test, et @Profile(test) sur la classe de configuration Mongo;
  • Initialiser nos jeu de données via l’annotation @Before Junit et un mongoTemplate;
  • Appel de notre service REST via un appel réel réseau et le TestRestTemplate. Ce dernier est idéal car il offre une méthode withBasicAuth pour passer l’utilisateur à authentifier via SPRING SECURITY.

Conclusion

Voici un POST riche où nous avons passés en revus de nombreux concepts fondamentaux du testing avec SPRING. Toute remarque ou commentaire est bienvenue! Comme d’habitude l’ensemble du code source est accessible sur mon github. Cheers!


Hello, moi c'est Sylvain Maestri , développeur WEB fullstack avec 15 années d'expérience, en environnement Java / Spring pour le backend et Javascript / React pour le frontend. Je relate donc dans ce blog quelques trucs appris au fil de ces années, ça servira surement :).