Vert.x Junit 5 整合

本模块提供了用 JUnit 5 编写 Vert.x 测试的相关整合以及支持。

在您的构建脚本中使用它

  • groupId: io.vertx

  • artifactId: vertx-junit5

  • version: (当前的 Vert.x 的发布版本或快照版本)

为什么测试异步代码与平常不同

测试异步操作需要比 JUnit 之类的测试工具更多的工具。 让我们考虑一个典型的例子,用 Vert.x 创建 HTTP 服务,并将它放入 Junit 测试:

@ExtendWith(VertxExtension.class)
class ATest {
  Vertx vertx = Vertx.vertx();

  @Test
  void start_server() {
    vertx.createHttpServer()
      .requestHandler(req -> req.response().end("Ok"))
      .listen(16969, ar -> {
        //(在此处检查服务是否已经启动)
      });
  }
}

这里会有一些问题,因为HTTP服务是异步创建的, listen 方法并不阻塞。 我们不能按常规方式来断定在 listen 方法返回之后,服务就成功启动了。 况且:

  1. 传到 listen 的回调函数是在 Vert.x event loop 线程上运行的,而不是在执行 Junit 测试的线程上运行的,此外

  2. 调用 listen 方法之后,单元测试就退出了,并且已经被认为执行成功,此时HTTP服务有可能并没有启动完毕,而且

  3. 因为 listen 中的回调函数不是运行于单元测试线程上,而是运行于另一线程,所以类似于断言失败等等任何异常都不会被Junit执行器捕获到。

异步执行过程的测试上下文

首先对该模块有贡献的是 VertxTestContext 对象:

  1. 允许等待其他线程中正在执行的操作,从而触发完成事件。

  2. 支持接收失败断言用以标记单元测试失败。

这是一个很基础的用法:

@ExtendWith(VertxExtension.class)
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 返回一个异步结果处理器,该处理器期望成功结果并使 test context 通过。

  2. awaitCompletion 具有 java.util.concurrent.CountDownLatch 的语义,并且,如果测试通过之前超时了,会返回 false 值。

  3. 如果 context 捕获到了一个错误(潜在的异步错误),那么在测试完成之后,我们必须抛出该异常并让测试用例失败。

使用其他任何断言库

本模块并不要求您使用特定的断言库。 您可以使用原始的JUnit断言、 AssertJ 、等等。

想要在异步代码当中做断言并确定 VertxTestContext 已被潜在失败所通知, 那么您需要将他们包装到 verifysucceeding 或者 failing 当中:

HttpClient 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 中有用的方法列举如下:

  • completeNowfailNow 用于通知成功或失败。

  • succeedingThenComplete 用来提供 Handler<AsyncResult<T>> 处理器,该处理器期望得到成功结果并完成 test context。

  • failingThenComplete 提供一个 Handler<AsyncResult<T>> 处理器,该处理器期望得到失败结果并完成 test context。

  • succeeding 用来提供 Handler<AsyncResult<T>> 处理器,该处理器期望成功结果并将该结果传到下一个回调函数中,该过程中从回调函数抛出任何异常都会被认为测试用例失败。

  • failing 用来提供 Handler<AsyncResult<T>> 处理器 ,该处理器期望一个失败结果,并将异常传入下一个回调函数中,该过程中从回调函数抛出任何异常都会被认为测试用例失败。

  • verify 提供断言功能,代码块中抛出的任何异常都被认为测试用例失败。

警告
succeedingThenCompletefailingThenComplete 不同,调用 succeedingfailing 方法只能让测试用例失败(例如,succeeding 获取到了失败的异步结果)。 如果想让测试用例通过,您仍然需要调用 completeNow ,或者使用下述的 checkpoints 。

有多个成功条件的Checkpoint

在一些特定的执行点调用 completeNow 可以轻松的将许多测试标记为通过。 也就是说基于不同的异步结果,一个测试用例在很多种情况下都可以视为成功。

您可以用 checkpoint 以标记某些执行点为通过。 一个 Checkpoint 可以由单个标记或多个标记来控制。 当所有的 checkpoint 被标记后, VertxTestContext 将通过测试用例。

以下是一个结合 checkpoint 启动 HTTP 服务、创建 10 个 HTTP 客户端请求并响应 10 个 HTTP 请求的示例:

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

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

    HttpClient 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.flag();
        })));
    }
  }));
提示
checkpoint只能在测试用例的主线程创建,不能在Vert.x异步事件回调中创建。

整合JUnit 5

Junit 5 相比于之前的版本,它提供了一个不同的模型。

测试方法

与Vert.x 的整合主要受益于 VertxExtension 类,并使用注入的测试参数: VertxVertxTestContext

@ExtendWith(VertxExtension.class)
class SomeTest {

  @Test
  void some_test(Vertx vertx, VertxTestContext testContext) {
    // (...)
  }
}
注意
Vertx 实例默认配置下并非集群模式。 如果您需要做一些其他事情,那么请不要使用注入的 Vertx 参数,需要您自己提供 Vertx 对象。

测试用例会被自动的包装到 VertxTestContext 生命周期,所以您无需自行注入 awaitCompletion

@ExtendWith(VertxExtension.class)
class SomeTest {

  @Test
  void http_server_check_response(Vertx vertx, VertxTestContext testContext) {
    vertx.deployVerticle(new HttpServerVerticle(), testContext.succeeding(id -> {
      HttpClient 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 并在部署成功之后
  // 执行测试用例方法
  @BeforeEach
  void deploy_verticle(Vertx vertx, VertxTestContext testContext) {
    vertx.deployVerticle(new HttpServerVerticle(), testContext.succeedingThenComplete());
  }

  // 重复测试3次
  @RepeatedTest(3)
  void http_server_check_response(Vertx vertx, VertxTestContext testContext) {
    HttpClient 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("Deploy a verticle")
  void prepare(Vertx vertx, VertxTestContext testContext) {
    vertx.deployVerticle(new SomeVerticle(), testContext.succeedingThenComplete());
  }

  @Test
  @DisplayName("A first test")
  void foo(Vertx vertx, VertxTestContext testContext) {
    // (...)
    testContext.completeNow();
  }

  @Test
  @DisplayName("A second test")
  void bar(Vertx vertx, VertxTestContext testContext) {
    // (...)
    testContext.completeNow();
  }

  @AfterEach
  @DisplayName("Check that the verticle is still there")
  void lastChecks(Vertx vertx) {
    assertThat(vertx.deploymentIDs())
      .isNotEmpty()
      .hasSize(1);
  }
}

VertxTestContext 对象的作用范围

因为这些对象都协助等待 异步操作 执行结束,所以调用任何 @Test@BeforeAll@BeforeEach@AfterEach@AfterAll 修饰的方法时都会随之创建一个新的 VertxTestContext 对象。

Vertx 对象的作用范围

Vertx 对象的作用范围取决于声明周期函数在 JUnit中相对执行顺序 里第一个创建 Vertx 的那个方法。一般来说,我们遵循JUnit扩展作用范围规则, 但是这里有一些规范。

  1. 如果一个父级test context已经持有一个 Vertx 对象, 那么该 Vertx 对象在子级扩展的test context中会被复用。

  2. @BeforeAll 修饰的方法中注入的 Vertx 对象,会在之后所有的测试方法以及生命周期函数中注入的 Vertx 参数当中共享。

  3. @BeforeEach 修饰的且没有父级context的方法注入过程中,或者在先前的 @BeforeAll 方法的参数注入过程中,会创建一个新的对象,并共享于相关所有的测试方法以及 AfterEach 方法。

  4. 当执行测试方法之前没有创建 Vertx 对象时,则会创建一个新的 Vertx 对象(仅仅作用于该方法本身)

配置 Vertx 实例

默认情况下,Vertx 对象使用 Vertx.vertx() 创建,并使用 Vertx 的默认设置。 但是,您可以配置 VertxOptions 以满足您的需要。 一个典型的应用场景是“扩展调试时阻塞超时警告”。 为配置 Vertx 对象,您必须:

  1. 创建一个带有 json 格式VertxOptions 的 json 文件

  2. 创建一个环境变量 vertx.parameter.filename 指向该文件

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

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

当满足这些条件时, Vertx 对象创建时将使用配置中的参数

关闭和移除 Vertx 对象

注入的 Vertx 对象会自动被关闭并被移除其作用域。

例如,如果在一个测试方法的范围内创建一个 Vertx 对象,那么在该测试方法执行完之后,这个 Vertx 对象会被关闭。 相似地,当在 @BeforeEach 方法中创建 Vertx 对象时,它会在 @AfterEach 方法执行完之后被关闭。

同一生命周期事件下多方法的警告

JUnit 5 允许同一个生命周期事件之下存在多个方法。

例如,同一个测试可以定义3个 @BeforeEach 方法。 因为是异步操作,这些方法更可能是并行执行而不是串行执行,这有可能引起不确定的结果状态。

这是JUnit 5本身就存在的问题,而不属于 Vert.x JUnit5 模块范畴。 如有疑问,也许您一直想知道的是,为什么单个方法不比多个方法更好。

对于其他额外参数类型的支持

Vert.x Unit 5 模块是可扩展的: 您可以通过 VertxExtensionParameterProvider 服务接口来添加更多的类型。

如果您使用的是 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

对于响应式库,您可以找到许多 vertx-junit5 的扩展库, 他们属于 reactiverse-junit5-extensions 项目,而且都整合了Vert.x技术栈,这些扩展库也正在进一步的发展:

参数顺序

在某些情况下,可能必须将一个参数类型放置在另一个参数之前。 例如 Web Client 在 vertx-junit5-extensions 项目中会要求 Vertx 参数在 WebClient 参数之前。 这是因为只有 Vertx 参数存在的时候,才可以创建 WebClient

我们期望参数提供者抛出一些有意义的异常来让用户知道参数顺序的要求。

然而,任何情况下,我们都建议:将 Vertx 作为第一个参数,并按照您创建的顺序去声明后续参数。

@MethodSource 做参数化测试

您可以结合vertx-junit5,用 @MethodSource 做参数化测试。因此,您需要在方法定义的vertx测试参数之前声明 method source 参数。

@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) {
    // your test code
    testContext.completeNow();
  }
}

这同样适用于其它 ArgumentSources 。 详见 ParameterizedTest 文档的 Formal Parameter List 一节。

在 Vert.x 的 context 中执行测试

默认情况下,是由 JUnit 线程来调用测试方法。 可以使用 RunTestOnContext 扩展以选择使用一个 Vert.x event-loop 线程来执行测试方法。

小心
您需要注意在使用该扩展时不要阻塞事件循环。

为了使用 Vert.x 的线程来执行测试,扩展需要您提供一个 Vertx 的实例。 默认情况下,扩展会自动创建一个 Vertx 的实例,但您也可以提供配置参数,或是指定一个方法来提供其实例。

这个 Vertx 的实例可以在执行测试的方法中获取到。

@ExtendWith(VertxExtension.class)
class RunTestOnContextExampleTest {

  @RegisterExtension
  RunTestOnContext rtoc = new RunTestOnContext();

  Vertx vertx;

  @BeforeEach
  void prepare(VertxTestContext testContext) {
    vertx = rtoc.vertx();
    // 在 Vert.x 的 event-loop 线程上准备测试资源
    // 每次执行测试时该线程都不一样
    testContext.completeNow();
  }

  @Test
  void foo(VertxTestContext testContext) {
    // 在同一个 Vert.x 的 event-loop 线程上执行测试
    // 该测试方法和 prepare 方法使用的是同一个线程
    testContext.completeNow();
  }

  @AfterEach
  void cleanUp(VertxTestContext testContext) {
    // 在同一个 Vert.x 的 event-loop 线程上清理资源
    // 该方法和 prepare 与 foo 方法使用的是同一个线程
    testContext.completeNow();
  }
}

使用 @RegisterExtension 注解修饰类的非静态属性时,会为每个测试方法创建新的 VertxContext 的对象。 @BeforeEach@AfterEach 注解修饰的方法会在该 context 中执行。

使用 @RegisterExtension 注解修饰类的静态属性时,会为所有的测试方法创建一个公用的 VertxContext 的对象。 @BeforeEach@AfterEach 注解修饰的方法也会在该 context 中执行。