发布于:2021-01-21 10:47:15
0
235
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映像,其中包含用于连接到它的数据库和角色,关于如何创建和设置数据库架构,有几个选项:
不需要其他Docker映像,因为我们已经有了数据库和凭据,集成测试本身(在其生命周期内)将负责设置模式,假设结合使用Spring的SqlScriptsTestExecutionListener和@Sql注解。
将提供一个已经设置了数据库模式的映像,这意味着数据库已经包括表、视图、触发器、函数以及种子数据。
无论选择哪个选项,我认为集成测试:
不应强加测试执行顺序。
应用程序外部资源应与生产环境中使用的资源紧密匹配。
应该从已知状态开始,这意味着每个测试最好有相同的种子数据,这是满足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中的测试类,实现这一点的方法是在“运行配置”对话框中设置它们->环境选项卡:
和运行配置对话框->参数选项卡
并运行JUnit测试通常是这样就这样,享受吧!我将非常感谢您的反馈,我很乐意为您解答。
作者介绍