从零开始 Spring Boot 44:Test

从零开始 Spring Boot 44:Test

从零开始 Spring Boot 44:Test_第1张图片

图源:简书 (jianshu.com)

本篇文章我们讨论如何在 Spring 项目中编写测试用例。

当前使用的是 Spring 6.0,默认集成 JUnit 5。

依赖

Spring Boot 的测试功能需要以下依赖:

<dependency>
    <groupId>org.springframework.bootgroupId>
    <artifactId>spring-boot-starter-testartifactId>
    <scope>testscope>
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));
        }
    }
}

为了能够处理依赖关系,这里不得不通过反射添加了FibonacciServicefibonacciUtil属性。

如果是通过构造器注入或者 Setter 注入,这里的处理会简单很多。

如果能够为我们的测试用例生成所需的上下文(Context),那岂不是可以使用“自动连接”来完成注入?

@ContextConfiguration

实际上的确如此,通过@ContextConfiguration我们可以完成类似的目的:

@ExtendWith(SpringExtension.class)
@ContextConfiguration(classes = {FibonacciUtil.class, FibonacciService.class})
public class TestFibonacciService4 {
    @Autowired
    private FibonacciService fibonacciService;
	// ...
}

这里为@ContextConfigurationclasses属性添加了两个组件类型:FibonacciUtil.classFibonacciService.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也包含locationsclasses属性,是@ContextConfiguration相应属性的别名。

@SpringBootTest

通常,使用我们前面介绍的上下文就可以测试 Spring 项目中绝大多数的功能,但有时候我们需要测试“更完整的功能”或是模拟“更真实”的运行情况。

此时就需要借助@SpringBootTest注解编写测试用例。

可以用@SpringBOotTest改写之前的示例:

@SpringBootTest(classes = {TestApplication.class})
public class TestFibonacciService2 {
    @Autowired
    private FibonacciService fibonacciService;
    // ...
}

@SpringBootTest同样需要指定测试相关的组件类型来生成上下文,但实际上更常见的是缺省classeslocations属性,让其自动检测以获取入口文件:

@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.bootgroupId>
    <artifactId>spring-boot-starter-webfluxartifactId>
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

你可能感兴趣的:(JAVA,spring,boot,junit,test)