测试与质量保障
你当前正在浏览历史版本 Vert.x 4.0.3 的文档。 访问本页面的 最新版本。

Vert.x JUnit 5 集成

本模块提供了使用 JUnit 5 编写 Vert.x 单元测试的集成与支持。

在构建中引入依赖

要使用本组件,请在项目构建描述文件的 dependencies 配置节中添加以下依赖:

  • Maven(在 pom.xml 中):

<dependency>
  <groupId>io.vertx</groupId>
  <artifactId>vertx-junit5</artifactId>
  <version>5.0.12</version>
</dependency>
  • Gradle(在 build.gradle 文件中):

compile io.vertx:vertx-junit5:5.0.12

异步代码测试为何截然不同

测试异步操作所需的工具远超 JUnit 等传统测试框架本身所能提供的范围。 让我们来看一段典型的 Vert.x HTTP 服务器创建代码,并将其放入一个 JUnit 测试中:

class ATest {
  Vertx vertx = Vertx.vertx();

  @Test
  void start_server() {
    vertx.createHttpServer()
      .requestHandler(req -> req.response().end("Ok"))
      .listen(16969).onComplete(ar -> {
        // (我们可以在此处检查服务器是否已成功启动)
      });
  }
}

这段代码存在诸多隐患,因为 listen 尝试异步启动 HTTP 服务器,本身并不会阻塞。 我们无法仅凭 listen 方法调用返回就认定服务器已经正常启动。 此外:

  1. 传递给 listen 的回调函数将在 Vert.x EventLoop 线程中执行,这与执行 JUnit 测试的主线程完全不同;

  2. 在调用 listen 后,测试方法会立即退出并被判定为通过,而此时 HTTP 服务器可能根本尚未完成启动;

  3. 由于 listen 回调与执行测试的线程不同,回调中抛出的任何异常(例如断言失败)都无法被 JUnit 运行器捕获。

用于异步执行的测试上下文

本模块的核心贡献之一是 VertxTestContext 对象,它具备以下能力:

  1. 允许等待其他线程中的异步操作通知其完成;

  2. 支持接收断言失败信息,从而将测试标记为失败。

以下是一个非常基础的使用示例:

class BTest {
  Vertx vertx = Vertx.vertx();

  @Test
  void start_http_server() throws Throwable {
    VertxTestContext testContext = new VertxTestContext();

    vertx.createHttpServer()
      .requestHandler(req -> req.response().end())
      .listen(16969)
      .onComplete(testContext.succeedingThenComplete()); (1)

    assertThat(testContext.awaitCompletion(5, TimeUnit.SECONDS)).isTrue(); (2)
    if (testContext.failed()) {  (3)
      throw testContext.causeOfFailure();
    }
  }
}
1 succeedingThenComplete 返回一个异步结果处理器,它期望操作成功并在成功后让测试上下文顺利通过。
2 awaitCompletion 具有类似于 java.util.concurrent.CountDownLatch 的语义,若在测试通过前等待超时则返回 false。
3 若上下文捕获到(潜在的异步)错误,则在完成后我们必须抛出该失败异常,以便让测试明确失败。

自由搭配任意断言库

本模块对你应该使用的断言库没有任何预设限制。 你可以使用原生的 JUnit 断言,也可以使用 AssertJ 等第三方断言库。

若要在异步代码中执行断言并确保 VertxTestContext 能够捕获到潜在的失败,需要将断言包装在对 verify、succeeding 或 failing 的调用中:

client = vertx.createHttpClient();

client.request(HttpMethod.GET, 8080, "localhost", "/")
  .compose(req -> req.send().compose(HttpClientResponse::body))
  .onComplete(testContext.succeeding(buffer -> testContext.verify(() -> {
    assertThat(buffer.toString()).isEqualTo("Plop");
    testContext.completeNow();
  })));

VertxTestContext 中常用的方法如下:

  • completeNow 与 failNow:用于显式通知操作成功或失败;

  • succeedingThenComplete:提供一个期望成功并在成功后直接完成测试上下文的 Handler<AsyncResult<T>> 处理器;

  • failingThenComplete:提供一个期望失败并在失败后直接完成测试上下文的 Handler<AsyncResult<T>> 处理器;

  • succeeding:提供一个期望成功并将结果传递给后续回调的 Handler<AsyncResult<T>> 处理器;回调中抛出的任何异常都会被视作测试失败;

  • failing:提供一个期望失败并将异常传递给后续回调的 Handler<AsyncResult<T>> 处理器;回调中抛出的任何异常都会被视作测试失败;

  • verify:用于执行断言,代码块中抛出的任何异常都会被视作测试失败。

与 succeedingThenComplete 和 failingThenComplete 不同,调用 succeeding 与 failing 方法本身只能让测试失败(例如 succeeding 接收到了一个失败的异步结果)。若要让测试顺利通过,你仍需调用 completeNow,或者按下文所述使用检查点(Checkpoints)。

多成功条件下的检查点(Checkpoint)

许多测试只需在执行过程中的某个点调用 completeNow 即可标记为通过。 尽管如此,也有很多测试的成功取决于多个不同的异步环节全部得到验证。

你可以使用检查点(Checkpoints)来标记某些执行步骤已达标。

当所有检查点均已达成时,对应的 VertxTestContext 就会判定测试通过。

当某个检查点需要多次通过时,还可以从检查点派生出计数门闩(Latch)。

以下是一个包含多个检查点的示例:分别验证 HTTP 服务器已启动、10 个 HTTP 请求已被响应、以及发起了 10 个 HTTP 客户端请求:

Checkpoint serverStarted = testContext.checkpoint();
CountDownLatch requestsServed = testContext.checkpoint().asLatch(10);
CountDownLatch responsesReceived = testContext.checkpoint().asLatch(10);

vertx.createHttpServer()
  .requestHandler(req -> {
    req.response().end("Ok");
    requestsServed.countDown();
  })
  .listen(8888)
  .onComplete(testContext.succeeding(httpServer -> {
    serverStarted.flag();

    client = vertx.createHttpClient();
    for (int i = 0; i < 10; i++) {
      client.request(HttpMethod.GET, 8888, "localhost", "/")
        .compose(req -> req.send().compose(HttpClientResponse::body))
        .onComplete(testContext.succeeding(buffer -> testContext.verify(() -> {
          assertThat(buffer.toString()).isEqualTo("Ok");
          responsesReceived.countDown();
        })));
    }
  }));
检查点应当仅在测试用例的主线程中创建,切勿在 Vert.x 异步事件回调内部动态创建。

与 JUnit 5 深度集成

相比早期版本,JUnit 5 引入了全新的扩展模型。

测试方法

Vert.x 与 JUnit 5 的集成主要通过 VertxExtension 类实现,并利用测试参数注入来获取 Vertx 与 VertxTestContext 实例:

@ExtendWith(VertxExtension.class)
class SomeTest {

  @Test
  void some_test(Vertx vertx, VertxTestContext testContext) {
    // (...)
  }
}
注入的 Vertx 实例是非集群模式并采用默认配置。如果你需要其它自定义配置,只需不要在该参数上使用注入,自行手动构建 Vertx 对象即可。

在 Vert.x 5 中,多个客户端资源(例如 HttpClient、WebClient)依赖 JVM 的 Cleaner 机制来进行资源泄漏回收(旧版本依赖终结器 Finalizer)。

这意味着如果某个客户端变得不可达,JVM 可能会将其回收,而 Vert.x 可能会不确定地关闭其底层连接池,甚至此时异步操作可能仍在执行中。

在测试中,当客户端作为局部变量创建且在异步回调完成之前未保留强引用时,就可能发生这种情况。这会导致偶发性失败,如 VertxException: Pool closed,尤其是在垃圾回收行为不同的 CI 环境中。

为避免这种情况,请在测试运行期间保持客户端强引用可达(例如将其存储在类字段中),并在合适时优先进行显式 close()。

测试执行会自动包装在 VertxTestContext 实例的生命周期内,因此你无需手动插入 awaitCompletion 调用:

@ExtendWith(VertxExtension.class)
class SomeTest {

  // 保留为成员变量以避免在异步操作期间被提前 GC/Cleaner 回收 (Vert.x 5)
  HttpClient client;

  @Test
  void http_server_check_response(Vertx vertx, VertxTestContext testContext) {
    vertx.deployVerticle(new HttpServerVerticle()).onComplete(testContext.succeeding(id -> {
      client = vertx.createHttpClient();
      client.request(HttpMethod.GET, 8080, "localhost", "/")
        .compose(req -> req.send().compose(HttpClientResponse::body))
        .onComplete(testContext.succeeding(buffer -> testContext.verify(() -> {
          assertThat(buffer.toString()).isEqualTo("Plop");
          testContext.completeNow();
        })));
    }));
  }
}

你可以配合标准 JUnit 注解使用,例如 @RepeatedTest 或生命周期回调注解:

@ExtendWith(VertxExtension.class)
class SomeTest {

  // 部署 Verticle,并在 Verticle 成功部署后再执行各测试方法
  @BeforeEach
  void deploy_verticle(Vertx vertx, VertxTestContext testContext) {
    vertx.deployVerticle(new HttpServerVerticle()).onComplete(testContext.succeedingThenComplete());
  }

  // 保留为成员变量以避免在异步操作期间被提前 GC/Cleaner 回收 (Vert.x 5)
  HttpClient client;

  // 重复运行该测试 3 次
  @RepeatedTest(3)
  void http_server_check_response(Vertx vertx, VertxTestContext testContext) {
    client = vertx.createHttpClient();
    client.request(HttpMethod.GET, 8080, "localhost", "/")
      .compose(req -> req.send().compose(HttpClientResponse::body))
      .onComplete(testContext.succeeding(buffer -> testContext.verify(() -> {
        assertThat(buffer.toString()).isEqualTo("Plop");
        testContext.completeNow();
      })));
  }
}

还可以通过在测试类或测试方法上标注 @Timeout 注解,来自定义默认的 VertxTestContext 超时时长:

@ExtendWith(VertxExtension.class)
class SomeTest {

  @Test
  @Timeout(value = 10, timeUnit = TimeUnit.SECONDS)
  void some_test(Vertx vertx, VertxTestContext context) {
    // (...)
  }
}

生命周期方法

JUnit 5 提供了多个由用户定义的生命周期方法,标注有 @BeforeAll、@BeforeEach、@AfterEach 和 @AfterAll。

这些方法同样可以请求注入 Vertx 实例。 这样做时,它们很可能需要使用该 Vertx 实例执行异步操作,因此也可以请求注入 VertxTestContext 实例,以确保 JUnit 运行器等待其执行完成并报告可能的错误。

示例如下:

@ExtendWith(VertxExtension.class)
class LifecycleExampleTest {

  @BeforeEach
  @DisplayName("部署 Verticle")
  void prepare(Vertx vertx, VertxTestContext testContext) {
    vertx.deployVerticle(new SomeVerticle()).onComplete(testContext.succeedingThenComplete());
  }

  @Test
  @DisplayName("第一个测试")
  void foo(Vertx vertx, VertxTestContext testContext) {
    // (...)
    testContext.completeNow();
  }

  @Test
  @DisplayName("第二个测试")
  void bar(Vertx vertx, VertxTestContext testContext) {
    // (...)
    testContext.completeNow();
  }

  @AfterEach
  @DisplayName("检查 Verticle 是否依然存活")
  void lastChecks(Vertx vertx) {
    assertThat(vertx.deploymentIDs())
      .isNotEmpty()
      .hasSize(1);
  }
}

VertxTestContext 对象的作用域

由于这些对象用于等待异步操作完成,因此每次针对 @Test、@BeforeAll、@BeforeEach、@AfterEach 和 @AfterAll 方法都会创建全新的实例。

Vertx 对象的作用域

Vertx 对象的作用域取决于按照 JUnit 相对执行顺序 中哪个生命周期方法最先需要创建新实例。 总体而言,我们遵循 JUnit 扩展的作用域规则,具体规范如下:

  1. 如果父测试上下文中已存在 Vertx 实例,则该实例会在子级扩展测试上下文中复用;

  2. 在 @BeforeAll 方法中注入会创建一个全新实例,并在后续所有测试与生命周期方法的注入中共享;

  3. 在没有父上下文或先前未发生 @BeforeAll 注入的情况下,于 @BeforeEach 中注入会创建一个新实例,并与对应的测试方法以及 AfterEach 方法共享;

  4. 当在运行测试方法之前不存在任何实例时,将专为该测试创建实例(且仅作用于该测试)。

配置 Vertx 实例

默认情况下,Vertx 对象使用 Vertx.vertx() 结合 Vert.x 默认设置创建。 但是,你可以根据需求配置 VertxOptions。 一个典型场景是“在调试时延长阻塞超时警告阈值”。 要配置 Vertx 对象,你必须:

  1. 创建一个包含 JSON 格式 的 VertxOptions 文件;

  2. 配置环境变量 VERTX_PARAMETER_FILENAME,或系统属性 vertx.parameter.filename,指向该文件。

若环境变量与系统属性同时存在,环境变量的取值优先级更高。

用于延长超时的 JSON 配置文件示例:

{
  "blockedThreadCheckInterval" : 5,
  "blockedThreadCheckIntervalUnit" : "MINUTES",
  "maxEventLoopExecuteTime" : 360,
  "maxEventLoopExecuteTimeUnit" : "SECONDS"
}

满足这些条件后,Vertx 对象便会根据所配置的选项进行创建。

Vertx 对象的关闭与清理

注入的 Vertx 对象会在其对应作用域生命周期结束时自动关闭并清理。

例如,若某个 Vertx 对象是为测试方法的作用域创建的,它会在测试执行完毕后自动关闭。 类似地,当它由 @BeforeEach 方法创建时,会在可能的 @AfterEach 方法全部执行完毕后自动关闭。

支持扩展参数类型

Vert.x JUnit 5 扩展具备出色的可扩展性:你可以通过 VertxExtensionParameterProvider 服务提供者接口(SPI)添加更多参数类型。

如果你使用 RxJava,可以替代 io.vertx.core.Vertx 注入以下类型:

  • io.vertx.rxjava3.core.Vertx,或

  • io.vertx.reactivex.core.Vertx,或

  • io.vertx.rxjava.core.Vertx。

要实现此功能,请在项目中引入对应的依赖库:

  • io.vertx:vertx-junit5-rx-java3,或

  • io.vertx:vertx-junit5-rx-java2,或

  • io.vertx:vertx-junit5-rx-java。

在 Reactiverse 上,你可以找到由 reactiverse-junit5-extensions 项目维护的、日益丰富的 vertx-junit5 扩展集合,用于集成各种 Vert.x 技术栈: https://github.com/reactiverse/reactiverse-junit5-extensions。

参数顺序规范

在某些情况下,某种参数类型必须置于另一种参数之前。 例如,reactiverse-junit5-extensions 项目中的 Web Client 支持要求 Vertx 参数必须位于 WebClient 参数之前。 这是因为创建 WebClient 必须首先依赖 Vertx 实例的存在。

参数提供者应当抛出清晰明确的异常,告知使用者可能存在的参数顺序约束。

在任何情况下,将 Vertx 参数放在首位,后续参数按照手动创建它们时所需的依赖顺序排列,都是最佳实践。

配合 @MethodSource 执行参数化测试

你可以使用 vertx-junit5 配合 @MethodSource 执行参数化测试。为此,你需要在方法定义中将方法源(MethodSource)参数声明在 Vert.x 测试参数之前。

@ExtendWith(VertxExtension.class)
static class SomeTest {

  static Stream<Arguments> testData() {
    return Stream.of(
      Arguments.of("complex object1", 4),
      Arguments.of("complex object2", 0)
    );
  }

  @ParameterizedTest
  @MethodSource("testData")
  void test2(String obj, int count, Vertx vertx, VertxTestContext testContext) {
    // 你的测试逻辑
    testContext.completeNow();
  }
}

其他 ArgumentSources 也同理适用。 详细说明请参阅 ParameterizedTest API 文档中的 Formal Parameter List 章节。

在 Vert.x 上下文中运行测试

默认情况下,调用测试方法的线程是 JUnit 线程。 可以使用 RunTestOnContext 扩展来改变该行为,直接在 Vert.x EventLoop 线程中运行测试方法。

请牢记,在使用此扩展时,绝对不能阻塞 EventLoop 线程。

为此,该扩展需要一个 Vertx 实例。 默认情况下它会自动创建一个,但你也可以提供自定义配置选项或 Supplier 提供方法。

在测试运行期间可以检索该 Vertx 实例。

@ExtendWith(VertxExtension.class)
class RunTestOnContextExampleTest {

  @RegisterExtension
  RunTestOnContext rtoc = new RunTestOnContext();

  Vertx vertx;

  @BeforeEach
  void prepare(VertxTestContext testContext) {
    vertx = rtoc.vertx();
    // 在 Vert.x EventLoop 线程中准备前置逻辑
    // 该线程会随着每个测试实例而变化
    testContext.completeNow();
  }

  @Test
  void foo(VertxTestContext testContext) {
    // 在调用 prepare 的同一个 Vert.x EventLoop 线程中执行测试
    testContext.completeNow();
  }

  @AfterEach
  void cleanUp(VertxTestContext testContext) {
    // 在调用 prepare 和 foo 的同一个 Vert.x EventLoop 线程中执行清理
    testContext.completeNow();
  }
}

当作为 @RegisterExtension 实例字段使用时,会为每个测试方法创建一个全新的 Vertx 对象与 Context。 @BeforeEach 与 @AfterEach 方法均在此上下文中执行。

当作为 @RegisterExtension 静态字段使用时,所有测试方法将共享同一个 Vertx 对象与 Context。 @BeforeAll 与 @AfterAll 方法也会在此上下文中执行。