从零开始 Spring Boot 44:Test
图源:简书 (jianshu.com)
本篇文章我们讨论如何在 Spring 项目中编写测试用例。
当前使用的是 Spring 6.0,默认集成 JUnit 5。
依赖
Spring Boot 的测试功能需要以下依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
通过工具构建 Spring Boot 项目时该依赖都会自动添加,一般不需要手动添加。
简单测试用例
我们从最简单的测试用例开始。
假设我们的 Spring 项目中有这样一个 bean:
@Component
public class FibonacciUtil {
public int doFibonacci(int n) {
if (n <= 0) {
throw new IllegalArgumentException("n 不能小于等于0");
}
if (n <= 2) {
return 1;
}
return doFibonacci(n - 1) + doFibonacci(n - 2);
}
}
这个 bean 很简单,且没有任何其他依赖。因此我们可以用最简单的方式编写测试用例:
package com.example.test;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
public class TestFibonacciUtil {
@Test
void testFibonacci() {
FibonacciUtil util = new FibonacciUtil();
int[] fibonacci = new int[]{1, 1, 2, 3, 5, 8, 13, 21};
for (int i = 0; i < fibonacci.length; i++) {
Assertions.assertEquals(fibonacci[i], util.doFibonacci(i + 1));
}
int[] errorIndex = new int[]{0, -1, -2};
for (int ei : errorIndex) {
var exp = Assertions.assertThrows(IllegalArgumentException.class, () -> util.doFibonacci(ei));
Assertions.assertEquals("n 不能小于等于0", exp.getMessage());
}
}
}
这里用 Junit 的@Test
注解标记testFibonacci
方法是一个测试用例。在测试用例中,使用Assertions.assertXXX
方法执行测试。具体使用了两种断言:
assertEquals
,判断执行结果是否与目标相等。assertThrows
,判断执行后是否会产生一个指定类型的异常。
就像示例中的那样,Junit 的断言方法都是Assertions
类的静态方法,因此也可以用以下这样的"简写"方式:
// ...
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
public class TestFibonacciUtil {
@Test
void testFibonacci() {
// ...
assertEquals(fibonacci[i], util.doFibonacci(i + 1));
// ...
var exp = assertThrows(IllegalArgumentException.class, () -> util.doFibonacci(ei));
assertEquals("n 不能小于等于0", exp.getMessage());
}
}
上下文
但对于存在依赖注入的情况,要想编写测试用例就可能变得复杂,比如有这么一个 Service:
@Service
public class FibonacciService {
@Autowired
private FibonacciUtil fibonacciUtil;
public int fibonacci(int n) {
return fibonacciUtil.doFibonacci(n);
}
}
为其编写测试用例:
public class TestFibonacciService {
private static FibonacciService fibonacciService = new FibonacciService();
@SneakyThrows
@BeforeAll
public static void init() {
var cls = FibonacciService.class.getDeclaredField("fibonacciUtil");
cls.setAccessible(true);
cls.set(fibonacciService, new FibonacciUtil());
}
@Test
public void testFibonacci() {
int[] fibonacciArr = new int[]{1, 1, 2, 3, 5, 8};
for (int i = 0; i < fibonacciArr.length; i++) {
Assertions.assertEquals(fibonacciArr[i], fibonacciService.fibonacci(i + 1));
}
int[] errorIndexes = new int[]{0, -1, -2};
for (int ei : errorIndexes) {
Assertions.assertThrows(IllegalArgumentException.class, () -> fibonacciService.fibonacci(ei));
}
}
}
为了能够处理依赖关系,这里不得不通过反射添加了FibonacciService
的fibonacciUtil
属性。
如果是通过构造器注入或者 Setter 注入,这里的处理会简单很多。
如果能够为我们的测试用例生成所需的上下文(Context),那岂不是可以使用“自动连接”来完成注入?
@ContextConfiguration
实际上的确如此,通过@ContextConfiguration
我们可以完成类似的目的:
@ExtendWith(SpringExtension.class)
@ContextConfiguration(classes = {FibonacciUtil.class, FibonacciService.class})
public class TestFibonacciService4 {
@Autowired
private FibonacciService fibonacciService;
// ...
}
这里为@ContextConfiguration
的classes
属性添加了两个组件类型:FibonacciUtil.class
和FibonacciService.class
,利用这两个组件类型就可以完成对FibonacciService
的“自动连接”。
所谓的“组件类型”就是作为 bean 定义的类型,包括
@Configuration
、@Component
、@Service
等标记的类(用@SpringBootApplication
标记的入口类同样属于,因为@SpringBootApplication
包含了@Configuration
注解)。
此外,这里的@ExtendWith
注解是 Junit5 提供的用来扩展 Junit 功能的注解,而SpringExtension
类正是 Spring 扩展 Junit 的类,它通过覆盖 Junit 的相关接口(比如beforeAll
)扩展了相应的功能。换言之,用@ExtendWith(SpringExtension.class)
标记的测试类,其中的@BeforeAll
等 Junit 相关注解在执行时将执行 Spring 扩展后的相关代码。
如果使用的是 JUnit4,需要用
@RunWith(SpringRunner.class)
来集成 Spring 扩展。
当然,我们指定 Spring Boot 的入口类来导入所有的组件类别(组件的自动扫描功能),而不是具体的某几个:
@ExtendWith(SpringExtension.class)
@ContextConfiguration(classes = {TestApplication.class})
public class TestFibonacciService5 {
@Autowired
private FibonacciService fibonacciService;
// ...
}
除了通过classes
属性指定组件类,还可以通过locations
属性指定 XML 配置文件来加载上下文。如果既没有指定classes
,也没有指定locations
,Spring 会查找测试类中的内嵌配置类来生成上下文:
@ExtendWith(SpringExtension.class)
@ContextConfiguration
public class TestFibonacciService6 {
@Configuration
public static class Config {
@SneakyThrows
@Bean
FibonacciService fibonacciService() {
FibonacciService fibonacciService = new FibonacciService();
return fibonacciService;
}
@Bean
FibonacciUtil fibonacciUtil(){
return new FibonacciUtil();
}
}
// ...
}
这里的内嵌类Config
,实际上和平时我们编写的 Spring 配置类的功能是相同的,所以只要在Config
中提供测试所需的 bean 的工厂方法,就可以生成相应的上下文(ApplicationContext),并且在测试类中完成自动连接。
需要注意的是,这里的内嵌类
Config
不能是private
的。
@SpringJUnitConfig
实际上@SpringJUnitConfig
就是以下两个注解的组合注解:
@ExtendWith(SpringExtension.class)
@ContextConfiguration
因此之前的示例可以改写为:
@SpringJUnitConfig
public class TestFibonacciService7 {
// ...
}
此外,@SpringJUnitConfig
也包含locations
和classes
属性,是@ContextConfiguration
相应属性的别名。
@SpringBootTest
通常,使用我们前面介绍的上下文就可以测试 Spring 项目中绝大多数的功能,但有时候我们需要测试“更完整的功能”或是模拟“更真实”的运行情况。
此时就需要借助@SpringBootTest
注解编写测试用例。
可以用@SpringBOotTest
改写之前的示例:
@SpringBootTest(classes = {TestApplication.class})
public class TestFibonacciService2 {
@Autowired
private FibonacciService fibonacciService;
// ...
}
@SpringBootTest
同样需要指定测试相关的组件类型来生成上下文,但实际上更常见的是缺省classes
和locations
属性,让其自动检测以获取入口文件:
@SpringBootTest
public class TestFibonacciService2 {
// ...
}
此时 Spring 会按照目录结构查找用@SpringBootApplication
或@SpringBootConfiguration
标记的类(通常找到的是 Spring 的入口类)。
调用 main 方法
通常我们的入口类中的 main
方法是很简单的:
@SpringBootApplication
public class TestApplication {
public static void main(String[] args) {
SpringApplication.run(TestApplication.class, args);
}
}
但有时候可能会添加一些额外代码,比如添加特殊的事件监听:
@SpringBootApplication
public class TestApplication {
public static void main(String[] args) {
SpringApplication application = new SpringApplication(TestApplication.class);
application.addListeners((ApplicationListener<ApplicationStartedEvent>) event -> {
System.out.println("ApplicationStartingEvent is called.");
});
application.run(args);
}
}
默认情况下@SpringBootApplication
编写的测试用例启动后并不会执行入口类的main
方法,而是直接利用入口类创建一个新对象并运行。
因此在下面这个测试用例中不会看到任何事件监听器的输出:
@SpringBootTest
public class TestFibonacciService8 {
// ...
}
可以通过修改useMainMethod
属性改变这一行为:
@SpringBootTest(useMainMethod = SpringBootTest.UseMainMethod.ALWAYS)
public class TestFibonacciService9 {
// ...
}
此时测试用例将通过main
方法启动SpringApplication
实例,所以可以看到相关监听器的输出。
useMainMethod
属性有以下可选的值:
ALWAYS
,总是通过main
方法启动,如果缺少main
方法,就报错。NEVER
,不使用main
方法启动,改为使用一个专门用于测试的SpringApplication
实例。WHEN_AVAILABLE
,如果入口类有main
方法,就通过main
方法启动,否则使用测试专用的SpringApplication
实例启动。
@TestConfiguration
有时候,你可能需要在测试时加载一些“专门为测试添加的 bean”,此时可以使用@TestConfiguration
:
@SpringBootTest(useMainMethod = SpringBootTest.UseMainMethod.ALWAYS)
public class TestFibonacciService10 {
@Autowired
private String msg;
@TestConfiguration
static class Config{
@Bean String msg(){
return "hello";
}
}
@Test
void testMsg(){
Assertions.assertEquals("hello", msg);
}
// ...
}
此时,上下文中除了 SpringApplication 启动后利用自动扫描加载的 bean 以外,还将测试类中@TestConfiguration
标记的内部类工厂方法返回的 bean 也加载到了上下文。
除了这种内部类以外,也可以单独定义类并使用@Import
导入:
@TestConfiguration
public class MyTestConfig {
@Bean
String msg(){
return "hello";
}
}
@SpringBootTest(useMainMethod = SpringBootTest.UseMainMethod.ALWAYS)
@Import(MyTestConfig.class)
public class TestFibonacciService11 {
// ...
}
此外,使用@TestConfiguration
标记的类并不会被自动扫描识别和添加。
使用应用参数
如果应用启动时会添加某些参数,并且你希望针对这点进行测试,可以:
@SpringBootTest(args = "--app.test=one")
public class TestApplicationArgs {
@Test
void testArgs(@Autowired ApplicationArguments arguments){
Assertions.assertTrue(arguments.getOptionNames().contains("app.test"));
Assertions.assertTrue(arguments.getOptionValues("app.test").contains("one"));
}
}
使用模拟环境测试
默认情况下,@SpringBootTest
不会启动 Nginx 服务器,而是通过一个模拟环境进行测试。
在这种情况下,我们可以使用MockMVC
测试我们的 Web 请求:
@SpringBootTest
@AutoConfigureMockMvc
public class TestFibonacciController {
@Test
void testFibonacci(@Autowired MockMvc mockMvc) throws Exception {
ObjectMapper mapper = new ObjectMapper();
int[] fibonacciArr = new int[]{1, 1, 2, 3, 5, 8};
for (int i = 0; i < fibonacciArr.length; i++) {
int n = i + 1;
var targetResultStr = mapper.writeValueAsString(Result.success(fibonacciArr[i]));
mockMvc.perform(MockMvcRequestBuilders.get("/fibonacci/" + n))
.andExpect(MockMvcResultMatchers.status().isOk())
.andExpect(MockMvcResultMatchers.content().json(targetResultStr, false));
}
}
}
需要注意的是,要使用 MockMVC,需要用@AutoConfigureMockMvc
开启相关功能,否则无法注入MockMvc
实例。
关于MockMVC 的更多介绍,见 Spring 官方文档。
运行服务器并测试
如果你需要真正地运行服务并测试网络请求,可以:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class TestFibonacciController2 {
@Test
void testFibonacci(@Autowired WebTestClient client) throws Exception {
ObjectMapper mapper = new ObjectMapper();
int[] fibonacciArr = new int[]{1, 1, 2, 3, 5, 8};
for (int i = 0; i < fibonacciArr.length; i++) {
int n = i + 1;
var targetResultStr = mapper.writeValueAsString(Result.success(fibonacciArr[i]));
client.get().uri("/fibonacci/" + n)
.exchange()
.expectStatus().isOk()
.expectBody().json(targetResultStr, false);
}
}
}
这里通过@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
,可以启动服务并随机监听一个端口用于测试网络请求和响应。
为了方便地调用HTTP客户端,这里注入了一个WebTestClient
,这是一个用 Spring-webflux 实现的 Http 客户端。要使用这个客户端,需要添加依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
如果你因为某些原因不愿意为了测试加入这个依赖,可以使用TestRestTemplate
,这是 Spring 提供的一个便利组件:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class TestFibonacciController3 {
@Test
void testFibonacci(@Autowired TestRestTemplate testRestTemplate) {
int[] fibonacciArr = new int[]{1, 1, 2, 3, 5, 8};
for (int i = 0; i < fibonacciArr.length; i++) {
int n = i + 1;
var body = testRestTemplate.getForObject("/fibonacci/" + n, Result.class);
Assertions.assertEquals(body, Result.success(fibonacciArr[i]));
}
}
}
还可以使用固定端口进行测试:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT)
public class TestFibonacciController4 {
// ...
}
此时会使用默认的8080
端口或者application.properties
中定义的server.port
。
webEnvironment
属性有以下可选值:
MOCK
,使用模拟环境。RANDOM_PORT
,使用随机端口运行服务。DEFINED_PORT
,使用固定端口运行服务。NONE
,使用SpringApplication
加载一个ApplicationContext
,但不提供任何Web环境。
自动配置的测试
有时候,并不需要对全部的功能进行测试,只希望对其中的一部分测试(比如 redis、数据库、MVC等),对此 Spring 提供了一个spring-boot-test-autoconfigure
模块,该模块包含一系列的@xxxTest
注解,以及@AutoConfigureXXX
注解,利用这些注解可以仅加载相关的组件进行测试。
具体见官方文档。
MockBean
有时候,在测试中你需要“模拟”某些组件,比如说某个功能依赖于远程调用,但是该功能尚未完成开发:
@Service
public class RemoteServer {
public enum Weather{
RAIN,
SUNNY,
CLOUDY
}
/**
* 通过远程服务查询当前天气
* 该接口还未完成
* @return
*/
public Weather getWeather(){
return null;
}
}
@Service
public class UserService {
@Autowired
private RemoteServer remoteServer;
/**
* 生成一段用户欢迎信息
*
* @return
*/
public String getHelloMsg() {
var weather = remoteServer.getWeather();
var msg = new StringBuilder();
switch (weather) {
case RAIN -> msg.append("今天有雨,记得带伞。");
case CLOUDY -> msg.append("今天多云,可以出去浪。");
case SUNNY -> msg.append("阳关有点强烈,记得防晒喔。");
default -> msg.append("我也不清楚天气状态,自己看天气预报吧。");
}
return msg.toString();
}
}
这样的情形下我们可以“假设”未完成的方法调用会返回某个值来进行测试:
@SpringBootTest
public class TestUserService {
@Autowired
private UserService userService;
@MockBean
private RemoteServer remoteServer;
@Test
void testGetHelloMsg() {
BDDMockito.given(this.remoteServer.getWeather()).willReturn(RemoteServer.Weather.RAIN);
var msg = userService.getHelloMsg();
Assertions.assertEquals("今天有雨,记得带伞。", msg);
}
}
在上面这个示例中,用@MockBean
标记充当“测试桩”的 bean,且用BDDMockito.given(...).willReturen(...)
的方式为其getWeather
方法指定了一个固定返回值。上下文中的RemoteServer
bean 将被我们这里设置的这个测试桩 bean 取代,因此注入UserService
并调用userService.getHelloMsg()
时,会得到我们想要的结果。
The End,谢谢阅读。
本文的完整示例代码可以从这里获取。
参考资料
- JUnit 5 User Guide
- JUnit 5自定义扩展 - 知乎 (zhihu.com)
- Testing in Spring Boot | Baeldung
- MockMVC
- WebTestClient
- Spring TestContext 框架
- Spring WebFlux :: Spring Framework
- Spring WebFlux 教程 - 知乎 (zhihu.com)
- SpringRunner vs MockitoJUnitRunner | Baeldung