Testing MVC web contractor with @ webmvctest (2)

Time:2021-4-25

original texthttps://reflectoring.io/sprin…
Translator: Zhu Kunrong
It takes about 10 minutes to read

1. Check and match the HTTP request

Verifying that a controller listens to a specific HTTP request is straightforward. We only need to call the perform() method of mockmvc and provide the URL to be tested

mockMvc.perform(post("/forums/42/register")
    .contentType("application/json"))
    .andExpect(status().isOk());

Not only does it verify that the controller will respond to a specific request, this test can also verify whether the HTTP method (in this case, post) and the content type of the request are correct. The above controller will reject any request using different HTTP methods or content types.

Remember that this test will still fail because our controller expects some input.

More matching HTTP requests can be found in JavadocMockHttpServletRequestBuilderYou can see it in the picture.

2. Check the input

In order to verify that the input parameter is successfully serialized into a Java object, we need to provide it in the test request. The input can be the JSON content in the request body (@ requestbody), the variable (@ pathvariable) in a URL or the parameter (@ requestparam) in an HTTP request:

@Test
void whenValidInput_thenReturns200() throws Exception {
  UserResource user = new UserResource("Zaphod", "[email protected]");
  
   mockMvc.perform(post("/forums/{forumId}/register", 42L)
        .contentType("application/json")
        .param("sendWelcomeMail", "true")
        .content(objectMapper.writeValueAsString(user)))
        .andExpect(status().isOk());
}

We now provide the path variable forumid, the request parameter sendwelcomemail, and the request body expected by the controller. The request body is generated with the objectmapper provided by spring boot, which serializes the userresource object into a JSON string.

If the test is green, then we know that the controller’s register () method can parse the parameters of these HTTP requests into Java objects.

3. Check the input verification

Let’s see that userresource rejects null values with the @ notnull declaration:

@Test
void whenValidInput_thenReturns200() throws Exception {
  UserResource user = new UserResource("Zaphod", "[email protected]");
  
   mockMvc.perform(post("/forums/{forumId}/register", 42L)
        .contentType("application/json")
        .param("sendWelcomeMail", "true")
        .content(objectMapper.writeValueAsString(user)))
        .andExpect(status().isOk());
}

When we add the @ valid declaration to the method parameter, the bean test will be triggered automatically. So the tests we created in the previous section are sufficient for taking an optimistic path (such as making the test successful).

If we want to test the test failure, we need to add a test case and send an illegal userresoucejson object to the controller

@Test
void whenNullValue_thenReturns400() throws Exception {
  UserResource user = new UserResource(null, "[email protected]");
  
  mockMvc.perform(post("/forums/{forumId}/register", 42L)
      ...
      .content(objectMapper.writeValueAsString(user)))
      .andExpect(status().isBadRequest());
}

Depending on how important the validation is to the application, we can add a test case to each illegal value. This allows you to quickly add a large number of test cases, so you need to explain to the team how you want to handle validation tests in your project.

4. Check business logic call

Next, we want to check whether the call of business logic meets the expectation. In this example, the business logic is provided by the registerusecase interface and expects a user object and a Boolean as input

interface RegisterUseCase {
  Long registerUser(User user, boolean sendWelcomeMail);
}

We expect the controller to turn the incoming userresource object into user and pass this object to the registeruser () method.

To verify this, we can simulate register use case, which is declared with @ mockbean declaration and injected into application context

@Test
void whenValidInput_thenMapsToBusinessModel() throws Exception {
  UserResource user = new UserResource("Zaphod", "[email protected]");
  mockMvc.perform(...);

  ArgumentCaptor<User> userCaptor = ArgumentCaptor.forClass(User.class);
  verify(registerUseCase, times(1)).registerUser(userCaptor.capture(), eq(true));
  assertThat(userCaptor.getValue().getName()).isEqualTo("Zaphod");
  assertThat(userCaptor.getValue().getEmail()).isEqualTo("[email protected]");
}

When the controller is called, we use the argumentcaptor to capture the RegisterUseCase.registerUser () and check that it contains the expected value.

Verify is used to check that registeruser() was actually called once.

Remember, if we make a lot of assertions and assumptions about the user object, we can write one to make it easier to readCustom mockito assertion method

5. Check the output serialization

After the business logic is called, we expect the controller to encapsulate the result into a JSON string and put it in the HTTP response. In this example, we expect an effective JSON format userresource object in the HTTP response body

@Test
void whenValidInput_thenReturnsUserResource() throws Exception {
  MvcResult mvcResult = mockMvc.perform(...)
      ...
      .andReturn();

  UserResource expectedResponseBody = ...;
  String actualResponseBody = mvcResult.getResponse().getContentAsString();
  
  assertThat(actualResponseBody).isEqualToIgnoringWhitespace(
              objectMapper.writeValueAsString(expectedResponseBody));
}

In order to assert the response body, we need to store the result of the HTTP interaction in a type mvcresult returned by the andreturn method.

You can then read the JSON string from the response body and use isequaltoigningwhitespce() to compare the expected string. We can use the objectmapper provided by spring boot to program Java objects into a JSON string.

Remember that we make these easier to read by using a custom resultmatcher,It will be introduced later

6. Check exception handling

Usually, if an exception occurs, the controller will return a specific HTTP status code, 400, the request is wrong, 500, the exception appears, and so on.

Spring can handle most of these situations by default. However, if we have custom exception handling, we will need to test it. For example, we want to return a structured response with form name and error message for each invalid form item. First write a @ controlleradvice:

@ControllerAdvice
class ControllerExceptionHandler {
  @ResponseStatus(HttpStatus.BAD_REQUEST)
  @ExceptionHandler(MethodArgumentNotValidException.class)
  @ResponseBody
  ErrorResult handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
    ErrorResult errorResult = new ErrorResult();
    for (FieldError fieldError : e.getBindingResult().getFieldErrors()) {
      errorResult.getFieldErrors()
              .add(new FieldValidationError(fieldError.getField(), 
                  fieldError.getDefaultMessage()));
    }
    return errorResult;
  }

  @Getter
  @NoArgsConstructor
  static class ErrorResult {
    private final List<FieldValidationError> fieldErrors = new ArrayList<>();
    ErrorResult(String field, String message){
      this.fieldErrors.add(new FieldValidationError(field, message));
    }
  }

  @Getter
  @AllArgsConstructor
  static class FieldValidationError {
    private String field;
    private String message;
  }
  
}

If bean validation fails, spring throws a methodargumentnotvalidexception. We handle this exception by mapping spring’s fielderror to our own errorresult data structure. Exception handling will make all controllers return the HTTP 400 status and turn the errorresult object into a JSON string and put it in the response body.

To verify this action, we use the previous test to fail the verification:

@Test
void whenNullValue_thenReturns400AndErrorResult() throws Exception {
  UserResource user = new UserResource(null, "[email protected]");

  MvcResult mvcResult = mockMvc.perform(...)
          .contentType("application/json")
          .param("sendWelcomeMail", "true")
          .content(objectMapper.writeValueAsString(user)))
          .andExpect(status().isBadRequest())
          .andReturn();

  ErrorResult expectedErrorResponse = new ErrorResult("name", "must not be null");
  String actualResponseBody = 
      mvcResult.getResponse().getContentAsString();
  String expectedResponseBody = 
      objectMapper.writeValueAsString(expectedErrorResponse);
  assertThat(actualResponseBody)
      .isEqualToIgnoringWhitespace(expectedResponseBody);
}

Again, we read the JSON string from the response body and compare it with the expected JSON string. Moreover, we also check that the response code is 400

These can also be implemented in a more readable way,Just like I learned before

Write custom resultmatchers

Specific assertions are not easy to write and, more importantly, difficult to read. Especially when we want to compare the JSON string from the HTTP response to see if it is as expected, we need to write a lot of code, as we saw in the last two examples.

Fortunately, we can use the built-in API of mockmvc to write a custom resultmatcher. Let’s see what we do in this case.

Match JSON output

Is it comfortable to compare whether the HTTP response body contains the JSON form of a Java object like the following code?

@Test
void whenValidInput_thenReturnsUserResource_withFluentApi() throws Exception {
  UserResource user = ...;
  UserResource expected = ...;

  mockMvc.perform(...)
      ...
      .andExpect(responseBody().containsObjectAsJson(expected, UserResource.class));
}

There is no need to compare JSON strings manually. And it’s more readable. In fact, the code can interpret itself.

To use the code like above, we need to write a custom resultmatcher:

public class ResponseBodyMatchers {
  private ObjectMapper objectMapper = new ObjectMapper();

  public <T> ResultMatcher containsObjectAsJson(
      Object expectedObject, 
      Class<T> targetClass) {
    return mvcResult -> {
      String json = mvcResult.getResponse().getContentAsString();
      T actualObject = objectMapper.readValue(json, targetClass);
      assertThat(actualObject).isEqualToComparingFieldByField(expectedObject);
    };
  }
  
  static ResponseBodyMatchers responseBody(){
    return new ResponseBodyMatchers();
  }
  
}

The static method ResponseBody () serves as the entry to our API. It returns the actual resultmatcher from the HTTP response body and compares item by item whether it matches the expected object.

Match expected validation error

We can further simplify our exception handling tests. It’s used hereFour lines of codeTo check that the JSON response contains specific error messages. We can use one line instead:

@Test
void whenNullValue_thenReturns400AndErrorResult_withFluentApi() throws Exception {
  UserResource user = new UserResource(null, "[email protected]");

  mockMvc.perform(...)
      ...
      .content(objectMapper.writeValueAsString(user)))
      .andExpect(status().isBadRequest())
      .andExpect(responseBody().containsError("name", "must not be null"));
}

Again, the code can interpret itself.

To open this API, we need to add containererrormessageforfield () to the responsebodymatchers class in the above code:

public class ResponseBodyMatchers {
  private ObjectMapper objectMapper = new ObjectMapper();

  public ResultMatcher containsError(
        String expectedFieldName, 
        String expectedMessage) {
    return mvcResult -> {
      String json = mvcResult.getResponse().getContentAsString();
      ErrorResult errorResult = objectMapper.readValue(json, ErrorResult.class);
      List<FieldValidationError> fieldErrors = errorResult.getFieldErrors().stream()
              .filter(fieldError -> fieldError.getField().equals(expectedFieldName))
              .filter(fieldError -> fieldError.getMessage().equals(expectedMessage))
              .collect(Collectors.toList());

      assertThat(fieldErrors)
              .hasSize(1)
              .withFailMessage("expecting exactly 1 error message"
                         + "with field name '%s' and message '%s'",
                      expectedFieldName,
                      expectedMessage);
    };
  }

  static ResponseBodyMatchers responseBody() {
    return new ResponseBodyMatchers();
  }
}

All the bad code is hidden in the helper class, and we can happily write clean assertion code in the integration test.

conclusion

Web controller has many responsibilities. If we want to override a web controller with meaningful tests, it is not enough to just check whether the HTTP status code is returned.

Through @ webmvctest, spring boot provides everything you need to test in the web controller, but to make the test meaningful, we should remember to cover all responsibilities. Otherwise, we may be scared when the application is running.

The code for this article is ingithubAvailable on.


This article is from WeChat official account of Zhu Kunrong (Time Series), malt bread, Id “darkjune_”. think」

Developer / science fiction enthusiast / hard core host player / amateur translator~~~~
Microblog: Zhu Kunrong
Station B:https://space.bilibili.com/23…
Please indicate the reprint.

Email:[email protected]