原文 https://reflectoring.io/spring-boot-web-controller-test/
翻译: 祝坤荣
在这个测试Spring Boot系列的第二部分,我们来看下web contoller。开始,我们会探索下web controller到底做了什么,然后我们可以基于写单元测试来覆盖所有它的职责。
然后,我们来看看如果在测试用覆盖这些职责。只有当所有这些职责都被覆盖到了,我们才可以肯定我们的contoller的行为应该与线上环境一样了。
样例代码
这篇文章提供在GitHub[1]上的可运行代码。
测试Spring Boot系列
这篇教程是一个系列的一部分:
1.Spring Boot的单元测试[2]2.使用@WebMvcTest测试Spring Boot的MVC Web Controller[3]3.使用@DataJpaTest测试Spring Boot的JPA Queries[4]4.使用@SpringBootTest进行集成测试[5]
如果你喜欢看视频学习,可以看看Philip的测试Spring Boot应用课程[6](如果你通过这个链接购买,我有分成)。
依赖
我们会使用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校验[7]。
然后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对象,对业务逻辑进行模拟[8],然后调用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。
你可以在我讲述模拟的文章[9]来看更多关于@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的内部原理,是一本不错的学习书籍。
本文来自祝坤荣(时序)的微信公众号「麦芽面包,id「darkjune_think」 转载请注明。
开发者/科幻爱好者/硬核主机玩家/业余翻译 微博:祝坤荣 B站: https://space.bilibili.com/23185593/ 交流Email: zhukunrong@yeah.net[10]
References
[1]
GitHub: https://github.com/thombergs/code-examples/tree/master/spring-boot/spring-boot-testing[2]
Spring Boot的单元测试: https://reflectoring.io/unit-testing-spring-boot/[3]
使用@WebMvcTest测试Spring Boot的MVC Web Controller: https://reflectoring.io/spring-boot-web-controller-test/[4]
使用@DataJpaTest测试Spring Boot的JPA Queries: https://reflectoring.io/spring-boot-data-jpa-test/[5]
使用@SpringBootTest进行集成测试: https://reflectoring.io/spring-boot-test/[6]
测试Spring Boot应用课程: https://transactions.sendowl.com/stores/13745/194393[7]
bean校验: https://reflectoring.io/bean-validation-with-spring-boot/[8]
业务逻辑进行模拟: https://reflectoring.io/unit-testing-spring-boot/#using-mockito-to-mock-dependencies[9]
述模拟的文章: https://reflectoring.io/spring-boot-mock/[10]
zhukunrong@yeah.net: mailto:zhukunrong@yeah.net