Motoring: Testing MVC Web Controllers with Spring Boot and @webmvCtest-reflecmotoring
In part 2 of our series on testing with Spring Boot, we’ll learn about the Web controller. First, we’ll explore what the Web controller actually does, so we can build tests that cover all of its responsibilities. Then, we’ll figure out how to cover these responsibilities in our tests. Only by covering these responsibilities can we ensure that our controllers perform as expected in a production environment.
code example
Examples of working code on GitHub are attached to this article.
Rely on
We’ll use JUnit Jupiter (JUnit 5) as a testing framework, Mockito for simulation, AssertJ for assertion creation, 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')}Copy the code
AssertJ and Mockito follow the spring-boot-starter-test dependency to get automatically.
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 newUserResource(userId, user.getName(), user.getEmail()); }}Copy the code
The controller method uses the @postMapping annotation to define the URL, HTTP method, and content type it should listen for. It takes input with @PathVariable, @RequestBody, and @RequestParam annotated parameters that are automatically populated from incoming HTTP requests. Parameters can be annotated with @Valid to indicate that Spring should validate their beans. The controller then uses these parameters and calls the business logic to return a plain Java object, which by default is automatically mapped to JSON and written to the BODY of the HTTP response. There’s a lot of Spring magic here. In summary, for each request, the controller typically performs the following steps:
# | Duties and responsibilities | describe |
---|---|---|
1. | Listening for HTTP requests | The controller should respond to certain URLS, HTTP methods, and content types. |
2. | Deserialize input | The 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 our code. |
3. | Validate the input | The controller is the first line of defense against incorrect input, so it’s where we can validate the input. |
4. | Invoking business logic | After 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 output | The controller takes the output of the business logic and serializes it as an HTTP response. |
6. | Convert exceptions | If an exception occurs somewhere, the controller should translate 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 would 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? Integration testing? What’s the difference? Let’s discuss the two approaches and decide on one. In unit tests, we will test the controller individually. This means we will instantiate a controller object, simulate the business logic, and then call the controller’s methods and validate the response. Does it work for us? Let’s examine which of the six responsibilities identified above can be covered in a single unit test:
# | Duties and responsibilities | Can it be covered in unit tests |
---|---|---|
1. | Listening for HTTP requests | ❌ No, because unit tests don’t evaluate @postMapping annotations and similar annotations that specify HTTP request attributes. |
2. | Deserialize input | ❌ No, because comments like @requestParam and @pathvariable are not evaluated. Instead, we provide the input as a Java object, effectively skipping deserialization of the HTTP request. |
3. | Validate the input | ❌ does not rely on bean validation because the @valid annotation is not evaluated. |
4. | Invoking business logic | ➤ Yes, because we can verify that the simulated business logic is invoked with the expected parameters. |
5. | Serialized output | ❌ cannot, because we can only validate the Java version of the output, not the HTTP response that will be generated. |
6. | Convert exceptions | ❌ no. We can check if an exception was thrown, but not if it was converted to a JSON response or HTTP status code. |
The integration test with Spring launches a Spring application context that contains all the beans we need. This includes skeleton beans that listen for certain urls, serialize and deserialize to and from JSON, and convert exceptions to HTTP. These beans evaluate annotations that simple unit tests would ignore. In summary, simple unit tests do not override the HTTP layer. So, we need to introduce Spring to do the HTTP magic for us in our tests. Therefore, we are building an integration test to test the integration between our controller code and the components that Spring provides for HTTP support. So, what do we do?
Verify controller responsibilities using @webmvctest
Spring Boot provides the @webMvctest annotation to launch 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(a) throws Exception {
mockMvc.perform(...);
}
}
Copy the code
@ExtendWith
The code examples in this tutorial use the @extendWith notation 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 the Spring Boot test annotations, Examples are @datajpatest, @webMvctest, and @Springboottest.
We can now @autowire all the beans we need from the application context. Spring Boot automatically provides beans like ObjectMapper to map to JSON and a MockMvc instance to simulate HTTP requests. We use @MockBean to simulate business logic because we don’t want to test the integration between controller and business logic, but between controller and HTTP layer. MockBean automatically replaces beans of the same type in the application context with Mockito emulation. You can read more about the @MockBean annotation in my article on emulation.
Use with or without
controllers
Parameters of the@WebMvcTest
?
Through the controllers in the example above parameter is set to RegisterRestController. Class, We told Spring Boot to limit the application context created for this test to a given controller bean and a few framework beans required by Spring Web MVC. All other beans we might need must be included separately or simulated using @MockBean. If we do not use controllers, 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 test setup more complex, with more dependencies, but saves run 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 tests independent of beans I don’t even need in my tests, even though Spring Boot has to create a new application context for each individual test.
Let’s review each responsibility and see how we can use MockMvc to validate each responsibility in order to build the best integration tests we can.
1. Verify the HTTP request
Verifying that a controller listens for an HTTP request is simple. We simply call MockMvc’s Perform () method and provide the URL we want to test:
mockMvc.perform(post("/forums/42/register")
.contentType("application/json"))
.andExpect(status().isOk());
Copy the code
In addition to validating the controller’s response to a particular URL, this test also validates the correct HTTP method (POST in our case) and the correct request content type. The controller we saw above will reject any request that has a different HTTP method or content type. Note that this test will still fail because our controller requires some input parameters. HTTP request more matching options can be found in MockHttpServletRequestBuilder Javadoc.
2. Verify input serialization
To verify that 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 RequestBody (@requestbody), a variable in the URL path (@pathvariable), or an HTTP request parameter (@requestparam) :
@Test
void whenValidInput_thenReturns200(a) 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());
}
Copy the code
We now provide the path variable forumId, the request parameter sendWelcomeMail, and the request body that the controller expects. The request body is generated using ObjectMapper provided with Spring Boot, serializing the UserResource object into a JSON string. If the test results are green, we now know that the controller’s Register () method received these parameters as Java objects and that they were successfully parsed from the HTTP request.
3. Verify input verification
Suppose UserResource uses the @notnull annotation to reject null values:
@Value
public class UserResource {
@NotNull
private final String name;
@NotNull
private final String email;
}
Copy the code
When we add the @Valid annotation to the method parameter, Bean validation is automatically triggered, just as we did with the userResource parameter in the controller. Therefore, the test we created in the previous section is sufficient for the happy path (that is, when the validation succeeds). If we want to test whether validation 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 (error request) :
@Test
void whenNullValue_thenReturns400(a) throws Exception {
UserResource user = new UserResource(null."[email protected]");
mockMvc.perform(post("/forums/{forumId}/register".42L)... .content(objectMapper.writeValueAsString(user))) .andExpect(status().isBadRequest()); }Copy the code
Depending on how important validation is to the application, we might add such a test case for each possible invalid value. However, this can quickly add a lot of test cases, so you should discuss with your team how you want to handle validation tests in your project.
4. Validate the business logic call
Next, verify that the business logic is invoked 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);
}
Copy the code
We want the controller to convert the passed UserResource object to User and pass this object to the registerUser() method. To verify this, we can require the RegisterUseCase impersonation, which has been injected into the application context using the @MockBean annotation:
@Test
void whenValidInput_thenMapsToBusinessModel(a) 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]");
}
Copy the code
After performed to the call of the controller, we use ArgumentCaptor passed to capture RegisterUseCase. RegisterUser () the User object and asserts that it contains the expected value. Verify is called to check if registerUser() has been called once. Note that if we make a lot of assertions on the User object, we can create our own custom Mockito assertion method for better readability.
5. Verify output serialization
After invoking 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 JSON-formatted UserResource object:
@Test
void whenValidInput_thenReturnsUserResource(a) throws Exception { MvcResult mvcResult = mockMvc.perform(...) . .andReturn(); UserResource expectedResponseBody = ... ; String actualResponseBody = mvcResult.getResponse().getContentAsString(); assertThat(actualResponseBody).isEqualToIgnoringWhitespace( objectMapper.writeValueAsString(expectedResponseBody)); }Copy the code
To assert the response body, we need to store the result of the HTTP interaction in a variable of type MvcResult using the andReturn() method. Then we can read a JSON string from the response body, and use isEqualToIgnoringWhitespace () will be compared with the desired string. We can use ObjectMapper provided by Spring Boot to build the desired JSON string from Java objects. Note that we can make it more readable by using a custom ResultMatcher, which is described later.
6. Verify exception handling
Typically, if an exception occurs, the controller should return some HTTP status. 400 – if there is a problem with the request, 500 – if there is an exception, and so on. By default, Spring handles most of these cases. However, if we have custom exception handling, we want to test it. Suppose we want to return a structured JSON error response with the field name and error message for each invalid field in the request. We’ll create @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(newFieldValidationError(field, message)); }}@Getter
@AllArgsConstructor
static class FieldValidationError {
private String field;
privateString message; }}Copy the code
If the bean validation fails, the Spring will throw MethodArgumentNotValidException. We handle this exception by mapping Spring’s FieldError object to our own ErrorResult data structure. In this case, the exception handler causes all controllers to return HTTP status 400 and place the ErrorResult object as a JSON string in the response body. To verify that this is actually happening, we extend our previous test for failed validation:
@Test
void whenNullValue_thenReturns400AndErrorResult(a) 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);
}
Copy the code
Again, we read the JSON string from the response body and compare it to the expected JSON string. In addition, we check if the response status is 400. This can also be done in a more readable way, as we’ll learn next. Creating custom ResultMatcher Some assertions are hard to write and, more importantly, hard to read. Especially when we want to compare the JSON string from the HTTP response to the expected value, it requires a lot of code, as we saw in the last two examples. Fortunately, we can create custom ResultMatcher, which we can use in MockMvc’s smooth API. Let’s see how to do that. Wouldn’t it be nice to use the following code to verify that the HTTP response body contains a JSON representation of a Java object?
@Test
void whenValidInput_thenReturnsUserResource_withFluentApi(a) throws Exception {
UserResource user = ...;
UserResource expected = ...;
mockMvc.perform(...)
...
.andExpect(responseBody().containsObjectAsJson(expected, UserResource.class));
}
Copy the code
You no longer need to compare JSON strings manually. It’s much more readable. In fact, the code is so obvious that I don’t need to explain it here. To be able to use the code above, we create 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(a) {
return newResponseBodyMatchers(); }}Copy the code
The static method responseBody() serves 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 incoming expected object. Matching expected validation errors allows us to simplify our exception-handling tests even further. It took four lines of code to verify that the JSON response contained an error message. We can change it to a line:
@Test
void whenNullValue_thenReturns400AndErrorResult_withFluentApi(a) 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"));
}
Copy the code
Again, the code is self-explanatory. In order to enable the fluent API, we must add methods from above containsErrorMessageForField () 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(a) {
return newResponseBodyMatchers(); }}Copy the code
All the ugly code is hidden in this helper class, and we can happily write clean assertions during integration tests.
conclusion
A Web controller has many responsibilities. If we want to override a Web controller with meaningful tests, it is not enough to simply check that it returns the correct HTTP state. With @webMvctest, Spring Boot gives us everything we need to build Web controller tests, but to make the tests meaningful, we need to remember to cover all responsibilities. Otherwise, we might have an ugly surprise at run time. The sample code for this article is available on GitHub.