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实现了一个单元测试。我们之前做的这个测试超级简单:

  1. 我们在测试前启动了应用
  2. 我们检验它是否以"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页面并检查:

  1. 页面存在(状态码200)
  2. 这是个HTML页面(Content-Type被设置为"text/html")
  3. 页面的标题正确("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

我们可以写更多类似这样的单元测试,但这将变得很复杂。下面将使用集成测试来继续我们的测试工作。

集成测试很伤人

我想我们首先需要明确,集成测试很折磨人。如果你在这个领域有经验,你还记得要花多久让一切事物就绪?一想起这事我就头疼。为何集成测试越来越麻烦了?主要在于安装环节:

  1. 我们必须以近似生产环境的方式来启动应用
  2. 接下来要运行测试(配置测试确保检查的是所需的应用实例)
  3. 最后必须停止应用

听上去并不麻烦,但如果你需要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阶段后,会分析集成测试的结果。由于集成测试失败造成的构建失败会在这阶段报告。

我们还没有具体的集成测试内容!

我们准备好了集成测试所需的材料,但我们还没有一个集成测试。为了简化实现,我们使用两个库:AssertJRest-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中,我们用一个数据库来取代内存后端,并和数据库进行异步集成。

敬请期待!

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,064评论 5 466
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,606评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,011评论 0 328
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,550评论 1 269
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,465评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 47,919评论 1 275
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,428评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,075评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,208评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,185评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,191评论 1 328
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,914评论 3 316
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,482评论 3 302
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,585评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,825评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,194评论 2 344
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,703评论 2 339

推荐阅读更多精彩内容