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

发布于:2021-01-21 10:47:15

0

227

0

docker spring boot Postgres 集成测试

在本文中解释了如何使用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

此时,我们只需要用dbu 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

如果在您的环境中设置了DOCKERu 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添加一个名为HOSTu PORTu的系统属性,并带有一个随机值。当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> ...

从命令行运行

除了JAVAu HOME、M2u 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参数传递(仅DOCKERu主机IP),并在Spring JUnit runner为每个测试创建应用程序上下文时替换。

从Eclipse运行

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

{xunruicms_img_title}

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

{xunruicms_img_title}

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