Vert.x 导论之四:单元测试和集成测试
’Vert.x导论‘回顾
现在让我们快速回顾到目前为止在Vert.x导论系列中我们开发了些什么。在第一篇帖子中,我们开发了一个非常简单的Vert.x 3应用,并且学习了这个应用如何被测试,打包及执行。在第二篇帖子中,我们学习了这个应用如何可配置,并在测试中采用了随机端口。最后,在上一篇帖子中,展示了如何使用vertx-web以及如何实现一个小型的REST API。然而,我们忘记了一个重要任务。我们没有测试新增的API。在这篇帖子中,我们会通过实现单元测试以及集成测试来增加我们对新功能的信心。
这篇帖子的代码在Introduction-to-Vert.x-Demo项目的post-4分支。起始代码在post-3分支。
测试,测试,再测试。。。
这篇帖子主要关于测试。我们区分两种测试:单元测试和集成测试。两者同等重要,但是关注点不同。单元测试确保你应用的一个组件正常工作,通常就是Java世界内的一个class行为符合预期。应用并没有作为一个整体被测试,而是一部分一部分的测试。集成测试感觉更黑盒测试因为应用通常从外部启动和测试。
在这篇帖子中,我们将从更多的单元测试起步作为热身,然后聚焦于集成测试。如果你之前实现过集成测试,你可能会被吓到,这说得通。但不用怕,用Vert.x开发没有隐藏的惊吓。
热身:更多的单元测试
我们慢慢来。在第一篇帖子里,我们用vertx-unit实现了一个单元测试。我们之前做的这个测试超级简单:
- 我们在测试前启动了应用
- 我们检验它是否以"Hello"作为响应
为了方便你回忆,让我们看看这段代码
@Before
public void setUp(TestContext context) throws IOException {
vertx = Vertx.vertx();
ServerSocket socket = new ServerSocket(0);
port = socket.getLocalPort();
socket.close();
DeploymentOptions options = new DeploymentOptions()
.setConfig(new JsonObject().put("http.port", port)
);
vertx.deployVerticle(MyFirstVerticle.class.getName(), options, context.asyncAssertSuccess());
}
setUp
方法在每次测试前都会被调用(@Before
注解指定这样操作)。这个方法首先创建一个Vert.x的新 实例,然后获取一个可用端口,最后根据对应的配置来部署我们的verticle。context.asyncAssertSuccess()
方法会一直等待直到verticle被成功部署好为止。
tearDown
方法是简单明了的,只是关闭了Vert.x实例。它自动卸载了verticles:
@After
public void tearDown(TestContext context) {
vertx.close(context.asyncAssertSuccess());
}
最终,我们的单个测试是:
@Test
public void testMyApplication(TestContext context) {
final Async async = context.async();
vertx.createHttpClient().getNow(port, "localhost", "/", response -> {
response.handler(body -> {
context.assertTrue(body.toString().contains("Hello"));
async.complete();
});
});
}
这个测试只是检测当我们对"/"地址发送一个HTTP请求时,应用是否回复了"Hello"。现在我们尝试实现一些单元测试来确认我们的web应用和REST API接口的行为是否符合预期。我们首先检查"index.html"页面是否正确工作。这个测试和之前那个测试很相似。
@Test
public void checkThatTheIndexPageIsServed(TestContext context) {
Async async = context.async();
vertx.createHttpClient().getNow(port, "localhost", "/assets/index.html", response -> {
context.assertEquals(response.statusCode(), 200);
context.assertEquals(response.headers().get("Content-Type"), "text/html");
response.bodyHandler(body -> {
context.assertTrue(body.toString().contains("<title>My Whisky Collection</title>"));
async.complete();
});
});
}
我们检索了index.html页面并检查:
- 页面存在(状态码200)
- 这是个HTML页面(Content-Type被设置为"text/html")
- 页面的标题正确("My Whisky Collection")
检索内容
如你所见,我们可以在HTTP响应上直接测试状态码和消息头,但我们需要检索消息体来确保它是正确的。这通过接受整个消息体作为参数的消息体句柄来做到的。一旦最后的检验完成,我们通过调用complete
来释放async
。
很好,但这实际上并没有测试我们的REST API。先确认我们可以在集合中增加一瓶葡萄酒。不像之前的测试,这个测试使用post
方法post数据到服务器:
@Test
public void checkThatWeCanAdd(TestContext context) {
Async async = context.async();
final String json = Json.encodePrettily(new Whisky("Jameson", "Ireland"));
final String length = Integer.toString(json.length());
vertx.createHttpClient().post(port, "localhost", "/api/whiskies")
.putHeader("content-type", "application/json")
.putHeader("content-length", length)
.handler(response -> {
context.assertEquals(response.statusCode(), 201);
context.assertTrue(response.headers().get("content-type").contains("application/json"));
response.bodyHandler(body -> {
final Whisky whisky = Json.decodeValue(body.toString(), Whisky.class);
context.assertEquals(whisky.getName(), "Jameson");
context.assertEquals(whisky.getOrigin(), "Ireland");
context.assertNotNull(whisky.getId());
async.complete();
});
})
.write(json)
.end();
}
首先我们创建我们想要添加的内容。服务器消费JSON数据,所以我们需要一个JSON字符串。你可以手工写出你的JSON文档,或者和这里一样使用Vert.x方法(Json.encodePrettily
)。一旦我们准备好了内容,我们做一个POST
请求。我们需要配置一些消息头来确保我们的JSON数据被服务器正确读取。我们表示我们在发送JSON数据并且还设置了消息体的长度。我们还附加了一个响应句柄做了类似前面测试的检测。请注意我们可以使用JSON.decodeValue
方法将服务器发送的JSON文档重构成我们需要的对象。这样做可以避免很多样板代码所以很方便。此刻,请求还没有发送,我们需要写出数据并调用end()方法。这通过 .write(json).end();
来办到。
方法的顺序很重要。如果你没有配置好响应句柄,你不能写出数据。最后不要忘记调用end()
。
你可以使用如下命令来执行测试:
mvn clean test
我们可以写更多类似这样的单元测试,但这将变得很复杂。下面将使用集成测试来继续我们的测试工作。
集成测试很伤人
我想我们首先需要明确,集成测试很折磨人。如果你在这个领域有经验,你还记得要花多久让一切事物就绪?一想起这事我就头疼。为何集成测试越来越麻烦了?主要在于安装环节:
- 我们必须以近似生产环境的方式来启动应用
- 接下来要运行测试(配置测试确保检查的是所需的应用实例)
- 最后必须停止应用
听上去并不麻烦,但如果你需要Linux,MacOS X和Windows的支持,事情很快变得凌乱起来。有很多了不起的框架可以解决这个问题比如Arquillian,但这里我们将不使用框架做集成测试,以便更好的理解工作机理。
我们需要一份战斗计划
在投入复杂的配置前,我们先花点时间确认下任务:
第一步 - 保留一个可用端口 我们需要获取一个应用可以监听的可用端口,并且我们需要将这个端口注入到集成测试中。
第二步 - 生成应用配置 一旦准备好了可用端口,我们需要写一个JSON文件配置这个端口为应用的HTTP端口
第三步 - 启动应用 听起来很容易?由于我们需要在后台进程中启动应用,所以也并不那么简单。
第四步 - 执行集成测试 最后,重点部分,运行测试。但在这之前,我们应该事先一些集成测试。我们后面将会提到。
第五步 - 停止应用 一旦测试都执行完成,无论测试中是否有失败或错误,我们需要停止应用。
有多种方式可以实现这份计划。我们打算采用一种通用的方式。这也许不是最好的,但几乎可以在任何场合使用。这种方法和Apache Maven绑的很紧。如果你想提议一种替代方案(采用Gradle或者其他工具),我很高兴能把你的方法添加到这篇帖子中。
实现这份计划
如上所说,这章节以Maven为中心,大部分代码在pom.xml文件中。如果你从未使用过不同的Maven生命周期阶段,推荐你读一下introduction to the Maven lifecycle。
我们需要添加和配置一些插件。打开pom.xml
文件,在<plugins>
部分添加:
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>build-helper-maven-plugin</artifactId>
<version>1.9.1</version>
<executions>
<execution>
<id>reserve-network-port</id>
<goals>
<goal>reserve-network-port</goal>
</goals>
<phase>process-sources</phase>
<configuration>
<portNames>
<portName>http.port</portName>
</portNames>
</configuration>
</execution>
</executions>
</plugin>
我们使用build-helper-maven-plugin
(如果你经常使用Maven你应该去了解下)来获取一个可用端口。一旦确定,这个插件将可用端口赋值给http.port
变量。我们在构建过程的早期执行这个插件(在process-sources
阶段),这样我们可以在其他插件中使用http.port变量。这是为了第一步做准备。
第二步需要执行两个动作。首先,在pom.xml
文件中,紧跟在<build>
开放标签下,添加:
<testResources>
<testResource>
<directory>src/test/resources</directory>
<filtering>true</filtering>
</testResource>
</testResources>
这里指示Maven从 src/test/resources
目录过滤资源。Filter意味着用真实值代替占位符。这正是我们所需的,现在我们有了http.port
变量。现在用如下内容来创建 src/test/resources/my-it-config.json
文件:
{
"http.port": ${http.port}
}
这个配置文件类似于我们在之前帖子中创建的那个。唯一的差别在于${http.port}
,这也是Maven过滤用的默认语法。所以,当Maven需要处理文件时,它将会用被选的端口来替换${http.port}
。这就是第二步。
第三步和第五步的处理比较麻烦。我们要启动和停止应用。我们打算用maven-antrun-plugin来办到。在pom.xml文件中,在build-helper-maven-plugin下,添加:
<!-- We use the maven-antrun-plugin to start the application before the integration tests
and stop them afterward -->
<plugin>
<artifactId>maven-antrun-plugin</artifactId>
<version>1.8</version>
<executions>
<execution>
<id>start-vertx-app</id>
<phase>pre-integration-test</phase>
<goals>
<goal>run</goal>
</goals>
<configuration>
<target>
<!--
Launch the application as in 'production' using the fatjar.
We pass the generated configuration, configuring the http port to the picked one
-->
<exec executable="${java.home}/bin/java"
dir="${project.build.directory}"
spawn="true">
<arg value="-jar"/>
<arg value="${project.artifactId}-${project.version}-fat.jar"/>
<arg value="-conf"/>
<arg value="${project.build.directory}/test-classes/my-it-config.json"/>
</exec>
</target>
</configuration>
</execution>
<execution>
<id>stop-vertx-app</id>
<phase>post-integration-test</phase>
<goals>
<goal>run</goal>
</goals>
<configuration>
<!--
Kill the started process.
Finding the right process is a bit tricky. Windows command is in the windows profile (below)
-->
<target>
<exec executable="bash"
dir="${project.build.directory}"
spawn="false">
<arg value="-c"/>
<arg value="ps ax | grep -Ei '[\-]DtestPort=${http.port}\s+\-jar\s+${project.artifactId}' | awk 'NR==1{print $1}' | xargs kill -SIGTERM"/>
</exec>
</target>
</configuration>
</execution>
</executions>
</plugin>
这里有一大堆XML。我们为这个插件配置了两个执行阶段。第一个,在pre-integration-test
阶段,执行一系列bash命令来启动应用。主要是执行:
java -jar my-first-app-1.0-SNAPSHOT-fat.jar -conf .../my-it-config.json
fatfar被创建了?
嵌入了我们应用的fatfar在package阶段被创建,在pre-integration-test
之前,所以,fatjar是被创建了。
如上,我们如在生产环境一样启动了应用。
一旦集成测试被执行了(第四步我们还没说起),我们需要停止应用(所以在post-integration-test
阶段)。为了关闭应用,我们会使用一些shell魔法命令来查找我们的进程号,会用到ps命令并发送SIGTERM
信号,这些等同于:
ps
.... -> find your process id
kill your_process_id -SIGTERM
还有Windows?
我之前提起过,我们希望支持Windows而这些命令在Windows下不工作。不用担心,Windows配置在下文会提到...
我们现在将要做之前跳过的第四步。为了执行我们的集成测试,我们将使用maven-failsafe-plugin
。将如下插件配置添加到你的pom.xml
文件中:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
<version>2.18.1</version>
<executions>
<execution>
<goals>
<goal>integration-test</goal>
<goal>verify</goal>
</goals>
<configuration>
<systemProperties>
<http.port>${http.port}</http.port>
</systemProperties>
</configuration>
</execution>
</executions>
</plugin>
如你所见,我们将http.port属性作为一个系统变量传递,这样我们的测试能够连接到正确的端口。
就这样了,现在来试试(就Windows用户而言,你必须更有耐心或直接跳到最后一节)。
mvn clean verify
我们不该使用 mvn integration-test
因为这样应用不会停止。verify
阶段在post-integration-test
阶段后,会分析集成测试的结果。由于集成测试失败造成的构建失败会在这阶段报告。
我们还没有具体的集成测试内容!
我们准备好了集成测试所需的材料,但我们还没有一个集成测试。为了简化实现,我们使用两个库:AssertJ 和Rest-Assured。
AssertJ提供很多断言,这些断言你能够链化并顺畅使用。Rest Assured是一个用来测试REST API的框架。
在pom.xml
文件中,在</dependencies>
前添加如下两个依赖:
<dependency>
<groupId>com.jayway.restassured</groupId>
<artifactId>rest-assured</artifactId>
<version>3.0.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.10.0</version>
<scope>test</scope>
</dependency>
然后创建 src/test/java/io/vertx/blog/first/MyRestIT.java
文件。不像单元测试,集成测试以IT
结束。对Failsafe插件来说,很容易区分单元测试(以Test开始结束)和集成测试(以IT开始结束)。在新增的文件中添加:
package io.vertx.blog.first;
import com.jayway.restassured.RestAssured;
import org.junit.AfterClass;
import org.junit.BeforeClass;
public class MyRestIT {
@BeforeClass
public static void configureRestAssured() {
RestAssured.baseURI = "http://localhost";
RestAssured.port = Integer.getInteger("http.port", 8080);
}
@AfterClass
public static void unconfigureRestAssured() {
RestAssured.reset();
}
}
用@BeforeClass
和@AfterClass
注解的方法在类里所有的测试之前/之后分别执行一次。这里,我们只是取回http.port(作为系统参数传入)并配置REST Assured。
是时候实现一个真的测试。让我们检测是否可以获取某个特定产品:
@Test
public void checkThatWeCanRetrieveIndividualProduct() {
// Get the list of bottles, ensure it's a success and extract the first id.
final int id = RestAssured.get("/api/whiskies").then()
.assertThat()
.statusCode(200)
.extract()
.jsonPath().getInt("find { it.name=='Bowmore 15 Years Laimrig' }.id");
// Now get the individual resource and check the content
RestAssured.get("/api/whiskies/" + id).then()
.assertThat()
.statusCode(200)
.body("name", equalTo("Bowmore 15 Years Laimrig"))
.body("origin", equalTo("Scotland, Islay"))
.body("id", equalTo(id));
}
这里你能够欣赏Rest Assured的力量和表达力。我们获取产品列表,确认响应是正确的,使用JSON(Groovy)路径表达式来提取某个特定产品的id。
然后,我们尝试获取这个产品的元数据,并检验结果。
现在实现一个更复杂的场景。添加和删除一个产品:
@Test
public void checkWeCanAddAndDeleteAProduct() {
// Create a new bottle and retrieve the result (as a Whisky instance).
Whisky whisky = RestAssured.given()
.body("{\"name\":\"Jameson\", \"origin\":\"Ireland\"}").request().post("/api/whiskies").thenReturn().as(Whisky.class);
Assertions.assertThat(whisky.getName()).isEqualToIgnoringCase("Jameson");
Assertions.assertThat(whisky.getOrigin()).isEqualToIgnoringCase("Ireland");
Assertions.assertThat(whisky.getId()).isNotZero();
// Check that it has created an individual resource, and check the content.
RestAssured.get("/api/whiskies/" + whisky.getId()).then()
.assertThat()
.statusCode(200)
.body("name", equalTo("Jameson"))
.body("origin", equalTo("Ireland"))
.body("id", equalTo(whisky.getId()));
// Delete the bottle
RestAssured.delete("/api/whiskies/" + whisky.getId()).then().assertThat().statusCode(204);
// Check that the resource is not available anymore
RestAssured.get("/api/whiskies/" + whisky.getId()).then()
.assertThat()
.statusCode(404);
}
现在我们有了集成测试,试着输入如下命令:
mvn clean verify
还蛮简单的?等环境被准备好后是蛮简单的。。。你能够继续实现其他集成测试来确保一切行为如你预期。
亲爱的Windows用户...
这一节是给Windows用户的福利,还有想在Windows机器上运行他们的集成测试的人们。之前我们执行来停止应用的命令在Windows系统上不起作用。幸运的是,我们可以用一个在Windows系统上执行的profile来扩展pom.xml。
在你的pom.xml文件中,紧跟着</build>,添加:
<profiles>
<!-- A profile for windows as the stop command is different -->
<profile>
<id>windows</id>
<activation>
<os>
<family>windows</family>
</os>
</activation>
<build>
<plugins>
<plugin>
<artifactId>maven-antrun-plugin</artifactId>
<version>1.8</version>
<executions>
<execution>
<id>stop-vertx-app</id>
<phase>post-integration-test</phase>
<goals>
<goal>run</goal>
</goals>
<configuration>
<target>
<exec executable="wmic"
dir="${project.build.directory}"
spawn="false">
<arg value="process"/>
<arg value="where"/>
<arg value="CommandLine like '%${project.artifactId}%' and not name='wmic.exe'"/>
<arg value="delete"/>
</exec>
</target>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>
</profiles>
这个profile用适用于Windows系统的版本替换了之前描述的版本来停止应用。这个profile在Windows上自动启用。和在其他操作系统上一样,执行:
mvn clean verify
如果pom.xml配置文件有
Plugin execution not covered by lifecycle configuration:
org.codehaus.mojo:build-helper-maven-plugin:1.12:reserve-network-port
(execution: reserve-network-port, phase: process-sources)
这样的报错信息。
这是因为m2e对maven的阶段支持不好造成的,具体可以参考m2e-execution-not-covered。具体修正代码如下:
<pluginManagement>
<plugins>
<plugin>
<groupId>org.eclipse.m2e</groupId>
<artifactId>lifecycle-mapping</artifactId>
<version>1.0.0</version>
<configuration>
<lifecycleMappingMetadata>
<pluginExecutions>
<pluginExecution>
<pluginExecutionFilter>
<groupId>org.codehaus.mojo</groupId>
<artifactId>build-helper-maven-plugin</artifactId>
<versionRange>[${build-helper.maven-plugin.version},)</versionRange>
<goals>
<goal>reserve-network-port</goal>
</goals>
</pluginExecutionFilter>
<action>
<ignore/>
</action>
</pluginExecution>
</pluginExecutions>
</lifecycleMappingMetadata>
</configuration>
</plugin>
</plugins>
</pluginManagement>
<article class="col-xs-12 blog-post">
<article>
结论
我们完成了...在这个帖子中,我们看到通过实现单元测试和集成测试,我们对自己的Vert.x应用更有信心了。单元测试,由于vert.x-unit,能够检测Vert.x应用的异步特性,但在复杂场景下可能太复杂。感谢Rest Assured和AssertJ,集成测试写起来简单很多...但是准备过程不够直观。这篇帖子展示了如何配置集成测试环境。很明显,你也能够在单元测试中使用AssertJ和Rest Assured。
在next post中,我们用一个数据库来取代内存后端,并和数据库进行异步集成。
敬请期待!