[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 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 the code. |
3. | Verify input | The controller is the first line of defense against wrong input, so it is where we can verify the input. |
4. | Call 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 into an HTTP response. |
6. | Conversion exception | If 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 loadSpringExtension
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 withoutcontrollers
parameters@WebMvcTest
?
By setting thecontrollers
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 thecontrollers
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.
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。