上一篇:Spring Boot 3.x Rest API统一异常处理最佳实践
下一篇:Spring Boot 3.x Filter实战:记录请求日志
Spring Boot
为我们提供了非常便捷的web
层Rest API
单元测试的API
,这种开发能力也是小伙伴必须要掌握的。如何对数据库、中间件服务以及远程调用在开发环境不可使用的情况进行Rest API
功能测试,本教程将为小伙伴揭秘。如果觉得对你有帮助,记得点赞收藏,关注小卷,后续更精彩!
文章目录
- 依赖与配置
- 创建单元测试
- mock mvc测试
- @MockBean注解
- 执行请求发送
- 假定条件下的测试
- perform结果断言
- 异常结果断言
- 完善异常测试捕获环节
- @AutoConfigureMockMvc测试方式
- 客户端测试方式
- 总结
依赖与配置
接下来我们通过web单元测试来对之前开发好的API
进行测试,先看下build.gradle
中跟单元测试相关的配置:
dependencies {
...
// 为单元测试环境引入和启用lombok编译功能
testAnnotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
...
}
tasks.named('test') {
useJUnitPlatform()
}
创建单元测试
创建单元测试的方式,在API接口上使用组合键Alt+Enter(这里对idea使用eclipse
风格的快捷键),比如:
点击创建Test,选择默认的junit5
单元测试框架,并选择要测试的方法,点创建:
mock mvc测试
看下面的单元测试结构:
package com.juan.demo.api;
import ...
import static org.junit.jupiter.api.Assertions.*;
@WebMvcTest(CartAPI.class)
class CartAPITest {
@Resource
private MockMvc mvc;
@Test
void addCartItem() {
assertNotNull(mvc);
}
}
我们在类的头部使用@WebMvcTest
注解,表明要启动一个web测试的应用模拟环境,也就是说并不会启动一个spring boot应用的真正上下文环境,对于需要注入的组件需要额外mock出来,直接运行以上单元测试用例,得到这样的错误:
...
Caused by: org.springframework.beans.factory.NoSuchBeanDefinitionException: No qualifying bean of type 'com.juan.demo.service.CartService' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {@jakarta.annotation.Resource(shareable=true, lookup="", name="", description="", authenticationType=CONTAINER, type=java.lang.Object.class, mappedName="")}
...
@MockBean注解
为此,我们可以通过@MockBean
注解来注入一个模拟实现的CartService
组件:
...
class CartAPITest {
...
@MockBean
private CartService cartService;
...
}
这样再启动单元测试,执行结果绿条,我们就模拟出了要测试的API的web环境,并对其依赖的后台组件进行模拟注入,以确保模拟上下文可以正常进行依赖注入。但是需要注意:对哪些API接口启动模拟测试环境是由@WebMvcTest
注解控制的,默认是所有的API,也可以指定(这里我们指定API接口即可),比如这里我们指定的是CartAPI
,而如果我们指定成@WebMvcTest(HelloAPI.class)
,则很显然,环境依赖更简单(无需用@@MockBean
注解注入任何模拟依赖)。
动一动小手
小伙伴们可以按照上述注意事项,自行做实验来验证结论。
执行请求发送
环境启动ok,有了模拟mvc的MockMvc
实例之后,就可以在启动的web模拟环境中借助MockMvc
实例来发送和测试API请求了。看下测试用例:
package com.juan.demo.api;
import ...
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(CartAPI.class)
class CartAPITest {
@Resource
private MockMvc mvc;
@MockBean
private CartService cartService;
@SneakyThrows
@Test
void addCartItem() {
assertNotNull(mvc);
this.mvc.perform(post("/personal/cart")
.accept(MediaType.APPLICATION_JSON_VALUE)
.contentType(MediaType.APPLICATION_JSON_VALUE)
.content("{\"p\":2,\"q\":4}"))
.andExpect(status().isOk());
}
}
从测试API支持的连缀写法,以及静态方法的包裹调用,一目了然,这里我们发送了一个路径为/personal/cart
的添加购物车API请求,发送和接收的都是json格式,发送的数据硬编码的json字符串,期望返回的http状态码为200
。注意,这里perform
方法会抛出异常,为了简化处理我们在测试用例上加了@SneakyThrows
注解。
启动单元测试,测试ok,从控制台输出的日志可以看出,CartService
的addCartItem
方法返回的结果为空:
为什么会这样呢?明明我们在CartService
的实现中对这种输入情况会抛出异常。
要注意,我们这里用@MockBean
注解注入的其实是一个CartService
的模拟代理实现,我们需要告诉模拟的代理对象,什么情况下返回什么样的结果。
假定条件下的测试
为此,我们给定这样的条件:当接收的购物车添加项,商品id为5
且数量为4
时,返回的购物车列表中有一个商品id为5
的条目,且总的添加数量为20
。
// 静态导入
import static org.mockito.BDDMockito.given;
...
@Test
void addCartItem() {
...
given(this.cartService.addCartItem(new CartItemDTO(5L, 4)))
.willReturn(List.of(new CartItemDTO(5L, 20)));
// 执行mockMvc调用代码省略
...
}
基于这样的预设,现在我们调整下执行单元测试发送的json内容,为了方便设值,这里我们构造一个CartItemDTO
对象,并使用注入的ObjectMapper
实例对其进行json序列化处理后,传给测试API的content(...)
方法调用:
package com.juan.demo.api;
import ...
...
class CartAPITest {
...
@Resource
private ObjectMapper objectMapper;
...
void addCartItem() {
...
String jsonStr = objectMapper.writeValueAsString(new CartItemDTO(5L, 4));
this.mvc.perform(...
.content(jsonStr))
...
}
}
因为这里我们发送的内容和模拟CartService
实现中给定的条件完全符合,因此测试得到这样的输出:
我们也可以对假定的条件放宽,比如只需要匹配商品id,而不用关心数量,则可以对不关心的字段值使用any()
来替换:
// 注意静态导入
import static org.mockito.ArgumentMatchers.any;
...
given(this.cartService.addCartItem(new CartItemDTO(5L, any())))
.willReturn(...);
针对执行结果的断言,我们可以做进一步的完善:
// 为方便静态导入,使用*通配符
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
...
this.mvc.perform(...)
...
.andExpect(cookie().exists("cart_data"))
.andExpect(jsonPath("status").value(0));
这里我们期望给客户端的数据中包含了写入的特定key的cookie信息,同时对响应的json内容进行检查。
MockMvcResultMatchers
类型的匹配器中提供了返回各种匹配器的静态方法以便对各种类型响应数据进行匹配,比如这里的cookie()
调用返回的CookieResultMatchers
可以对cookie信息进行匹配。而jsonPath()
很显然我们可以通过json path表达式的方式来抠取json中嵌套的节点内容进行断言。
perform结果断言
除此之外,我们还可以对MockMvc
实例的perform
方法调用返回的结果进行相关信息的获取并断言:
// 注意静态导入
import static org.assertj.core.api.Assertions.assertThat;
...
MvcResult result = this.mvc.perform(...)
...
.andReturn();
// 从响应对象中获取cookie信息
String cartData = result.getResponse().getCookie("cart_data").getValue();
// 对cookie字符串解码
String decode = URLDecoder.decode(cartData);
// 反序列化
List<CartItemDTO> cartItems = objectMapper.readValue(decode, new TypeReference<>() {});
// 断言写入cookie的结果
assertThat(cartItems.size()).isEqualTo(1);
assertThat(cartItems.get(0).getProductId()).isEqualTo(5);
assertThat(cartItems.get(0).getQuantity()).isEqualTo(20);
这里在对json字符串反序列化为对象时,我们对TypeReference
后接的泛型类型采用JDK9开始支持的钻石语法,让代码看着更简洁。
另外这里的断言,我们采用了org.assertj.core.api.Assertions
包下提供的assertThat
方法进行优雅的断言。
异常结果断言
这里我们还可以进行异常情况的测试,比如我们给定:当添加id为2
的商品时,会抛出已经下架的异常,为此我们又编写了一个测试用例:
// 通配的静态导入
import static org.junit.jupiter.api.Assertions.*;
@SneakyThrows
@Test
void addCartItemWithException() {
given(this.cartService.addCartItem(new CartItemDTO(2L, any())))
.willThrow(new BusinessException("该商品已经下架,请另拍"));
String jsonStr = objectMapper.writeValueAsString(new CartItemDTO(2L, 4));
mvc.perform(post("/personal/cart")
.accept(MediaType.APPLICATION_JSON_VALUE)
.contentType(MediaType.APPLICATION_JSON_VALUE)
.content(jsonStr))
.andExpect(status().is5xxServerError())
.andExpect(result -> assertTrue(result.getResolvedException() instanceof BusinessException))
.andExpect(result -> assertEquals("该商品已经下架,请另拍", result.getResolvedException().getMessage()))
.andExpect(jsonPath("status").value(1))
.andExpect(jsonPath("msg").value("该商品已经下架,请另拍"));
}
在该用例中,我们假定了添加购物车失败的场景,断言的结果我们判断了返回的http状态、异常的类型、异常信息以及返回的json内容。但很遗憾的是,测试代码在执行perform
时抛出了我们自定义的BusinessException
异常,后续的断言不会再被执行。
究其原因,官网文档说的很清楚:
Test with a mock environment
意思很清楚,这里mockMvc的测试方式仅仅是mock了Spring MVC层的运行环境,而非整个web容器处理请求的全流程,包括Web容器自身的Filter、Servlet组件以及控制器后面比较偏后的Spring Web模块处理流程(比如Spring Boot通过DefaultErrorAttributes
组件来处理全局错误信息的流程)。这种仅仅启用局部组件的模拟运行环境运行比较快,仅关心控制器层的测试,符合单元测试关注点分离的测试理念。
完善异常测试捕获环节
对于这里异常没有被测试框架捕获处理转成可被断言的错误信息,是因为这种处理流程上我们对抛出的异常没有做必要的处理。解决办法有两种:
-
在
BusinessException
类上加要映射的http状态... @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) public class BusinessException extends RuntimeException { ... }
显然,这种绑定死状态码的方式违背了我们当初的设计的初衷。
-
ExceptionHandler方式
这种方式来捕获处理异常,我们在之前的全局异常处理中并未采用,原因是这是属于
spring mvc
的处理方式,而我们采用的是直接扩展spring boot
的处理方式。但在mock mvc
的测试流程中,我们需要把这一环补上,以完成整个测试流程。为此,我们在test
包下创建一个异常处理类来做这件事:package com.juan.demo.web.testing.support; import ... @Slf4j @RestControllerAdvice public class TestingExceptionHandler { @ExceptionHandler(value = BusinessException.class) public Response<?> handleBusinessException(BusinessException ex) { // 抛出和打印异常堆栈 log.error(ex.getMessage(), ex); // 设置http异常响应状态 RequestContextUtil.getResponse().setStatus(ex.getStatus().value()); return Response.fail(ex); } }
这里我们只是对测试用例中抛出
BusinessException
的情况进行了捕获处理,而对于其他的异常类型也只要照葫芦画瓢的加进来即可。需要注意,这里要往http响应对象中设置http异常状态码,这里我们用之前封装的工具类。因为最终流程还要走到
RestBodyAdvice
的beforeBodyWrite
拦截处理方法中,我们需要额外判断第一个参数body
如果已经是Response
类型则,直接记录日志并返回,增加的判断处理逻辑:public Object beforeBodyWrite(Object body, ...) { ... Type type = ... ... if (type == String.class) { ... } else if (body instanceof Response<?>) { // 这种情况属于在exceptionHandler中进行错误响应对象的包装 return logResp((Response<?>) body); } ... }
最后,再执行测试,ok!
@AutoConfigureMockMvc测试方式
除了直接使用@WebMvcTest
注解,还可以使用@SpringBootTest
结合@AutoConfigureMockMvc
的方式,后者会在spring boot启动时启用mockMvc的自动配置,来模拟spring mvc的测试环境,并注入MockMvc
的bean实例。这种启动方式会对所有API实现中涉及到的依赖完成真实bean组件的注入,看下面的例子:
package com.juan.demo.api;
import ...
@SpringBootTest
// 启用mockMvc自动配置
@AutoConfigureMockMvc
class APITest {
@Resource
private ObjectMapper objectMapper;
@Resource
private MockMvc mvc;
@SneakyThrows
@Test
void addCartItem() {
String jsonStr = objectMapper.writeValueAsString(new CartItemDTO(2L, 4));
mvc.perform(post("/personal/cart")
.accept(MediaType.APPLICATION_JSON_VALUE)
.contentType(MediaType.APPLICATION_JSON_VALUE)
.content(jsonStr))
.andExpect(status().is5xxServerError())
.andExpect(result -> assertTrue(result.getResolvedException() instanceof BusinessException))
.andExpect(result -> assertEquals("该商品已经下架,无法添加到购物车", result.getResolvedException().getMessage()));
}
}
该测试用例,实际调用的是我们的CartServiceImpl
的实现,在实现逻辑中,我们加了硬编码的判断:如果是id为2
的商品,就抛出已下架的异常。
当然,必要的时候(比如某些后台服务无法访问时),我们也可以采用@MockBean
模拟注入的方式来替换掉CartService
的真实实现。
在APITest
类中引入下面代码:
@MockBean
private CartService cartService;
再运行测试看看,实际CartService
的代理实现并未返回数据。这种情况读者可以自行测试验证。
客户端测试方式
除了前面介绍的mock mvc
的方式以及结合模拟代理组件的给定条件结果进行web
层的测试外,我们还可以使用@SpringBootTest
注解来启动web容器并使用测试客户端组件来完成web单元测试,看下面的例子:
package com.juan.demo.api;
import ...
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class APIClientTest {
@Test
void addCartItem(@Autowired TestRestTemplate restTemplate) {
assertNotNull(restTemplate);
}
}
这里我们指定了启动的web容器运行在一个随机的端口,启动测试环境后,我们可以获得方法注入的TestRestTemplate
对象进行客户端连接服务器的测试。TestRestTemplate
的内部其实依赖了一个对测试友好且配置完好的RestTemplate
对象。以下是用它来发送请求和进行断言的逻辑:
...
void addCartItem(@Autowired TestRestTemplate restTemplate) {
...
ResponseEntity<Response<?>> re = restTemplate.exchange("/personal/cart", HttpMethod.POST,
new HttpEntity<>(new CartItemDTO(2L, 4), new HttpHeaders()), new ParameterizedTypeReference<>() {});
assertThat(re.getStatusCode().is5xxServerError()).isTrue();
assertThat(re.getBody().getStatus()).isEqualTo(1);
assertThat(re.getBody().getMsg()).isEqualTo("该商品已经下架,无法添加到购物车");
}
测试结果:
总结
通过对之前开发好的添加购物车api
的各种形式的web
单元测试,相信小伙伴们对此有很好的掌握了,并能在企业开发中很好的编写web
单元测试来保证rest api
开发任务的质量以及提高自测的效率。大家加油!