Spring Boot and Tests

Personal note : I wrote multiple posts on Stackoverflow about  Spring Boot and Tests. 
For example that answer about difference between @Mock and @MockBean  or still that question-answer on the general approach to test ours components with Spring Boot.
But from now I never took the time to post that matter on my blog. Now it is time to : )

Global exception handler for Rest Controllers

Usage : 
– To not clutter controller code with exception-status code mapping.
– To not repeat exception-status code mapping in each controller
– To be able to assert error response code rather exceptions in tests with MockMvc

Notes :
– we could define multiple exception classes inside the same method annotated with ExceptionHandler.
– we could define a specific processing when the exception is caught or nothing and only add the ResponseStatus annotation

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
 
@ControllerAdvice
public class GlobalRestExceptionHandler {
 
  @ExceptionHandler(value = IllegalArgumentException.class)
  @ResponseStatus(HttpStatus.BAD_REQUEST)
  public void  badRequest(RuntimeException e){
    // just the mapping
  }
}

WebMvcTest

Known Problems

Problem : Spring add more beans in the context that it should.
Fix :
Normally annotating the class with @WebMvcTest(FooController.class) adds that bean only in the spring boot context.
So other controllers and services are not available in.
That is right only if we don’t specify @ComponentScan(basePackages = "foo.bar") in the Spring Boot main class.
So remove it as much as possible.

Problem : …
Fix :

Test a RestController with @PathVariable and that returns JSON

Goal :
– mocking the controller dependency (jpa repository) to test only the controller logic.
– testing the error case and check that in case of error a html content type and data is returned thanks to the interceptor whatever content type was specified in the rest controller or in the request header accept.

PersonController:

import davidxxx.Person;
import davidxxx.PersonRepository;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;
 
@RestController
public class PersonController {
 
    PersonRepository personRepository;
 
    public PersonController(PersonRepository personRepository) {
        this.personRepository = personRepository;
    }
 
    @GetMapping(value = "/person/{id}")
    @ResponseBody
    public ResponseEntity<Person> getPerson(@PathVariable("id") Long id) {
        Person person = personRepository.findById(id).orElseThrow(()->  new IllegalArgumentException("id person " + id + " not existing in the system"));
        return ResponseEntity.status(HttpStatus.OK)
                             .body(person);
    }
 
}

GlobalRestExceptionHandler:

@ControllerAdvice
public class GlobalRestExceptionHandler {
 
  @ExceptionHandler(value = IllegalArgumentException.class)
  @ResponseStatus(HttpStatus.BAD_REQUEST)
  public ResponseEntity<?> badRequest(RuntimeException e) {
    return ResponseEntity.badRequest().contentType(MediaType.TEXT_PLAIN).body(e.getMessage());
  }
}

PersonControllerTest :

import davidxxx.Person;
import davidxxx.PersonRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
 
import java.util.Optional;
 
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
 
@ExtendWith(SpringExtension.class)
@WebMvcTest(PersonController.class)
class PersonControllerTest {
 
  @MockBean
  PersonRepository personRepositoryMock;
 
  @Autowired
  private MockMvc mvc;
 
  @Test
  void getPerson() throws Exception {
    Person personByMock = new Person(1L, "foo", "bar");
    Mockito.when(personRepositoryMock.findById(1L))
           .thenReturn(Optional.of(personByMock));
    mvc.perform(MockMvcRequestBuilders.get("/person/{id}", 1)
                                      .contentType(MediaType.APPLICATION_JSON_VALUE)
                                      .characterEncoding("UTF-8"))
       .andExpect(status().isOk())
       .andExpect(content().json("{'id':1,'firstname':'foo', 'lastname':'bar'}"));
  }
 
  @Test
  void getPerson_when_id_not_existing() throws Exception {
    mvc.perform(MockMvcRequestBuilders.get("/person/{id}", 1)
                                      .contentType(MediaType.APPLICATION_JSON_VALUE)
                                      .accept(MediaType.APPLICATION_JSON_VALUE)
                                      .characterEncoding("UTF-8"))
       .andExpect(status().isBadRequest())
       .andExpect(header().string("Content-Type", "text/plain"))
       .andExpect(content().string("id person 1 not existing in the system"));
  }
 
}

 

About asserting the JSON contained in the response body : in that simple case, declare a JSON string representing the expected Person is simple and readable. But in more complex cases (many properties and nested properties), doing that is neither simple nor readable.
In that case, an alternative is passing to the ObjectMapper instance the model to serialize into a JSON string.
Example :

package davidxxx.basic;
 
import java.util.Optional;
 
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import davidxxx.GlobalRestExceptionHandler;
import davidxxx.Person;
import davidxxx.PersonRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
 
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
 
@ExtendWith(SpringExtension.class)
@WebMvcTest
@ContextConfiguration(classes = {PersonController.class, ObjectMapper.class, GlobalRestExceptionHandler.class})
class PersonControllerStraigtherTest {
 
    @MockBean
    PersonRepository personRepositoryMock;
 
    @Autowired
    private MockMvc mvc;
 
    @Autowired
    ObjectMapper objectMapper;
 
    @Test
    void getPerson() throws Exception {
        Person personByMock = new Person(1L, "foo", "bar");
        Mockito.when(personRepositoryMock.findById(1L))
               .thenReturn(Optional.of(personByMock));
 
        // Comparing the json generated by objectmapper simplifies the test and for complex objects, it may be helpul
        // but it has a downside : the assertion is slightly less reliable
        mvc.perform(MockMvcRequestBuilders.get("/person/{id}", 1)
                                          .contentType(MediaType.APPLICATION_JSON_VALUE)
                                          .characterEncoding("UTF-8"))
           .andExpect(status().isOk())
           .andExpect(content().json(asJsonString(personByMock)));
    }
 
    private String asJsonString(Object obj) {
        try {
            return objectMapper.writeValueAsString(obj);
        } catch (JsonProcessingException e) {
            throw new RuntimeException(e);
        }
    }
 
    @Test
    void getPerson_when_id_not_existing() throws Exception {
        mvc.perform(MockMvcRequestBuilders.get("/person/{id}", 1)
                                          .contentType(MediaType.APPLICATION_JSON_VALUE)
                                          .accept(MediaType.APPLICATION_JSON_VALUE)
                                          .characterEncoding("UTF-8"))
           .andExpect(status().isBadRequest())
           .andExpect(header().string("Content-Type", "text/plain"))
           .andExpect(content().string("id person 1 not existing in the system"));
    }
 
}

Notes :
– we inject ObjectMapper to use the ObjectMapper instances configured in the Spring Boot contexts. It matters because we may have customized it and so having not the same behavior in the tests than in the application.
– to be able to inject other thing than a Controller in the test such as ObjectMapper, we need to annotate the test class with @ContextConfiguration with .
But specifying that annotation has side effects on @WebMvcTest behavior. Indeed some beans that should be injected by @WebMvcTest are not any longer. Consequently, we need to declare them in @ContextConfiguration. ObjectMapper to use the ObjectMapper instances configured in the Spring Boot contexts. It matters because we may have customized it and so having not the same behavior in the tests than in the application.

Test a RestController along a service with @PathVariable and that returns JSON

Goal : mocking partly the controller dependency (jpa repository is mocked but not the DnaService) to test both the controller logic and the service logic.
Asserting the logic of the service allows to overlap unit test scope for controllers but if we lack of time to test the service, it looks a good approach. Besides, over-mocking may make a test silly, mini integration tests are sometimes desirable.

DnaControllerTest:

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;
 
@RestController
public class DnaController {
 
    private DnaService dnaService;
 
    public DnaController(DnaService dnaService){
        this.dnaService = dnaService;
    }
 
    @GetMapping(value = "/dna/{id}")
    @ResponseBody
    public ResponseEntity<Dna> getDna(@PathVariable("id") Long id) {
        Dna person = dnaService.find(id).get();
        return ResponseEntity.status(HttpStatus.OK)
                             .body(person);
    }
 
}

DnaService

import davidxxx.Person;
import davidxxx.PersonRepository;
import org.springframework.stereotype.Service;
 
import java.util.Optional;
 
@Service
public class DnaService {
 
  private PersonRepository personRepository;
 
  public DnaService(PersonRepository personRepository) {
    this.personRepository = personRepository;
  }
 
  public Optional<Dna> find(Long id) {
    return personRepository.findById(id).map(this::computeDna);
  }
 
  private Dna computeDna(Person person) {
    return new Dna(person);
  }
}

DnaControllerTest
To test DnaController along DnaService while mocking PersonRepository, we override the DnaService bean definition with our own instance that is a plain java instance of DnaService but with a PersonRepository mock as dependency.

import davidxxx.Person;
import davidxxx.PersonRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Bean;
import org.springframework.http.MediaType;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
 
import java.util.Optional;
 
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
 
@ExtendWith(SpringExtension.class)
@WebMvcTest(DnaController.class)
class DnaControllerTest {
 
  @TestConfiguration
  static class AdditionalConfig{
    @Bean
    public  DnaService getOverridedService(PersonRepository personRepository){
      return new DnaService(personRepository);
    }
  }
  @MockBean
  PersonRepository personRepositoryMock;
 
  @Autowired
  private MockMvc mvc;
 
  @Test
  void getDna() throws Exception {
    Person personByMock = new Person(1L, "foo", "bar");
    Mockito.when(personRepositoryMock.findById(1L))
           .thenReturn(Optional.of(personByMock));
    mvc.perform(MockMvcRequestBuilders.get("/dna/{id}", 1)
                                      .contentType(MediaType.APPLICATION_JSON_VALUE)
                                      .characterEncoding("UTF-8"))
       .andExpect(status().isOk())
       .andExpect(content().json("{'dna':'foo-bar'}"));
  }
 
}
Ce contenu a été publié dans Non classé. Vous pouvez le mettre en favoris avec ce permalien.

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *