使用Testcontainer对Spring Boot实现集成测试


在使用容器进行测试时,Testcontainers 是多种编程语言的标准解决方案。它对 Spring 应用程序具有一流的支持。它甚至包含在众所周知的start.spring.io上作为默认测试依赖项,并被推荐为技术雷达立即采用的库。

测试 Spring Boot 应用程序自然意味着我们初始化应用程序上下文,其中创建并准备好所有 bean 层次结构并配置所有必需的集成属性。使用 Testcontainers 的集成测试将其提升到一个新的水平,这意味着我们将针对数据库的实际版本和其他依赖项运行测试,我们的应用程序需要与执行实际代码路径一起工作,而不依赖于模拟对象来削减功能的角落。

因此,让我们看看如何从本地工作的 Spring Boot 应用程序转移到使用 dockerized 服务或使用 Testcontainers 管理的数据库进行集成测试。

配置您的项目
Testcontainers 的基本设置只需要testcontainers依赖项本身,你就可以开始了。
以下是配置 maven 项目的方法:

<dependency>
  <groupId>org.testcontainers</groupId>
  <artifactId>testcontainers</artifactId>
  <version>1.17.3</version>
</dependency>

Gradle :

testImplementation 'org.testcontainers:testcontainers:1.17.3'

它将立即工作,你将能够立即管理容器,使用GenericContainer抽象来表示我们的应用程序将用于测试的服务。

@Test
  void test() throws IOException, InterruptedException {
    try (var container = new GenericContainer<>("nginx").withExposedPorts(80)) {
      container.start();
      var client = HttpClient.newHttpClient();
      var uri =
"http://" + container.getHost() + ":" + container.getFirstMappedPort();
 
      var request = HttpRequest.newBuilder(URI.create(uri)).GET().build();
      var response = client.send(request, HttpResponse.BodyHandlers.ofString());
 
      Assertions.assertTrue(response.body().contains(
"Thank you for using nginx."));
    }
  }

而且它适用于任何在Docker容器中运行的东西。在这里,我们要创建并启动一个简单的Nginx网络服务器,并暴露其默认的80端口。该库将随机选择一个本地端口,并自动转发所需的端口,所以你不需要费心去寻找一个可用的端口。然后我们启动一个容器并发出一个GET请求。

注意这个实现有几个很酷的地方:实现AutoClosable接口可以让容器在离开尝试块时自动关闭。此外,Testcontainers会在你的测试结束后停止并移除它所管理的容器,即使它们失败或崩溃,也会为你的下一次测试运行提供一个干净的板块。
另一个值得注意的细节使Testcontainers对开发者如此友好--是所有的配置都可以通过流畅的API来实现,在我们的例子中,一个发布容器端口80的调用:.withExposedPorts(80)。程序化配置使你的IDE帮助你暴露出哪些选项是可用的,并允许你微调你的服务依赖配置对于特定的测试需要的样子。

顺便说一下,暴露一个容器的端口也是以一种聪明的方式进行的,它不会自动映射到主机端相同的固定端口,这将导致在几个容器试图暴露相同价值的端口时产生冲突。相反,它将端口映射到主机上的一个随机的高值端口,你也可以通过getFirstMappedPort()方法以编程方式获得这个端口。

Testcontainers模块
尽管如此,即使运行复杂的技术,如Kafka或ElasticSearch,也是相当简单的。Testcontainers还提供了额外的模块,使事情更加简化,从测试框架的集成开始,到十几个著名的优化服务和数据库容器结束。每个模块都可以作为一个单独的依赖项,在需要时可以添加到项目中。我们可以通过使用JUnit Jupiter集成和Nginx专用容器来简化上面提到的测试案例。这需要在Maven/Gradle设置中添加提到的Testcontainers模块。下面是如何将其添加到Maven构建中的方法

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.testcontainers</groupId>
      <artifactId>testcontainers-bom</artifactId>
      <version>1.17.3</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>
 
<dependency>
  <groupId>org.testcontainers</groupId>
  <artifactId>junit-jupiter</artifactId>
  <scope>test</scope>
</dependency>
<dependency>
  <groupId>org.testcontainers</groupId>
  <artifactId>nginx</artifactId>
  <scope>test</scope>
</dependency>

而一旦添加了模块,我们就可以将上述测试简化为如下内容:

@Testcontainers
final class NginxTest {
 
    @Container
    NginxContainer<?> nginx = new NginxContainer<>("nginx");
 
    @Test
    void test() throws IOException, InterruptedException, URISyntaxException {
        var client = HttpClient.newHttpClient();
        var url = nginx.getBaseUrl(
"http", 80);
        var request = HttpRequest.newBuilder(url.toURI()).GET().build();
        var response = client.send(request, HttpResponse.BodyHandlers.ofString());
        
        Assertions.assertTrue(response.body().contains(
"Thank you for using nginx."));
    }
}

在这里,@Testcontainers注解启用了JUnit5扩展,它将自动为我们管理容器的生命周期,它可以通过将你需要的容器的生命周期与测试本身的生命周期联系起来,大大简化了单个测试。 在这个例子中,容器将为NginxTest类中的每个测试方法启动和停止。

我们将在另一篇博文中更详细地探讨这种方法。

不过要注意的是,NginxContainer为与Web服务器的交互提供了有用的帮助工具。此外,人们可以指定他们应该从哪里提供额外的内容。对于网络服务器服务的例子,这是一个相当直接的getBaseUrl("http", 80),但更复杂的模块可以通过抽象出更复杂的配置,如为你的Kafka集群启用或禁用ZooKeeper,或在飞行中产生正确的反应式JDBC尿,来大大降低认知负荷。

抽离可重用的容器设置
因此,像JUnit5集成模块或Nginx模块一样,Testcontainers提供了可插拔的架构,人们可以通过扩展GenericContainer来抽象出一些常见的服务配置。例如,这里可以创建一个自定义的容器包装器,为谷歌云存储仿真器容器隐藏容器的特定配置。

public final class GcsContainer extends GenericContainer<GcsContainer> {
 
    private static final String GCS_CONTAINER =
            "spine3/cloudstorage-emulator:eeaa4f1de686a8d4315d5c2fa2aa26fc9fa242d6";
    private static final int PORT = 9199;
 
    private GcsContainer(DockerImageName image) {
        super(image);
    }
 
   
/**
     * Creates a new instance of the GCS emulator container.
     *
     * <p>The connection port is dynamically exposed to the host and can be obtained using
     * {@link getFirstMappedPort()}.
     */

    public static GcsContainer newInstance() {
        return new GcsContainer(DockerImageName.parse(GCS_CONTAINER))
                .waitingFor(Wait.forLogMessage(
".*Listening at.*", 1))
                .withExposedPorts(PORT);
    }
}


测试容器的 Spring Boot 应用程序配置
虽然 Spring Boot 应用程序可以像任何其他 Java 应用程序一样进行测试,但 Spring 使得注入和配置被测组件更加容易。测试容器提供与 Docker 从配置到清理的所有内容的集成。剩下的唯一事情就是确保您的 Spring 应用程序知道在哪里可以找到在测试期间创建的所有这些 dockerized 服务。

基于动态属性的配置
幸运的是,Spring Boot 提供了一种简洁的机制来在测试期间配置应用程序,覆盖之前的所有静态配置:`DynamicPropertySource`。DynamicPropertySource 标记的方法在初始化开始时执行,您可以使用它将配置从动态管理的容器化服务传递到应用程序:

@SpringBootTest
@Testcontainers
public class MySQLTest {
 
    static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8");
 
    @DynamicPropertySource
    static void registerMySQLProperties(DynamicPropertyRegistry registry) {
        registry.add(
"spring.datasource.url", mysql::getJdbcUrl);
        registry.add(
"spring.datasource.username", mysql::getUsername);
        registry.add(
"spring.datasource.password", mysql::getPassword);
    }
}

在此示例中,我们使用 Testcontainers 创建了一个 MySQL 容器并配置了相关spring.datasource属性,以便应用程序在测试期间正确连接到实际数据库。 
唯一要提的是,动态属性源方法必须遵循约定,只有一个DynamicPropertyRegistry参数,使用注解进行@DynamicPropertySource注解,并且是静态的。

总结:
要使 Spring Boot 应用程序易于使用真实数据库或您的代码所依赖的其他服务进行测试,您可以使用 Testcontainers。 

有两件重要的事情需要考虑:容器配置、生命周期和清理,它们被 Testcontainers 核心库和特定技术的模块很好地抽象出来。并动态配置被测应用程序以使用 dockerized 依赖项,最简单的方法是使用@DynamicPropertySource方法。

总而言之,Spring Boot 和 testcontainers 是一个非常灵活的组合,如果你以前没有看过它,也许你应该考虑编写更多的集成测试,现在它很容易上手。 

如果您想尝试这种组合,您可以访问这个GitHub 存储库