Spring Boot 3.x Web单元测试最佳实践

上一篇:Spring Boot 3.x Rest API统一异常处理最佳实践

下一篇:Spring Boot 3.x Filter实战:记录请求日志

Spring Boot为我们提供了非常便捷的webRest API单元测试的API,这种开发能力也是小伙伴必须要掌握的。如何对数据库、中间件服务以及远程调用在开发环境不可使用的情况进行Rest API功能测试,本教程将为小伙伴揭秘。如果觉得对你有帮助,记得点赞收藏,关注小卷,后续更精彩!

在这里插入图片描述

依赖与配置

接下来我们通过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,从控制台输出的日志可以看出,CartServiceaddCartItem方法返回的结果为空:

在这里插入图片描述

为什么会这样呢?明明我们在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组件来处理全局错误信息的流程)。这种仅仅启用局部组件的模拟运行环境运行比较快,仅关心控制器层的测试,符合单元测试关注点分离的测试理念。

完善异常测试捕获环节

对于这里异常没有被测试框架捕获处理转成可被断言的错误信息,是因为这种处理流程上我们对抛出的异常没有做必要的处理。解决办法有两种:

  1. BusinessException类上加要映射的http状态

    ...
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public class BusinessException extends RuntimeException { ... }
    

    显然,这种绑定死状态码的方式违背了我们当初的设计的初衷。

  2. 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异常状态码,这里我们用之前封装的工具类。

    因为最终流程还要走到RestBodyAdvicebeforeBodyWrite拦截处理方法中,我们需要额外判断第一个参数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开发任务的质量以及提高自测的效率。大家加油!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Java小卷

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值