用Testcontainers实现SpringBoot+Docker集成测试


我们的大多数应用程序都必须与数据库,HTTP API,消息代理,SMTP服务器等进行通信......使用这些组件设置真正的测试环境非常复杂。
在某些情况下,我们可以在测试执行期间简单地模拟这些组件或具有内存中的组件。例如,H2 或HSQLDB是众所周知的在集成测试期间使用的内存数据库。但是,它们不是生产环境中使用的,我们的测试似乎没有代表性。
今天,借助Testcontainers,可以轻松利用Docker的所有功能并轻松建立连接的测试环境。

Testcontainers
Testcontainers允许我们在测试执行期间轻松操作Docker容器。它使用Docker客户端docker-java与Docker守护进程通信。它适用于大多数操作系统和环境,尽管对Windows提供了最大的支持,但我每天都使用Docker Toolbox。您可以在此处看看与你的操作系统兼容性。
当你创建一个容器,Testcontainers将尝试使用连接到Docker 守护进程,这是通过DOCKER_HOST,DOCKER_TLS_VERIFY和DOCKER_CERT_PATH环境变量实现,可以在JVM中轻松覆盖这些环境变量。

创建一个容器
容器是使用对象GenericContainer表示。可以从镜像、Dockerfile或动态创建的Dockerfile创建容器。此外,还可以从Docker Compose文件创建容器。
例如,这是一个从镜像docker.elastic.co/elasticsearch/elasticsearch:6.1.1创建的Elasticsearch服务器。

GenericContainer container = new GenericContainer("docker.elastic.co/elasticsearch/elasticsearch:6.1.1")
 .withEnv("discovery.type", "single-node")
 .withExposedPorts(9200)
 .waitingFor(
   Wait
   .forHttp("/_cat/health?v&pretty")
   .forStatusCode(200)
 );

我们可以看到使用withEnv方法向容器提供环境变量相当容易。在这个案例中,设置了变量discovery.type是单节点。
接下来,我们通过对/_cat/healthAPI 进行HTTP调用并具有200代码响应来确保我们的容器已启动。
还有其他策略断言容器正在运行:

  • Wait.forLogMessage等待日志消息,
  • Wait.forListeningPort等待侦听端口
  • Wait.forHealthcheck允许使用docker中的HEALTHCHECK功能。

要完成容器配置,我们的容器将公开内部端口9200,并使用该方法显式设置withExposedPorts。这意味着Testcontainers会将此容器的端口映射到随机端口。可以使用该方法检索映射端口,getMappedPort否则我们可以使用该方法定义端口绑定setPortBindings。在这里,我们将端口9200从容器暴露到端口9200:

container.setPortBindings(Arrays.asList(“9200:9200”));

我们的Elasticsearch服务器已准备好使用。要启动它,我们只需要执行start方法:

container.start();

在启动时,Testcontainers将运行一系列检查,如docker版本或与已注册Docker Registry的连接。如果您在公司代理后面工作,这可能会阻塞,因此可以通过使用以下内容在tests资源目录中创建文件testcontainers.properties来禁用这些检查:

check.disable=true

最后,我们可以使用stop方法停止我们的容器。

container.stop();

这将停止容器并移除附加的卷。这很棒,因为它可以防止悬空卷。

在测试期间
Testcontainers的一大优势在于它与JUnit框架的集成。实际上,GenericContainer对象是JUnit规则。这意味着它们的生命周期直接与测试生命周期绑定。因此,通过使用@Rule或@ClassRuleJUnit注释,我们的容器将在测试启动之前初始化,并在测试执行结束时停止。

@ClassRule
public static GenericContainer redis = new GenericContainer("redis:3.0.2")
 .withExposedPorts(6379);

尽管如此,这意味着Testcontainers将带有JUnit 4依赖项,如果您的测试使用JUnit 5运行,则会很烦人。实际上,JUnit已经用Extension扩展了Rule规则。从2018年11月发布的1.10.0版本开始,Testcontainers 现在支持JUnit 5,并且可以在专用库junit-jupiter 的帮助@Testcontainers和@Container注释中使用扩展:

<dependency>
 <groupId>testcontainers</groupId>
 <artifactId>junit-jupiter</artifactId>
 <version>1.10.2</version>
</dependency>

预配置的容器
像Docker一样,Testcontainers生态系统非常丰富。您可以找到预配置的容器,如MySQL,PostgreSQL,Oracle数据库,Kafka,Neo4j,Elasticsearch等。

@Rule
public KafkaContainer kafka = new KafkaContainer();

您可以直接从maven存储库浏览列表。

具体案例
让我们看一下使用Spring PetClinic应用程序使用Testscontainers的具体示例。这是一个基于Spring Boot,Spring MVC和Spring JPA等几个Spring组件的演示项目。该应用程序旨在管理宠物诊所与宠物,宠物主人和兽医。
控制器层公开HTTP端点以创建和读取实体。然后,持久层与关系数据库通信。可以将应用程序配置为与HSQLDB或MySQL数据库通信。
持久层使用集成测试进行测试,它们使用内存中的HSQL数据库,而持久层本身使用MySQL数据库。

要求
首先,我们必须在要执行测试的机器上安装Docker。然后,我们需要将Testcontainers依赖项添加到项目中。在这种情况下,我们只需将以下内容添加到pom.xml文件中:

<dependency>
  <groupId>org.testcontainers</groupId>
  <artifactId>testcontainers</artifactId>
  <version>1.10.2</version>
  <scope>test</scope>
</dependency>

数据库配置
默认数据库配置在application.properties文件中完成。

database=hsqldb
spring.datasource.schema=classpath*:db/${database}/schema.sql
spring.datasource.data=classpath*:db/${database}/data.sql

我们可以看到,这是一个使用schema.sql文件中的模式初始化的内存中HSQLDB数据库。然后,使用data.sql文件填充数据库。这是默认的项目配置。
我们需要创建application-test.properties文件来配置与MySQL数据库的连接。

spring.datasource.url=jdbc:mysql://localhost/petclinic
spring.datasource.username=petclinic
spring.datasource.password=petclinic
spring.datasource.driver-class-name=com.mysql.jdbc.Driver

接下来,让我们参加测试类ClinicServiceTests.java。此类包含持久层的所有集成测试。首先,我们需要更改Spring测试配置以确保测试将使用我们的数据库连接。

@RunWith(SpringRunner.class)
@DataJpaTest(includeFilters = @ComponentScan.Filter(Service.class))
@TestPropertySource(locations="classpath:application-test.properties")
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
public class ClinicServiceTests {
...
}

TestPropertySource注释能够载入我们的文件application-test.properties和AutoConfigureTestDatabase与NONE值可防止Spring创建一个嵌入式数据库。

MySQL容器
让我们创建一个匹配测试要求的MySQL数据库。在这种情况下,我们使用Testcontainers的功能从动态创建的Dockerfile创建Docker镜像。作为第一步,我们从Docker Hub中提取了MySQL官方图像

@ClassRule
public static GenericContainer mysql = new GenericContainer(
  new ImageFromDockerfile("mysql-petclinic")
    .withDockerfileFromBuilder(dockerfileBuilder -> {
       dockerfileBuilder.from("mysql:5.7.8")
    }
);

现在,我们必须创建我们的数据库和连接的用户。这是通过使用Docker镜像中的环境变量来完成的。

@ClassRule
public static GenericContainer mysql = new GenericContainer(
  new ImageFromDockerfile("mysql-petclinic")
   .withDockerfileFromBuilder(dockerfileBuilder -> {
      dockerfileBuilder.from("mysql:5.7.8")
      // root password is mandatory
      .env("MYSQL_ROOT_PASSWORD", "root_password")
      .env("MYSQL_DATABASE", "petclinic")
      .env("MYSQL_USER", "petclinic")
      .env("MYSQL_PASSWORD", "petclinic")
 })

接下来,我们必须创建一个数据库模式并填充数据库。镜像文件中的目录/docker-entrypoint-initdb.d在启动时被扫描,所有带有 .sh ,.sql 或  .sql.gz扩展名的文件都被执行  。所以,我们只要把我们的文件schema.sql文件和data.sql此目录中。

@ClassRule
public static GenericContainer mysql = new GenericContainer(
  new ImageFromDockerfile("mysql-petclinic")
    .withDockerfileFromBuilder(dockerfileBuilder -> {
      dockerfileBuilder.from("mysql:5.7.8")
     .env("MYSQL_ROOT_PASSWORD", "root_password")
     .env("MYSQL_DATABASE", "petclinic")
     .env("MYSQL_USER", "petclinic")
     .env("MYSQL_PASSWORD", "petclinic")
     .add("a_schema.sql", "/docker-entrypoint-initdb.d")
     .add("b_data.sql", "/docker-entrypoint-initdb.d");
 })
 .withFileFromClasspath("a_schema.sql", "db/mysql/schema.sql")
 .withFileFromClasspath("b_data.sql", "db/mysql/data.sql"))

通过使用withClasspathResourceMapping,文件schema.sql文件和data.sql被放置在类路径从而进入容器作为它的一个卷。然后,我们可以在我们的Dockerfile构造中访问它。
最后一件事,我们必须公开默认的MySQL端口:3306。

@ClassRule
public static GenericContainer mysql = new GenericContainer(
  new ImageFromDockerfile("mysql-petclinic")
    .withDockerfileFromBuilder(dockerfileBuilder -> {
      ....
    })
  .withExposedPorts(3306)
  .withCreateContainerCmdModifier(
    new Consumer<CreateContainerCmd>() {   
     @Override
     public void accept(CreateContainerCmd createContainerCmd) {
       createContainerCmd.withPortBindings(
         new PortBinding(Ports.Binding.bindPort(3306), new ExposedPort(3306))
       );
    }
 })
 .waitingFor(Wait.forListeningPort());

不幸的是,我们无法直接使用该方法设置端口绑定setPortBindings。我们必须在创建时使用withCreateContainerCmdModifier方法自定义容器。最后,我们正在等待监听端口以确保我们的容器已启动。

瞧!只需几行代码,我们就可以轻松地为我们的测试设置MySQL数据库,而无需管理容器生命周期。该@ClassRule注释使我们的容器,所有的测试启动一次。您可能想知道:我们是否延长了测试执行时间?实际上,使用HSQLDB内存数据库时,Docker容器只需要907毫秒,而860毫秒。本节中显示的源代码可在github上找到