原文 https://reflectoring.io/spring-boot-web-controller-test/
>翻译: 祝坤荣
>
在这个测试 Spring Boot 系列的第二部分,我们来看下 web contoller。开始,我们会探索下 web controller 到底做了什么,然后我们可以基于写单元测试来覆盖所有它的职责。
然后,我们来看看如果在测试用覆盖这些职责。只有当所有这些职责都被覆盖到了,我们才可以肯定我们的 contoller 的行为应该与线上环境一样了。
样例代码
这篇文章提供在GitHub上的可运行代码。
测试 Spring Boot 系列
这篇教程是一个系列的一部分:
Spring Boot的单元测试
使用@WebMvcTest测试Spring Boot的MVC Web Controller
使用@DataJpaTest测试Spring Boot的JPA Queries
使用@SpringBootTest进行集成测试
如果你喜欢看视频学习,可以看看 Philip 的测试Spring Boot应用课程(如果你通过这个链接购买,我有分成)。
依赖
我们会使用 JUnit Jupiter(JUnit 5)作为测试框架,Mockito 来模拟,AssertJ 来建立断言,Lombok 来减少冗余代码:
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-engine:5.2.0'
testCompile('org.mockito:mockito-junit-jupiter:2.23.0')
}
复制代码
AssertJ 和 Mockito 会通过引入 spring-boot-starter-test 自动引入。
Web Controller 的职责
让我们先看一个典型的 REST controller:
@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());
}
}
复制代码
Controller 的方法通过 @PostMapping 的声明来定义需要监听的 URL,HTTP 方法和 content 类型。
它接受通过 @PathVariable, @RequestBody 和 @RequestsParam 声明的入参,其被进入的 HTTP 请求自动填充。
参数可能被声明成 @Valid 来标明 Spring 需要对它们进行bean校验。
然后 controller 使用这些参数,调用业务逻辑,得到一个简单 Java 对象,其会被以 JSON 的形式默认自动写入到 HTTP 响应 body 中。
这里有很多 Spring 魔法。简单来说,对每一个请求,controller 通常经过以下步骤:
|# | 职责 | 描述 |
| --- | --- | --- |
| 1. | 监听 HTTP 请求 | controller 需要对特定的 URL,HTTP 方法和 content 类型做响应 |
| 2. | 反序列化输入 | controller 需要解析进入的 HTTP 请求并从 URL,HTTP 请求参数和请求 body 中创建 Java 对象,这样我们在代码中使用 |
| 3. | 检查输入 | controller 是防御不合法输入的第一道防线,所以这是个校验输入的好地方 |
| 4. | 调用业务逻辑 | 得到了解析过的入参,controller 需要将入参传给业务逻辑期望的业务模型 |
| 5. | 序列化输出 | controller 得到业务逻辑的输出并将其序列化到 HTTP 响应中 |
| 6. | 翻译异常 | 如果某些地方有异常发生了,controller 需要将其翻译成一个合理的错误消息和 HTTP 状态码 |
所以 controller 有一大堆活要干!
我们要注意不要再填加更多的像执行业务逻辑这样的职责了。那样的话,我们的 controller 测试会过于冗余并难以维护。
我们如果编写可以覆盖所有这些职责的合理测试呢?
单元或集成测试?
我们是写单元测试?还是写集成测试呢?这两个有什么不同?让我们看看两种方式并选择其中一个。
在单元测试中,我们需要将 controller 隔离测试。这表示我们要初始化一个 controller 对象,对业务逻辑进行模拟,然后调用 controller 的方法并校验返回。
这在我们的例子里行吗?让我们看下在上面我们定义的 6 个职责能否在隔离的单元测试中覆盖:
|# | 职责 | 描述 |
| --- | --- | --- |
| 1. | 监听 HTTP 请求 | 不行,因为单元测试不会检查 @PostMapping 声明并模拟 HTTP 请求的特定参数 |
| 2. | 反序列化输入 | 不行,因为像 @RequestParam 和 @pathVariable 这样的声明不会被检验。我们会以 Java 对象的形式提供输入,这会跳过 HTTP 请求的反序列化 |
| 3. | 检查输入 | 不行,不依赖 bean 校验,因为 @Valid 声明不会被校验。 |
| 4. | 调用业务逻辑 | 可以,因为我们可以校验业务逻辑被期望的参数调用 |
| 5. | 序列化输出 | 不行,因为只能校验 Java 版本的输出,HTTP 返回不会生成 |
| 6. | 翻译异常 | 不行,我们可以检查一个特定的异常是否产生,但它不会被翻译成一个 JSON 返回或 HTTP 状态码 |
简单来说,一个简单的单元测试不能覆盖 HTTP 层。所以我们要将 Spring 引入到测试中来帮我们做点 HTTP 魔法。因此,我们构建一个集成测试来测试我们 controller 代码与 Spring 提供的 HTTP 支持组件的集成。
一个 Spring 集成测试启动一个 Spring 包含所有我们需要 bean 的应用上下文。这包括了负责监听特定 URL,序列化与反序列化 JSON 并翻译 HTTP 异常的框架 bean。这些 bean 会检查在一个简单的单元测试里会被忽略的声明。
那么,我们怎么做呢?
使用 @WebMvcTest 校验 Controller 的职责
Spring Boot 提供了 @WebMvcTest 声明来加载只包括了需要测试 web controller 的 bean 的应用上下文:
@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
这篇教程的代码样例使用了@ExtendWith声明来告诉JUnit 5来开启Spring支持。 在Spring Boot 2.1,我们不再需要加载SpringExtension了,因为它已经被包含在像@DataJpaTest,@WebMvcTest和@SpringBootTest这样的Spring Boot测试声明中了。
复制代码
现在我们可以在所有我们在应用上下文中需要的 bean 上使用 @Autowire 了。 Spring Boot 会自动提供像 @ObjectMapping 这样的 bean 来做映射并从 JSON 和 MockMvc 实例来模拟 HTTP 请求。
我们使用 @MockBean 来模拟业务逻辑,因为我们并不想测试 controller 与业务逻辑的集成,而只是要测试 controller 与 Http 层的集成。 @MockBean 自动用 Mockito 的 mock 来替换应用上下文与被替换的 bean 同类型的 bean。
你可以在我讲述模拟的文章来看更多关于 @MockBean 的内容。
使用@WebMvcTest
在上例中通过将controller的参数设置到RegisterRestController.class上,我们告诉Spring Boot在创建上下文时限制给定的controller和一些Spring Web MVC框架的bean。而其他我们可能需要的bean被@MockBean隔离或模拟掉了。
如果我们不传controllers参数,Spring Boot会加载应用上下文中的所有controller。 这样我们就需要加载或模拟每个controller依赖的所有bean。这回事测试的配置变得复杂的多,但由于所有的controller测试都可以重用相同的应用上下线而节省了时间。
我打算将应用上下文缩小来限制controller测试,这样可以让测试保持独立,不需要引入其他的bean,尽管这样会让Spring Boot在每次单个测试时都会建一个新的应用上下文。
让我们看一下每个职责,并看看如果通过使用MockMvc来校验每项职责来进行最佳的集成测试。
复制代码
插入一条推荐内容,我与其他 2 位作者一起翻译的 Spring 5 设计模式 21 年 2 月已经在京东等电商渠道上架了,本书主要讨论了在 Spring 框架中使用的经典设计模式,能帮助开发者了解 Spring 的内部原理,是一本不错的学习书籍
1. 校验匹配 HTTP 请求
验证一个 controller 监听一个特定的 HTTP 请求很直接。我们只要调用 MockMvc 的 perform()方法并提供要测试的 URL 即可:
mockMvc.perform(post("/forums/42/register")
.contentType("application/json"))
.andExpect(status().isOk());
复制代码
不只是校验 controller 会对一个特定的请求会有响应,这个测试也可以校验 HTTP 方法(这个例子是 POST)与请求的 content type 是否正确。以上 controller 会拒绝任何用了不同 HTTP 方法或 content type 的请求。
记住这个测试仍然会失败,因为我们的 controller 期望一些入参。
更多匹配 HTTP 请求的内容可以在 Javadoc MockHttpServletRequestBuilder中看到。
2. 校验输入
为了校验入参被成功的序列化成了 Java 对象,我们需要在测试请求中提供它。输入可以是请求 body(@RequestBody)里的 JSON 内容,一个 URL 中的变量(@PathVariable)或一个 HTTP 请求中的参数(@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());
}
复制代码
我们现在提供了路径变量 forumId,请求参数 sendWelcomeMail 和 controller 期望的请求 body。请求 body 是用 Spring Boot 提供的 ObjectMapper 生成的,将 UserResource 对象序列化成了一个 JSON 字符串。
如果测试绿了,那么我们就知道了 controller 的 register()方法可以将将这些 HTTP 请求的参数并将其解析成为 Java 对象。
3. 检查输入校验
让我们看下 UserResource 用 @NotNull 声明来拒绝 null 值:
@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());
}
复制代码
当我们为方法参数增加了 @Valid 的声明后 Bean 检验会自动触发。所以,对于走乐观路径来说(比如让检验成功),我们在上一节创建的测试已经足够了。
如果我们想要测试一下检验失败的情况,我们需要加一个测试用例,发送一个不合法的 UserResouceJSON 对象给 controller.我们期望 controller 返回 HTTP 状态 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());
}
复制代码
取决于这个校验对于应用有多重要,我们可以为每个不合法的值加一个测试用例。这样可以快速添加大量测试用例,所以你需要与团队说明下你到底想要如何在你的项目里处理校验的测试。
4. 校验业务逻辑调用
下面,我们想要校验一下业务逻辑的调用是否符合预期。在这个例子,业务逻辑是由 RegisterUseCase 接口提供的并期望以一个 User 对象和一个 boolean 作为输入:
interface RegisterUseCase {
Long registerUser(User user, boolean sendWelcomeMail);
}
复制代码
我们期望 controller 将传入的 UserResource 对象转成 User 并将这个对象传给 registerUser()方法。
为了验证这个,我们可以模拟 RegisterUseCase,其是被声明了 @MockBean 声明并被注入到 application context:
@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");
}
复制代码
当调用了 controller 后,我们使用 ArgumentCaptor 来捕捉传给 RegisterUseCase.registerUser()的 User 对象并检查它包含了期望的值。
verify 用来检查 registerUser()确实被调用了一次。
记住如果我们对 User 对象做了很多断言假设,为了更易读,我们可以写一个自定义Mockito断言方法。
5. 检查输出序列化
在业务逻辑被调用后,我们期望 controller 将结果封装到 JSON 字符串并放在 HTTP 响应里。在这个例子,我们期望 HTTP 响应 body 里有一个有效的 JSON 格式的 UserResource 对象:
@Test
void whenValidInput_thenReturnsUserResource() throws Exception {
MvcResult mvcResult = mockMvc.perform(...)
...
.andReturn();
UserResource expectedResponseBody = ...;
String actualResponseBody = mvcResult.getResponse().getContentAsString();
assertThat(actualResponseBody).isEqualToIgnoringWhitespace(
objectMapper.writeValueAsString(expectedResponseBody));
}
复制代码
为了对响应 body 做断言,我们需要将 HTTP 交互的结果存储在一个使用 andReturn 方法返回的类型 MvcResult 中。
然后可以从响应 body 中读取 JSON 字符串并使用 isEqualToIgnoringWhitespce()来比较预期字符串。我们可以用 Spring Boot 提供的 ObjectMapper 来将 Java 对象编程一个 JSON 字符串。
记住我们通过使用一个自定义的 ResultMatcher 来让这些更易读,后面会介绍
6. 校验异常处理
通常,如果一个异常发生,controller 会返回一个特定的 HTTP 状态码,400,是请求出问题了,500,是异常出现了,等等。
Spring 默认能处理大部分这些情况。尽管如此,如果我们有自定义的异常处理,我们会需要测试。比如我们想要对每个无效的表单项返回一个结构化的带表单名和错误信息的响应。先写一个 @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;
}
}
复制代码
如果 bean 校验失败,Spring 抛出 MethodArgumentNotValidException。我们通过将 Spring 的 FieldError 映射到我们自己的 ErrorResult 数据结构来处理这个异常。异常处理会让所有 controller 返回 HTTP 400 状态并将 ErrorResult 对象转成 JSON 字符串放在响应 body。
要校验这个动作,我们使用之前的测试来让校验失败:
@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);
}
复制代码
一样的,我们从响应 body 读取 JSON 字符串并与期望的 JSON 字符串来比较。而且,我们也检查响应码是 400.
这些,也可以按更可读的方式来实现,就像之前学过的
编写自定义 ResultMatchers
特定的断言不太好写,更重要的是,比较难读。特别是当我们想要从 HTTP 响应中比较 JSON 字符串是否符合预期时需要写很多代码,就像我们在上两个例子看到的。
幸运的是,我们可以使用 MockMvc 内置的 API 来写一个自定义的 ResultMatcher。来看下在这个例子里我们怎么做。
匹配 JSON 输出
如果像下面的代码一样来比较 HTTP 响应 body 中是否包含一个 Java 对象的 JSON 形式是不是很舒服?
@Test
void whenValidInput_thenReturnsUserResource_withFluentApi() throws Exception {
UserResource user = ...;
UserResource expected = ...;
mockMvc.perform(...)
...
.andExpect(responseBody().containsObjectAsJson(expected, UserResource.class));
}
复制代码
不需要手动比较 JSON 字符串了。并且更具有可读性。事实上,代码可以自解释。
要像上面这样使用代码,我们要写一个自定义的 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();
}
}
复制代码
静态方法 responseBody()作为我们 API 的入口。它返回从 HTTP 响应 body 的实际 ResultMatcher 并且逐项比较是否与预期对象相符。
匹配期望的校验错误
我们可以进一步简化我们的异常处理测试。这里用了四行代码来检查 JSON 响应包含了特定的错误信息。我们可以使用一行替代:
@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"));
}
复制代码
同样,代码可以自解释。
要开启这个 API,我们要上面代码里的 ResponseBodyMatchers 类填加 containsErrorMessageForField():
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();
}
}
复制代码
所有的糟糕代码都隐藏在了 helper 类里,而我们可以愉快的在集成测试里编写干净的断言代码。
结论
Web controller 有许多职责。如果我们想要用有意义的测试来覆盖一个 web controller,只是检查是否返回 HTTP 状态码是不够的。
通过 @WebMvcTest,Spring Boot 提供了所有需要在 web controller 测试需要的东西,但要让测试有意义,我们要记得覆盖所有职责。不然,我们可能在应用运行时出现惊吓。
这篇文章的代码在github上可用。
本文来自祝坤荣(时序)的微信公众号「麦芽面包,id「darkjune_think」
开发者/科幻爱好者/硬核主机玩家/业余翻译~~~~
微博:祝坤荣
B 站: https://space.bilibili.com/23185593/
转载请注明。
交流 Email: zhukunrong@yeah.net
评论