使用Spring-Boot、Postgres和Docker进行集成测试

docker spring boot Postgres 集成测试
2021-01-21 10:47:15
23 0 0

在本文中解释了如何使用Spring-Boot、Postgres和Docker来实现集成测试,以获取Docker映像,启动容器,使用一个或多个Docker容器运行DAOs相关测试,并在测试完成后进行处理。

集成测试的目标是验证系统不同部分之间的交互是否正常工作。

考虑这个简单的例子:

...
public class ActorDaoIT {
 
  @Autowired
  private ActorDao actorDao;
 
  @Test
  public void shouldHave200Actors() {
    Assert.assertThat(this.actorDao.count(), Matchers.equalTo(200L));       
  }
 
  @Test
  public void shouldCreateAnActor() {
    Actor actor = new Actor();
    actor.setFirstName("First");
    actor.setLastName("Last");
    actor.setLastUpdate(new Date());
 
    Actor created = this.actorDao.save(actor);
    ...
  }
...
}

此集成测试的成功运行验证了:

  • 在依赖项注入容器中找到类属性actorDao。

  • 如果存在actorDao接口的多个实现,依赖项注入容器能够分类使用哪一个。

  • 与后端数据库通信所需的凭据正确。

  • 参与者类属性正确映射到数据库列名。

  • 参与者表正好有200行。

这个微不足道的集成测试负责处理单元测试无法发现的可能问题。不过,这是有代价的,后端数据库需要启动并运行。如果集成测试使用的资源还包括消息代理或基于文本的搜索引擎,则此类服务的实例将需要正在运行且可访问。可以看出,需要额外的工作来配置和维护vm/Servers/…以便集成测试与之交互。

在本文中,我将展示并解释如何使用springboot、Postgres和Docker实现集成测试,以获取Docker映像、启动容器、使用一个或多个Docker容器运行DAOs相关测试,并在测试完成后进行处理。

要求

  • Java 8或Java 7。对于Java 7,java.version版本内部财产pom.xml文件需要相应地更新。

  • Maven 3.3.x

  • 熟悉Spring框架。

  • Docker主机,通过Docker机器或远程主机进行本地操作。

Docker图像

我将首先构建两个Docker映像,首先是一个基本Postgres Docker映像,然后是一个DVD-rental DB Docker映像,它从基本映像扩展而来,一旦容器启动,集成测试将连接到。

基地POSTGRES DOCKER图片

此图像扩展了Docker hub中包含的官方Postgres图像,并尝试创建一个数据库,将环境变量传递给run命令

以下是Dockerfile中的一个片段:

...
ENV DB_NAME dbName
ENV DB_USER dbUser
ENV DB_PASSWD dbPassword
 
RUN mkdir -p /docker-entrypoint-initdb.d
ADD scripts/db-init.sh /docker-entrypoint-initdb.d/
RUN chmod 755 /docker-entrypoint-initdb.d/db-init.sh
...

在容器启动期间,/docker entrypoint initdb.d目录中包含的Shell或SQL文件将自动运行。这导致了db的执行-初始化.sh地址:

#!/bin/bash
 
echo "Verifying DB $DB_NAME presence ..."
result=`psql -v ON_ERROR_STOP=on -U "$POSTGRES_USER" -d postgres -t -c "SELECT 1 FROM pg_database WHERE datname='$DB_NAME';" | xargs`
if [[ $result == "1" ]]; then
  echo "$DB_NAME DB already exists"
else
  echo "$DB_NAME DB does not exist, creating it ..."
 
  echo "Verifying role $DB_USER presence ..."
  result=`psql -v ON_ERROR_STOP=on -U "$POSTGRES_USER" -d postgres -t -c "SELECT 1 FROM pg_roles WHERE rolname='$DB_USER';" | xargs`
  if [[ $result == "1" ]]; then
    echo "$DB_USER role already exists"
  else
    echo "$DB_USER role does not exist, creating it ..."
    psql -v ON_ERROR_STOP=on -U "$POSTGRES_USER" <<-EOSQL
      CREATE ROLE $DB_USER WITH LOGIN ENCRYPTED PASSWORD '${DB_PASSWD}';
EOSQL
    echo "$DB_USER role successfully created"
  fi
 
  psql -v ON_ERROR_STOP=on -U "$POSTGRES_USER" <<-EOSQL
    CREATE DATABASE $DB_NAME WITH OWNER $DB_USER TEMPLATE template0 ENCODING 'UTF8';
    GRANT ALL PRIVILEGES ON DATABASE $DB_NAME TO $DB_USER;
EOSQL
  result=$?
  if [[ $result == "0" ]]; then
    echo "$DB_NAME DB successfully created"
  else
    echo "$DB_NAME DB could not be created"
  fi
fi

这个脚本主要负责创建Postgres角色和数据库,并在新创建的用户不存在的情况下将数据库权限授予他们。数据库和角色信息通过环境变量传递,如下所示:

docker run -d -e DB_NAME=db_dvdrental -e DB_USER=user_dvdrental -e DB_PASSWD=changeit asimio/postgres:latest

现在我们已经创建了一个Postgres映像,其中包含用于连接到它的数据库和角色,关于如何创建和设置数据库架构,有几个选项:

  1. 不需要其他Docker映像,因为我们已经有了数据库和凭据,集成测试本身(在其生命周期内)将负责设置模式,假设结合使用Spring的SqlScriptsTestExecutionListener和@Sql注解。

  2. 将提供一个已经设置了数据库模式的映像,这意味着数据库已经包括表、视图、触发器、函数以及种子数据。

无论选择哪个选项,我认为集成测试:

  • 不应强加测试执行顺序。

  • 应用程序外部资源应与生产环境中使用的资源紧密匹配。

  • 应该从已知状态开始,这意味着每个测试最好有相同的种子数据,这是满足bullet 1的结果。

这不是一个一刀切的解决方案,它应该取决于特定的需要,例如,如果应用程序使用的数据库是内存中的产品,那么在每个测试开始之前使用相同的容器并创建表可能会更快,而不是为每个测试启动一个新的容器。

我决定使用2nd选项,用模式和种子数据设置创建一个Docker映像。在本例中,每个集成测试都将启动一个新的容器,在这个容器中不需要创建模式,并且在容器启动期间不需要对数据(其中可能有很多数据)进行播种。下一节将介绍此选项。

DVD租赁DB POSTGRES DOCKER图片

Pagila是从MySQL的Sakila移植的DVD租赁Postgres数据库。它是用于运行本文中讨论的集成测试的数据库

我们来看看Dockerfile的相关命令:

...
VOLUME /tmp
RUN mkdir -p /tmp/data/db_dvdrental
 
ENV DB_NAME db_dvdrental
ENV DB_USER user_dvdrental
ENV DB_PASSWD changeit
 
COPY sql/dvdrental.tar /tmp/data/db_dvdrental/dvdrental.tar
# Seems scripts will get executed in alphabetical-sorted order, db-init.sh needs to be executed first
ADD scripts/db-restore.sh /docker-entrypoint-initdb.d/
RUN chmod 755 /docker-entrypoint-initdb.d/db-restore.sh
...

它用DB name和凭据设置环境变量,包括数据库的转储和从转储中还原DB的脚本被复制到/docker entrypoint initdb.d目录,正如我前面提到的,该目录将执行在其中找到的Shell和SQL脚本。

要从Postgres转储还原的脚本如下所示:

#!/bin/bash
 
echo "Importing data into DB $DB_NAME"
pg_restore -U $POSTGRES_USER -d $DB_NAME /tmp/data/db_dvdrental/dvdrental.tar
echo "$DB_NAME DB restored from backup"
 
echo "Granting permissions in DB '$DB_NAME' to role '$DB_USER'."
psql -v ON_ERROR_STOP=on -U $POSTGRES_USER -d $DB_NAME <<-EOSQL
  GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO $DB_USER;
  GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO $DB_USER;
EOSQL
echo "Permissions granted"

注意:需要授予数据库角色权限,否则SQL语句将无法执行。基本Postgres和DVD租赁映像都被设置为一旦对包含它们的存储库进行更改,便会自动在Docker Hub中构建。要在本地构建它们,请首先转到asimio / postgres的Dockerfile所在的位置并运行:

docker build -t asimio / postgres:latest。

然后转到找到asimio / db_dvdrental的Dockerfile的位置并运行:

docker build -t asimio / db_dvdrental:latest。

从数据库模式生成JPA实体

我将使用maven插件来生成JPA实体,它们不包括@Idgeneration策略,但是这是一个非常好的起点,可以节省大量的时间来手动创建pojo。中的相关部分pom.xml文件看起来像:

...
<properties>
...
  <postgresql.version>9.4-1206-jdbc42</postgresql.version>
...
</properties>
...
<plugin>
  <groupId>org.codehaus.mojo</groupId>
  <artifactId>hibernate3-maven-plugin</artifactId>
  <version>2.2</version>
    <configuration>
      <components>
        <component>
          <name>hbm2java</name>
          <implementation>jdbcconfiguration</implementation>
          <outputDirectory>target/generated-sources/hibernate3</outputDirectory>
        </component>
      </components>
      <componentProperties>
        <revengfile>src/main/resources/reveng/db_dvdrental.reveng.xml</revengfile>
        <propertyfile>src/main/resources/reveng/db_dvdrental.hibernate.properties</propertyfile>
        <packagename>com.asimio.dvdrental.model</packagename>
        <jdk5>true</jdk5>
        <ejb3>true</ejb3>
      </componentProperties>
    </configuration>
    <dependencies>
    <dependency>
      <groupId>cglib</groupId>
      <artifactId>cglib-nodep</artifactId>
      <version>2.2.2</version>
    </dependency>
    <dependency>
      <groupId>org.postgresql</groupId>
      <artifactId>postgresql</artifactId>
      <version>${postgresql.version}</version>
    </dependency>            
  </dependencies>
</plugin>
...

注意:需要授予DB role权限,否则SQL语句将无法执行。基本Postgres和DVD租赁映像都设置为在Docker Hub中自动生成,一旦对包含它们的repos进行了更改。要在本地构建它们,请首先转到asimio/postgres的Dockerfile所在的位置并运行:

docker build -t asimio / postgres:latest。

然后转到找到asimio / db_dvdrental的Dockerfile的位置并运行:

docker build -t asimio / db_dvdrental:latest。

从数据库模式生成JPA实体

我将使用maven插件来生成JPA实体,它们不包括@Id生成策略,但是这是一个非常好的起点,可以节省大量的时间来手动创建pojo。中的相关部分pom.xml文件看起来像:

...
<properties>
...
  <postgresql.version>9.4-1206-jdbc42</postgresql.version>
...
</properties>
...
<plugin>
  <groupId>org.codehaus.mojo</groupId>
  <artifactId>hibernate3-maven-plugin</artifactId>
  <version>2.2</version>
    <configuration>
      <components>
        <component>
          <name>hbm2java</name>
          <implementation>jdbcconfiguration</implementation>
          <outputDirectory>target/generated-sources/hibernate3</outputDirectory>
        </component>
      </components>
      <componentProperties>
        <revengfile>src/main/resources/reveng/db_dvdrental.reveng.xml</revengfile>
        <propertyfile>src/main/resources/reveng/db_dvdrental.hibernate.properties</propertyfile>
        <packagename>com.asimio.dvdrental.model</packagename>
        <jdk5>true</jdk5>
        <ejb3>true</ejb3>
      </componentProperties>
    </configuration>
    <dependencies>
    <dependency>
      <groupId>cglib</groupId>
      <artifactId>cglib-nodep</artifactId>
      <version>2.2.2</version>
    </dependency>
    <dependency>
      <groupId>org.postgresql</groupId>
      <artifactId>postgresql</artifactId>
      <version>${postgresql.version}</version>
    </dependency>            
  </dependencies>
</plugin>
...

注意:如果使用Java 7,postgresql.version版本需要设置为9.4-1206-jdbc41。

插件配置引用db_dvdreant.reveng.xml文件,其中包括我们希望用于生成POJO的反向工程任务的模式:

...
<hibernate-reverse-engineering>
  <schema-selection match-schema="public" />
</hibernate-reverse-engineering>

它还引用了db_dvdreent.hibernate.properties属性其中包括要连接到要从中读取架构的数据库的JDBC连接属性:

hibernate.connection.driver_class=org.postgresql.Driver
hibernate.connection.url=jdbc:postgresql://${docker.host}:5432/db_dvdrental
hibernate.connection.username=user_dvdrental
hibernate.connection.password=changeit

此时,我们只需要用db\u dvdrent db setup启动一个Docker容器,并运行Maven命令来生成pojo。要启动容器,只需运行以下命令:

docker run -d -p 5432:5432 -e DB_NAME=db_dvdrental -e DB_USER=user_dvdrental -e DB_PASSWD=changeit asimio/db_dvdrental:latest

如果在您的环境中设置了DOCKER\u HOST,请按原样运行以下Maven命令,否则执行硬代码docker.主机到可以找到Docker主机的IP:

mvn hibernate3:hbm2java -Ddocker.host=`echo $DOCKER_HOST | sed "s/^tcp:\/\///" | sed "s/:.*$//"`

JPA实体应该在target/generated sources/hibernate3生成,结果包需要复制到src/main/java

TestExecutionListener支持代码

Spring的TestExecutionListener实现与集成测试生命周期挂钩,以便在执行之前提供Docker容器。以下是此类实现的一个片段:

...
public class DockerizedTestExecutionListener extends AbstractTestExecutionListener {
...
    private DockerClient docker;
    private Set<String> containerIds = Sets.newConcurrentHashSet();
    
     @Override
     public void beforeTestClass(TestContext testContext) throws Exception {
       final DockerConfig dockerConfig = (DockerConfig) TestContextUtil.getClassAnnotationConfiguration(testContext, DockerConfig.class);
      this.validateDockerConfig(dockerConfig);
   
      final String image = dockerConfig.image();
      this.docker = this.createDockerClient(dockerConfig);
      LOG.debug("Pulling image '{}' from Docker registry ...", image);
      this.docker.pull(image);
      LOG.debug("Completed pulling image '{}' from Docker registry", image);
   
      if (DockerConfig.ContainerStartMode.ONCE == dockerConfig.startMode()) {
        this.startContainer(testContext);
      }
   
      super.beforeTestClass(testContext);
    }
   
    @Override
    public void prepareTestInstance(TestContext testContext) throws Exception {
      final DockerConfig dockerConfig = (DockerConfig) TestContextUtil.getClassAnnotationConfiguration(testContext, DockerConfig.class);
      if (DockerConfig.ContainerStartMode.FOR_EACH_TEST == dockerConfig.startMode()) {
        this.startContainer(testContext);
      }
      super.prepareTestInstance(testContext);
    }
   
    @Override
    public void afterTestClass(TestContext testContext) throws Exception {
      try {
        super.afterTestClass(testContext);
        for (String containerId : this.containerIds) {
          LOG.debug("Stopping container: {}, timeout to kill: {}", containerId, DEFAULT_STOP_WAIT_BEFORE_KILLING_CONTAINER_IN_SECONDS);
          this.docker.stopContainer(containerId, DEFAULT_STOP_WAIT_BEFORE_KILLING_CONTAINER_IN_SECONDS);
          LOG.debug("Removing container: {}", containerId);
          this.docker.removeContainer(containerId, RemoveContainerParam.forceKill());
        }
      } finally {
        LOG.debug("Final cleanup");
        IOUtils.closeQuietly(this.docker);
      }
    }
  ...
    private void startContainer(TestContext testContext) throws Exception {
      LOG.debug("Starting docker container in prepareTestInstance() to make System properties available to Spring context ...");
      final DockerConfig dockerConfig = (DockerConfig) TestContextUtil.getClassAnnotationConfiguration(testContext, DockerConfig.class);
      final String image = dockerConfig.image();
   
      // Bind container ports to automatically allocated available host ports
      final int[] containerToHostRandomPorts = dockerConfig.containerToHostRandomPorts();
      final Map<String, List<PortBinding>> portBindings = this.bindContainerToHostRandomPorts(this.docker, containerToHostRandomPorts);
   
      // Creates container with exposed ports, makes host ports available to outside
      final HostConfig hostConfig = HostConfig.builder().
        portBindings(portBindings).
        publishAllPorts(true).
        build();
      final ContainerConfig containerConfig = ContainerConfig.builder().
        hostConfig(hostConfig).
        image(image).
        build();
   
      LOG.debug("Creating container for image: {}", image);
      final ContainerCreation creation = this.docker.createContainer(containerConfig);
      final String id = creation.id();
      LOG.debug("Created container [image={}, containerId={}]", image, id);
   
      // Stores container Id to remove it for later removal
      this.containerIds.add(id);
   
      // Starts container
      this.docker.startContainer(id);
      LOG.debug("Started container: {}", id);
   
      Set<String> hostPorts = Sets.newHashSet();
   
      // Sets published host ports to system properties so that test method can connect through it
      final ContainerInfo info = this.docker.inspectContainer(id);
      final Map<String, List<PortBinding>> infoPorts = info.networkSettings().ports();
      for (int port : containerToHostRandomPorts) {
        final String hostPort = infoPorts.get(String.format("%s/tcp", port)).iterator().next().hostPort();
        hostPorts.add(hostPort);
        final String hostToContainerPortMapPropName = String.format(HOST_PORT_SYS_PROPERTY_NAME_PATTERN, port);
        System.getProperties().put(hostToContainerPortMapPropName, hostPort);
        LOG.debug(String.format("Mapped ports host=%s to container=%s via System property=%s", hostPort, port, hostToContainerPortMapPropName));
      }
   
      // Makes sure ports are LISTENing before giving running test
      if (dockerConfig.waitForPorts()) {
        LOG.debug("Waiting for host ports [{}] ...", StringUtils.join(hostPorts, ", "));
        final Collection<Integer> intHostPorts = Collections2.transform(hostPorts,
          new Function<String, Integer>() {
   
           @Override
           public Integer apply(String arg) {
             return Integer.valueOf(arg);
           }
         }
       );
       NetworkUtil.waitForPort(this.docker.getHost(), intHostPorts, DEFAULT_PORT_WAIT_TIMEOUT_IN_MILLIS);
       LOG.debug("All ports are now listening");
     }
   }
 ...
 }

此类方法beforeTestClass()、prepareTestInstance()和afterTestClass()用于管理Docker容器的生命周期:

  • beforeTestClass():在第一个测试之前只执行一次。它的目的是拉取Docker映像,并且根据测试类是否将重复使用同一个正在运行的容器,它还可能启动一个容器。

  • prepareTestInstance():在运行下一个测试方法之前调用。它的目的是根据每个测试方法是否需要启动一个新的容器,否则将重新使用在beforeTestClass()中启动的同一个正在运行的容器。

  • afterTestClass():在执行所有测试之后,只执行一次。其目的是停止并移除正在运行的容器。

为什么我不在beforeTestMethod()和afterTestMethod()TestExecutionListener的方法中实现这个功能,如果从它们的名称来判断似乎更合适的话?这些方法的问题取决于如何将信息传递回Spring以加载应用程序上下文

为了防止硬编码从Docker容器映射到Docker主机的任何端口,我决定使用随机端口,例如,在演示中,我使用了一个Postgres容器,该容器在内部侦听端口5432,但它需要映射到主机端口,以便其他应用程序连接到数据库,这个主机端口是随机选择的,并放入JVM系统属性中,如第90行所示。最后可能会出现如下系统属性:

HOST_PORT_FOR_5432=32769

这就把我们带到了下一节。

ActorDaoIT集成测试类

ActorDao没那么复杂,我们来看看:

...
@RunWith(SpringJUnit4ClassRunner.class)
@SpringApplicationConfiguration(classes = SpringbootITApplication.class)
@TestExecutionListeners({
  DockerizedTestExecutionListener.class,
  DependencyInjectionTestExecutionListener.class,
  DirtiesContextTestExecutionListener.class
})
@DockerConfig(image = "asimio/db_dvdrental:latest",
  containerToHostRandomPorts = { 5432 }, waitForPorts = true, startMode = ContainerStartMode.FOR_EACH_TEST,
  registry = @RegistryConfig(email="", host="", userName="", passwd="")
)
@DirtiesContext(classMode = ClassMode.AFTER_EACH_TEST_METHOD)
@ActiveProfiles(profiles = { "test" })
public class ActorDaoIT {
 
  @Autowired
  private ActorDao actorDao;
 
  @Test
  public void shouldHave200Actors_1() {
    Assert.assertThat(this.actorDao.count(), Matchers.equalTo(200L));       
  }
...
}

有趣的部分是用于配置的@TestExecutionListeners、@DockerConfig和@DirtiesContext注释。

  • 前面讨论的DockerizedTestExecutionListener通过@DockerConfig配置,其中包含有关Docker映像、其名称和标记的信息,它将从何处提取以及将公开的容器端口。

  • 使用DependencyInjectionTestExecutionListener,以便注入actorDao并可供测试运行。

  • DirtiesContextTestExecutionListener与@DirtiesContextannotation一起使用,使Spring在中的每个测试之后重新加载应用程序上下文类被执行。

重新加载应用程序上下文(如本演示中所述)的原因是,JDBC url根据上一节末尾讨论的Docker主机映射容器端口而变化。让我们看看用于构建数据源bean的属性文件:

---
 spring:
    profiles: test
    database:
      driverClassName: org.postgresql.Driver
    datasource:
      url: jdbc:postgresql://${docker.host}:${HOST_PORT_FOR_5432}/db_dvdrental
      username: user_dvdrentals
      password: changeit
   jpa:
     database: POSTGRESQL
     generate-ddl: false

注意到主机\端口\为\ 5432占位符?DockerizedTestExecutionListener启动Postgres DB Docker容器后,它会为\u 5432添加一个名为HOST\u PORT\u的系统属性,并带有一个随机值。当springjunit运行程序加载应用程序上下文时,它会成功地用可用的属性值替换yaml文件中的占位符。这只是因为Docker生命周期是在DockerizedTestExecutionListener的beforeTestClass()和prepareTestInstance()中管理的,其中应用程序上下文尚未加载,就像beforeTestMethod()一样。

如果每次测试都使用相同的运行容器,不需要重新加载应用程序上下文,可以删除与DirtiesContext相关的侦听器@DockerConfig的startMode可以设置为容器开始模式.ONCE所以Docker容器只能通过DockerizedTestExecutionListener的beforeTestClass()启动一次。

现在actordaobean已经创建并可用,每个单独的集成测试都照常执行。

yaml文件中还有另一个占位符,docker.主机将在下一节中讨论。

Maven插件配置

按照命名约定,integration test使用它作为后缀命名,例如ActorDaoIT,这意味着maven surefire plugin在测试阶段不会执行它,所以改用maven failsafe plugin。来自的相关部分pom.xml文件包括:

<properties>
...
  <maven-failsafe-plugin.version>2.19.1</maven-failsafe-plugin.version>
...
</properties>
...
<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-failsafe-plugin</artifactId>
  <version>${maven-failsafe-plugin.version}</version>
  <executions>
    <execution>
      <goals>
        <goal>integration-test</goal>
        <goal>verify</goal>
      </goals>
    </execution>
  </executions>
</plugin>
...

从命令行运行

除了JAVA\u HOME、M2\u HOME、PATH环境变量之外,还有一些需要设置的变量(例如在~/.bashrc中),因为Spotify的docker客户端在DockerizedTestExecutionListener中使用这些变量。

export DOCKER_HOST=172.16.69.133:2376
export DOCKER_MACHINE_NAME=osxdocker
export DOCKER_TLS_VERIFY=1
export DOCKER_CERT_PATH=~/.docker/machine/certs

一旦找到这些变量,就可以使用以下方法构建和测试演示:

mvn verify -Ddocker.host=`echo $DOCKER_HOST | sed "s/^tcp:\/\///" | sed "s/:.*$//"`

docker.host作为VM参数传递(仅DOCKER\u主机IP),并在Spring JUnit runner为每个测试创建应用程序上下文时替换。

从Eclipse运行

如前一节所述,DOCKER_*环境变量和docker.主机VM参数需要传递给Eclipse中的测试类,实现这一点的方法是在“运行配置”对话框中设置它们->环境选项卡:

使用Spring-Boot、Postgres和Docker进行集成测试

和运行配置对话框->参数选项卡

使用Spring-Boot、Postgres和Docker进行集成测试

并运行JUnit测试通常是这样就这样,享受吧!我将非常感谢您的反馈,我很乐意为您解答。

作者介绍

用微信扫一扫

收藏