Spring Boot 中内置的测试容器(Testcontainers)

1、概览

在本教程中,我们将讨论 Spring Boot 3.1 中引入的增强型 Testcontainers 支持。

这个更新提供了一种更简化的配置容器的方法,并允许我们启动它们进行本地开发。因此,使用 Testcontainers 进行开发和运行测试变得更加无缝和高效。

2、SpringBoot 3.1 之前的 Testcontainers

在测试阶段,我们可以使用 Testcontainers 创建一个类似生产的环境。这样,我们就不需要模拟,就能写出与实现细节无关的高质量自动测试。

在本文的代码示例中,我们将使用一个简单的 Web 应用程序,其中包含一个 MongoDB 数据库作为持久层,并具有一个小型的 REST 接口。

@RestController
@RequestMapping("characters")
public class MiddleEarthCharactersController {
    private final MiddleEarthCharactersRepository repository;

    // constructor not shown

    @GetMapping
    public List<MiddleEarthCharacter> findByRace(@RequestParam String race) {
        return repository.findAllByRace(race);
    }

    @PostMapping
    public MiddleEarthCharacter save(@RequestBody MiddleEarthCharacter character) {
        return repository.save(character);
    }
}

在集成测试期间,我们将启动一个包含数据库服务器的 Docker 容器。由于容器暴露的数据库端口将动态分配,我们无法在 properties 文件中定义数据库 URL。因此,对于版本早于 3.1 的 Spring Boot 应用程序,我们需要使用 @DynamicPropertySource 注解才能将这些属性添加到 DynamicPropertyRegistry 中:

@Testcontainers
@SpringBootTest(webEnvironment = DEFINED_PORT)
class DynamicPropertiesIntegrationTest {
    
    @Container
    static MongoDBContainer mongoDBContainer = new MongoDBContainer(DockerImageName.parse("mongo:4.0.10"));

    @DynamicPropertySource 
    static void setProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.data.mongodb.uri", mongoDBContainer::getReplicaSetUrl);
    }

    // ...
}

对于集成测试,我们将使用 @SpringBootTest 注解在配置文件中定义的端口上启动应用程序。此外,我们还将使用 Testcontainers 来设置 environment。

最后,让我们使用 REST-assured 来执行 HTTP 请求并断言响应的有效性:

@Test
void whenRequestingHobbits_thenReturnFrodoAndSam() {
    repository.saveAll(List.of(
        new MiddleEarthCharacter("Frodo", "hobbit"),
        new MiddleEarthCharacter("Samwise", "hobbit"),
        new MiddleEarthCharacter("Aragon", "human"),
        new MiddleEarthCharacter("Gandalf", "wizzard")
    ));

    when().get("/characters?race=hobbit")
      .then().statusCode(200)
      .and().body("name", hasItems("Frodo", "Samwise"));
}

3、使用 @ServiceConnection 获取动态属性

从 SpringBoot 3.1 开始,我们可以利用 @ServiceConnection 注解来消除定义动态属性的模板代码。

首先,我们需要在 pom.xml 中加入 spring-boot-testcontainers 依赖:

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-testcontainers</artifactId>
  <scope>test</scope>
</dependency>

之后,我们可以删除注册所有动态属性的静态方法。相反,我们只需用 @ServiceConnection 对容器进行注解即可:

@Testcontainers
@SpringBootTest(webEnvironment = DEFINED_PORT)
class ServiceConnectionIntegrationTest {

    @Container
    @ServiceConnection
    static MongoDBContainer mongoDBContainer = new MongoDBContainer(DockerImageName.parse("mongo:4.0.10"));

    // ...

}

@ServiceConncetion 允许 SpringBoot 的自动配置动态注册所有需要的属性。在幕后,@ServiceConncetion 会根据容器类或 Docker 镜像名称来确定需要哪些属性。

支持此注解的所有容器和镜像的列表可在 Spring Boot 的 官方文档 中找到。

4、Testcontainers 支持本地开发

另一个令人兴奋的功能是将 Testcontainers 无缝集成到本地开发中,只需最少的配置。这一功能使我们不仅能在测试期间复制生产环境,还能用于本地开发。

要启用它,我们首先需要创建 @TestConfiguration 并将所有 Testcontainers 声明为 Spring Bean。我们还要添加 @ServiceConnection 注解,以便将应用程序无缝绑定到数据库:

@TestConfiguration(proxyBeanMethods = false)
class LocalDevTestcontainersConfig {
    @Bean
    @ServiceConnection
    public MongoDBContainer mongoDBContainer() {
        return new MongoDBContainer(DockerImageName.parse("mongo:4.0.10"));
    }
}

由于所有 Testcontainers 依赖都是以 test scope 导入的,因此我们需要从 test 包启动应用程序。因此,让我们在该包中创建一个 main() 方法,调用 java 包中的实际 main() 方法:

public class LocalDevApplication {    
    public static void main(String[] args) {
        SpringApplication.from(Application::main)
          .with(LocalDevTestcontainersConfig.class)
          .run(args);
    }
}

现在,我们可以通过这个 main() 方法在本地启动应用程序,它将使用 MongoDB 数据库。

让我们从 Postman 发送一个 POST 请求,然后直接连接到数据库,检查数据是否被正确持久化:

使用 Postman 发起请求

为了连接数据库,我们需要找到容器暴露的端口。我们可以从应用程序日志中获取,或者直接运行 docker ps 命令:

docker ps 命令

最后,我们可以使用 MongoDB 客户端,通过 URL mongodb://localhost:63437/test 连接到数据库,并查询 characters collection:

使用 MongoDB 客户端查询 characters collection

就这样,我们就可以连接并查询由 Testcontainer 启动的数据库,进行本地开发了。

5、与 DevTools 和 @RestartScope 整合

如果我们在本地开发过程中经常重启应用程序,潜在的弊端就是每次都要重启所有容器。因此,启动速度可能会变慢,测试数据也会丢失。

不过,我们可以利用 Testcontainers 与 spring-boot-devtools 的整合,在应用程序关闭时保持容器的活力。这是一项试验性的 Testcontainers 功能,它能节省宝贵的时间和测试数据,从而带来更顺畅、更高效的开发体验。

首先,让我们添加 spring-boot-devtools 依赖项:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-devtools</artifactId>
    <scope>runtime</scope>
    <optional>true</optional>
</dependency>

现在,我们可以返回本地开发的测试配置,并用 @RestartScope 注解,注解 Testcontainers Bean:

@Bean
@RestartScope
@ServiceConnection
public MongoDBContainer mongoDBContainer() {
    return new MongoDBContainer(DockerImageName.parse("mongo:4.0.10"));
}

或者,我们也可以使用 Testcontainer API 的 withReuse(true) 方法:

@Bean
@ServiceConnection
public MongoDBContainer mongoDBContainer() {
    return new MongoDBContainer(DockerImageName.parse("mongo:4.0.10"))
      .withReuse(true);
}

因此,我们现在可以从测试包中的 main() 方法启动应用程序,并利用 spring-boot-devtools 的实时重载功能。例如,我们可以从 Postman 中保存一个条目,然后重新编译并重新加载应用程序:

使用 Postman 中保存一个条目

让我们引入一个小改动,比如将 request mapping 从 characters 改为 api/characters,然后重新编译:

request mapping 从 characters 改为 api/characters

我们可以从应用程序日志或 Docker 本身看到,数据库容器并未重启。不过,让我们更进一步,检查应用程序是否在重启后重新连接到同一个数据库。例如,我们可以在新路径下发送 GET 请求,并期待之前插入的数据会出现在那里:

在新路径下发送 GET 请求

6、总结

在本文中,我们讨论了 SpringBoot 3.1 中新的 Testcontainers 功能。我们学习了如何使用新的 @ServiceConnection 注解,它为使用 @DynamicPropertySource 和模板化的配置提供了简化的替代方案。

随后,我们通过在 test 包中创建一个额外的 main() 方法并将其声明为 Spring Bean,深入了解了如何利用 Testcontainers 进行本地开发。除此之外,与 spring-boot-devtools@RestartScope 的整合使我们能够为本地开发创建一个快速、一致和可靠的环境。


参考:https://www.baeldung.com/spring-boot-built-in-testcontainers