头图

[Note] This article is translated from: Testing MVC Web Controllers with Spring Boot and @WebMvcTest-Reflectoring

In the second part of a series on testing with Spring Boot, we will learn about web controllers. First, we will explore the practical role of the Web Controller so that we can build tests that cover all its responsibilities.
Then, we will figure out how to cover these responsibilities in the test. Only by covering these responsibilities can we ensure that our controllers operate as expected in a production environment.

 Code example

This article attached on GitHub working code examples.

rely

We will use JUnit Jupiter (JUnit 5) as the testing framework, Mockito for simulation, AssertJ to create assertions, and Lombok to reduce boilerplate code:

dependencies {
    compile('org.springframework.boot:spring-boot-starter-web')
    compileOnly('org.projectlombok:lombok')
    testCompile('org.springframework.boot:spring-boot-starter-test')
    testCompile('org.junit.jupiter:junit-jupiter:5.4.0')
    testCompile('org.mockito:mockito-junit-jupiter:2.23.0')
}

AssertJ and Mockito are automatically obtained spring-boot-starter-test

Responsibilities of the Web Controller

Let's start with a typical REST controller:

@RestController
@RequiredArgsConstructor
class RegisterRestController {
    private final RegisterUseCase registerUseCase;

    @PostMapping("/forums/{forumId}/register")
    UserResource register(@PathVariable("forumId") Long forumId, @Valid @RequestBody UserResource userResource,
            @RequestParam("sendWelcomeMail") boolean sendWelcomeMail) {

        User user = new User(userResource.getName(), userResource.getEmail());
        Long userId = registerUseCase.registerUser(user, sendWelcomeMail);

        return new UserResource(userId, user.getName(), user.getEmail());
    }

}

The controller method uses the @PostMapping annotation to define the URL, HTTP method, and content type it should listen to.
It gets input by using @PathVariable , @RequestBody and @RequestParam , which are automatically filled in from the incoming HTTP request.
Parameters can @Valid to indicate that Spring should verify bean.
Then the controller uses these parameters to call the business logic to return an ordinary Java object, which is automatically mapped to JSON and written into the HTTP response body by default.
There is a lot of spring magic here. In short, for each request, the controller usually performs the following steps:

# Responsibilities Description
1.Listen for HTTP requestsThe controller should respond to certain URLs, HTTP methods, and content types.
2.Deserialize inputThe controller should parse the incoming HTTP request and create Java objects based on the URL, HTTP request parameters, and variables in the request body so that we can use them in the code.
3.Verify inputThe controller is the first line of defense against wrong input, so it is where we can verify the input.
4.Call business logicAfter parsing the input, the controller must transform the input into the model expected by the business logic and pass it to the business logic.
5.Serialized outputThe controller takes the output of the business logic and serializes it into an HTTP response.
6.Conversion exceptionIf an exception occurs somewhere, the controller should convert it into an error message and HTTP status that is meaningful to the user.

The controller obviously has a lot of work to do!
We should be careful not to add more responsibilities, such as executing business logic . Otherwise, our controller tests will become bloated and unmaintainable.
How will we write meaningful tests that cover all these responsibilities?

Unit testing or integration testing?

Do we write unit tests? Or integration testing? What is the difference? Let us discuss these two methods and decide on one of them.
In the unit test, we will test the controller separately. This means that we will instantiate a controller object,
simulates business logic , and then call the controller's method and verify the response.
Does this work for us? Let's check which of the 6 responsibilities identified above can be covered in a single unit test:

# Responsibilities be covered in unit tests?
1.Listen for HTTP requests❌ No, because the unit test will not evaluate @PostMapping annotations and similar annotations that specify HTTP request attributes.
2.Deserialize input❌ No, because annotations like @RequestParam and @PathVariable will not be evaluated. Instead, we provide the input as a Java object, effectively skipping the deserialization of the HTTP request.
3.Verify input❌ Do not rely on bean validation, because @Valid annotations will not be evaluated.
4.Call business logic✔ Yes, because we can verify whether the simulated business logic is called with the expected parameters.
5.Serialized output❌ No, because we can only verify the output Java version, not the HTTP response that will be generated.
6.Conversion exception❌ No. We can check whether an exception is thrown, but we cannot check whether it is converted to a JSON response or HTTP status code.

The integration test with Spring will start a Spring application context that contains all the beans we need. This includes framework beans responsible for listening to certain URLs, serializing and deserializing with JSON, and converting exceptions to HTTP. These beans will evaluate annotations that simple unit tests would ignore. In short, simple unit test does not cover the HTTP layer . Therefore, we need to introduce Spring into our tests to do HTTP magic for us. Therefore, we are building an integration test to test the integration between our controller code and the components Spring provides for HTTP support.
So, what should we do?

Use @WebMvcTest to verify controller responsibilities

Spring Boot provides the @WebMvcTest annotation to start an application context that contains only the beans needed to test the Web controller:

@ExtendWith(SpringExtension.class)
@WebMvcTest(controllers = RegisterRestController.class)
class RegisterRestControllerTest {
    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private ObjectMapper objectMapper;

    @MockBean
    private RegisterUseCase registerUseCase;

  @Test
  void whenValidInput_thenReturns200() throws Exception {
    mockMvc.perform(...);
  }
}
@ExtendWith
The code example in this tutorial uses the @ExtendWith annotation to tell JUnit 5 to enable Spring support. Starting with Spring Boot 2.1, we no longer need to load SpringExtension because it is included as a meta-annotation in Spring Boot test annotations, such as @DataJpaTest , @WebMvcTest and @SpringBootTest .

We can now @Autowire get all the beans we need from the application context. Spring Boot automatically provides ObjectMapper to map to JSON and an MockMvc to simulate HTTP requests.
We use @MockBean to simulate the business logic, because we do not want to test the integration between the controller and the business logic, but the integration between the controller and the HTTP layer. @MockBea automatically replaces beans of the same type in the application context with Mockito simulation.
You can simulate about me in article in reading about @MockBean more information annotations.

use with or without controllers parameters @WebMvcTest ?
By setting the controllers RegisterRestController.class in the above example, we tell Spring Boot to limit the application context created for this test to a given controller bean and some framework beans required by Spring Web MVC. All other beans that we may need must be included separately or simulated @MockBean
If we do not use the controllers parameter, Spring Boot will include all controllers in the application context. Therefore, we need to include or simulate all the beans that any controller depends on. This makes the test setup more complicated and has more dependencies, but saves running time because all controller tests will reuse the same application context.
I tend to limit controller testing to the narrowest application context to make the test independent of beans that I don’t even need in the test, even though Spring Boot must create a new application context for each individual test.

Let us review each responsibility and see how we use MockMvc to verify each responsibility in order to build the best integration test we can.

1. Verify that the HTTP request matches

Verifying that the controller is listening for an HTTP request is very simple. We simply call MockMvc of perform() method and provide the URL we want to test:

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

In addition to verifying the controller's response to a specific URL, this test also verifies the correct HTTP method (POST in our example) and the correct request content type. The controller we saw above will reject any request with a different HTTP method or content type.
Please note that this test will still fail because our controller requires some input parameters.
More options for matching HTTP requests can be found in the Javadoc of MockHttpServletRequestBuilder

2. Verify input serialization

In order to verify whether the input was successfully serialized into a Java object, we must provide it in the test request. The input can be the JSON content of the request body (@RequestBody), variables in the URL path (@PathVariable), or HTTP request parameters (@RequestParam):

@Test
void whenValidInput_thenReturns200() throws Exception {
  UserResource user = new UserResource("Zaphod", "zaphod@galaxy.net");
 
   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 ObjectMapper provided by Spring Boot, and the UserResource object is serialized into a JSON string.
If the test result is green, we now know that the register() method of the controller has received these parameters as Java objects, and they have been successfully parsed from the HTTP request.

3. Validation input validation

Assume that UserResource uses the @NotNull annotation to reject the null value:

@Value
public class UserResource {

    @NotNull
    private final String name;

    @NotNull
    private final String email;

}

When we add the @Valid annotation to the method parameter, Bean validation will be triggered automatically, just like we did with the userResource parameter in the controller. Therefore, for the happy path (that is, when the verification is successful), the test we created in the previous section is sufficient.
If we want to test whether the verification fails as expected, we need to add a test case in which we send an invalid UserResource JSON object to the controller. Then we expect the controller to return HTTP status 400 (Bad Request):

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

Depending on the importance of verification to the application, we may add such test cases for each possible invalid value. However, this will quickly add a lot of test cases, so you should discuss with your team how you want to handle the verification tests in your project.

4. Verify the business logic call

Next, we want to verify that the business logic is called as expected. In our example, the business logic is provided by the RegisterUseCase interface, and requires a User object and a boolean value as input:

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

We want the controller to convert the incoming UserResource object to User and pass this object to the registerUser() method.
To verify this, we can request the RegisterUseCase simulation, which has been injected into the application context @MockBean

@Test
void whenValidInput_thenMapsToBusinessModel() throws Exception {
  UserResource user = new UserResource("Zaphod", "zaphod@galaxy.net");
  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("zaphod@galaxy.net");
}

After executing the call to the controller, we use ArgumentCaptor to capture the User RegisterUseCase.registerUser() and assert that it contains the expected value.
Call verify check registerUser() has been called once.
Please note that if we User object, we can create our own custom Mockito assertion method for better readability.

5. Verify output serialization

After calling the business logic, we want the controller to map the result to a JSON string and include it in the HTTP response. In our example, we want the HTTP response body to contain a valid UserResource object in JSON format:

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

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

To make an assertion on the response body, we need to use the andReturn() method to store the result of the HTTP interaction in a variable of type MvcResult
Then we can read the JSON string from the response body and compare it with the expected string isEqualToIgnoringWhitespace() ObjectMapper provided by Spring Boot to construct the expected JSON string from a Java object.
Please note that we can make it more readable ResultMatcher will be described later .

6. Verify exception handling

Generally, if an exception occurs, the controller should return a certain HTTP status. 400 --- if there is a problem with the request, 500 --- if there is an exception, etc.
By default, Spring will handle most of these situations. However, if we have custom exception handling, we want to test it. Suppose we want to return a structured JSON error response that contains the field name and error message of each invalid field in the request. We will create a @ControllerAdvice like this:

@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 will throw MethodArgumentNotValidException . We handle this exception by mapping Spring's FieldError object to our own ErrorResult In this case, the exception handler will cause all controllers to return HTTP status 400 and put the ErrorResult object as a JSON string in the response body.
To verify that this actually happened, we extended our previous test of failed verification:

@Test
void whenNullValue_thenReturns400AndErrorResult() throws Exception {
  UserResource user = new UserResource(null, "zaphod@galaxy.net");

  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);
}

Similarly, we read the JSON string from the response body and compare it with the expected JSON string. In addition, we check whether the response status is 400.
This can also be achieved in a more readable way, which we will learn next. Create custom ResultMatcher
Certain assertions are difficult to write, and more importantly, difficult to read. Especially when we want to compare the JSON string from the HTTP response with the expected value, it requires a lot of code, as we saw in the last two examples.
Fortunately, we can create custom ResultMatcher and we can use them in MockMvc's fluent API. Let's see how to do this. Match JSON output
Wouldn't it be nice to use the following code to verify whether the HTTP response body contains a JSON representation of a Java object?

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

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

No need to compare JSON strings manually. Its readability is much better. In fact, the code is so self-explanatory, I don't need to explain it here.
In order to be able to use the above code, we created 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() used as the entry point for our fluent API. It returns the actual ResultMatcher, which parses the JSON from the HTTP response body and compares it field by field with the expected object passed in. Matches expected validation errors
We can even further simplify our exception handling tests. We used 4 lines of code to verify whether the JSON response contains an error message. We can change it to one line:

@Test
void whenNullValue_thenReturns400AndErrorResult_withFluentApi() throws Exception {
  UserResource user = new UserResource(null, "zaphod@galaxy.net");

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

Again, the code is self-explanatory.
In order to enable this fluent API, we must add the method containsErrorMessageForField() from above to our ResponseBodyMatchers class:

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 ugly code is hidden in this helper class, and we can happily write clean assertions in integration tests.

in conclusion

The web controller has many responsibilities. If we want to cover a web controller with meaningful tests, it is not enough to just check whether it returns the correct HTTP status.
With @WebMvcTest , Spring Boot provides everything we need to build a web controller test, but in order to make the test meaningful, we need to remember to cover all responsibilities. Otherwise, we may encounter ugly surprises at runtime.
Sample code in this article may be on GitHub found.


信码由缰
65 声望8 粉丝

“码”界老兵,分享程序人生。