跳到主要内容

测试

Doubao 中英对照 Testing

目录

无论您编写的是一条简单的 Reactor 操作符链,还是自己实现的操作符,自动化测试始终是个好主意。

Reactor 内置了一些专用于测试的组件,它们被打包成独立的构件:reactor-test。您可以在 reactor-core 仓库中在 Github 上找到该项目。

要在测试中使用它,您必须把它加为测试依赖。下例展示如何在 Maven 中把 reactor-test 加为依赖:

<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-test</artifactId>
<scope>test</scope>
// <1>
</dependency>
  • 如果您使用 BOM,则无需指定 <version>。

下例展示如何在 Gradle 中把 reactor-test 加为依赖:

dependencies {
testCompile 'io.projectreactor:reactor-test'
}

reactor-test 的三大主要用途如下:

  • 用 StepVerifier 逐步验证一个序列是否符合给定场景。

  • 用 TestPublisher 产生数据,以测试下游操作符(包括您自己的操作符)的行为。

  • 在可能经过若干备选 Publisher 的序列中(例如使用 switchIfEmpty 的链),探测这样的 Publisher 是否被使用(即是否被订阅)。

1. 用 StepVerifier 测试场景​

测试 Reactor 序列最常见的情形是:您的代码中定义了一个 Flux 或 Mono(例如,它可能由某个方法返回),而您想测试它在被订阅时的行为。

这种情形很适合定义一个「测试场景」:您以事件为单位、逐步定义自己的期望。您可以提出并回答如下问题:

  • 下一个期望的事件是什么?

  • 您期望 Flux 发出某个特定值吗?

  • 还是期望它在接下来的 300ms 内什么都不做?

您可以通过 StepVerifier API 表达所有这些。

例如,您的代码库中可能有下面这样一个装饰 Flux 的工具方法:

public <T> Flux<T> appendBoomError(Flux<T> source) {
return source.concatWith(Mono.error(new IllegalArgumentException("boom")));
}

为了测试它,您想验证下面这个场景:

我期望这个 Flux 先发出 thing1,再发出 thing2,然后产生一个错误,错误消息为 boom。订阅并验证这些期望。

在 StepVerifier API 中,这对应为下面这个测试:

@Test
public void testAppendBoomError() {
Flux<String> source = Flux.just("thing1", "thing2"); 1

StepVerifier.create( 2
appendBoomError(source)) 3
.expectNext("thing1") 4
.expectNext("thing2")
.expectErrorMessage("boom") 5
.verify(); 6
}
  • 由于我们的方法需要一个源 Flux,为测试目的定义一个简单的源。

  • 创建一个包装并验证 Flux 的 StepVerifier 构建器。

  • 传入待测试的 Flux(即调用我们工具方法的结果)。

  • 我们期望订阅后发生的第一个信号是一个 onNext,值为 thing1。

  • 我们期望发生的最后一个信号是序列以 onError 终止。该异常的消息应为 boom。

  • 务必通过调用 verify() 来触发测试。

该 API 是一个构建器。您先创建一个 StepVerifier,并传入待测试的序列。它提供一组方法,让您能够:

  • 表达对接下来将要发生的信号的期望。如果收到任何其他信号(或信号内容与期望不符),整个测试就会以一个有意义的 AssertionError 失败。例如,您可以使用 expectNext(T…) 和 expectNextCount(long)。

  • 消费下一个信号。当您想跳过序列的一部分,或想对信号内容施加自定义 assertion 时使用(例如,检查是否有一个 onNext 事件,并断言发出的元素是一个大小为 5 的列表)。例如,您可以使用 consumeNextWith(Consumer<T>)。

  • 执行其他杂项操作,例如暂停或运行任意代码。例如,如果您想操控某个测试专用的状态或上下文。为此,您可以使用 thenAwait(Duration) 和 then(Runnable)。

对于终止事件,相应的期望方法(expectComplete() 和 expectError() 及其所有变体)会切换到一个无法再表达期望的 API。在这最后一步,您所能做的就是对 StepVerifier 做一些额外配置,然后触发验证,通常用 verify() 或其某个变体。

此时发生的是:StepVerifier 订阅被测试的 Flux 或 Mono 并播放该序列,把每个新信号与场景中的下一步进行比较。只要它们匹配,测试即视为通过。一旦出现不一致,就抛出一个 AssertionError。

important

别忘了 verify() 这一步,它才会触发验证。为方便起见,该 API 提供了若干把终止期望与 verify() 调用结合起来的快捷方法:verifyComplete()、verifyError()、verifyErrorMessage(String) 等。

请注意,如果某个基于 lambda 的期望抛出了 AssertionError,它会被原样上报,使测试失败。这对于自定义断言很有用。

提示

默认情况下,verify() 方法及其派生的快捷方法(verifyThenAssertThat、verifyComplete() 等)没有超时。它们可能无限期阻塞。您可以用 StepVerifier.setDefaultTimeout(Duration) 为这些方法全局设置超时,也可以用 verify(Duration) 在每次调用时单独指定。

1.1. 更好地定位测试失败​

StepVerifier 提供两个选项,以便更精确地识别究竟是哪一步期望导致了测试失败:

  • as(String):在大多数 expect* 方法之后使用,为前一个期望添加一段描述。如果该期望失败,其错误消息会包含这段描述。终止期望和 verify 不能以这种方式描述。

  • StepVerifierOptions.create().scenarioName(String):通过使用 StepVerifierOptions 创建 StepVerifier,您可以用 scenarioName 方法为整个场景起一个名字,它也会出现在断言错误消息中。

请注意,在这两种情况下,描述或名字在消息中的出现,只对那些自身产生 AssertionError 的 StepVerifier 方法有保证(例如,在 assertNext 中手动抛出异常或通过断言库抛出异常,不会把描述或名字加到错误消息中)。

2. 操控时间​

您可以把 StepVerifier 与基于时间的操作符配合使用,以避免相应测试的运行时间过长。做法是使用 StepVerifier.withVirtualTime 构建器。

如下例所示:

StepVerifier.withVirtualTime(() -> Mono.delay(Duration.ofDays(1)))
//... continue expectations here

这个虚拟时间特性会在 Reactor 的 Schedulers 工厂中插入一个自定义 Scheduler。由于这些定时操作符通常使用默认的 Schedulers.parallel() 调度器,把它替换为 VirtualTimeScheduler 即可奏效。然而,一个重要的前提是:操作符必须在虚拟时间调度器激活之后才被实例化。

为提高这一前提被正确满足的概率,StepVerifier 并不直接接收一个普通的 Flux 作为输入。withVirtualTime 接收一个 Supplier,引导您在完成调度器设置之后再惰性地创建被测 flux 的实例。

important

请格外注意确保 Supplier<Publisher<T>> 能以惰性方式使用。否则,虚拟时间无法得到保证。尤其要避免在测试代码中过早实例化 Flux、再让 Supplier 返回那个变量。相反,始终在 lambda 内部实例化 Flux。

有两个处理时间的期望方法,无论是否使用虚拟时间都适用:

  • thenAwait(Duration):暂停对各步骤的求值(允许发生若干信号或让延迟耗尽)。

  • expectNoEvent(Duration):同样让序列在给定时长内播放,但如果在此期间发生_任何_信号,则使测试失败。

在经典模式下,这两个方法都会让线程暂停给定时长;而在虚拟模式下,则改为推进虚拟时钟。

提示

expectNoEvent 也会把 subscription 视为一个事件。如果您把它用作第一步,通常会失败,因为检测到了订阅信号。请改用 expectSubscription().expectNoEvent(duration)。

为了快速评估上面那个 Mono.delay 的行为,我们可以把代码补全如下:

StepVerifier.withVirtualTime(() -> Mono.delay(Duration.ofDays(1)))
.expectSubscription() 1
.expectNoEvent(Duration.ofDays(1)) 2
.expectNext(0L) 3
.verifyComplete(); 4
  • 参见前面的 tip。

  • 期望整整一天内什么都不发生。

  • 然后期望一个发出 0 的延迟。

  • 然后期望完成(并触发验证)。

上面我们本可以使用 thenAwait(Duration.ofDays(1)),但 expectNoEvent 的好处是能保证没有任何事情在不该发生之前发生。

请注意,verify() 会返回一个 Duration 值。它是整个测试的实时时长。

注意

虚拟时间并非银弹。所有 Schedulers 都会被替换为同一个 VirtualTimeScheduler。在某些情况下,您可能会把验证过程锁死,因为在某个期望被表达之前虚拟时钟尚未前进,导致该期望只能等待通过推进时间才能产生的数据。在大多数情况下,您需要推进虚拟时钟,序列才会发出数据。对于无限序列,虚拟时间也会变得非常受限,可能会独占序列及其验证所运行的那个线程。

3. 用 StepVerifier 进行事后断言​

在描述完场景的最终期望之后,您可以切换到一个补充的断言 API,而不触发 verify()。为此,请改用 verifyThenAssertThat()。

verifyThenAssertThat() 返回一个 StepVerifier.Assertions 对象,您可以用它在整个场景成功播放完毕后对若干状态元素进行断言(因为它内部也会调用 verify())。典型(虽然较高级)的用法是:捕获被某个操作符丢弃的元素并对其断言(参见 Hooks 一节)。

4. 测试 Context​

关于 Context 的更多信息,请参阅向响应式序列添加上下文。

StepVerifier 围绕 Context 的传播提供了几个期望:

  • expectAccessibleContext:返回一个 ContextExpectations 对象,您可以用它对传播过来的 Context 设置期望。务必调用 then() 以回到序列期望的设置。

  • expectNoAccessibleContext:设置这样一个期望:被测操作符链中_没有_任何 Context 能向上传播。这最可能发生在被测 Publisher 不是 Reactor 的、或没有任何能传播 Context 的操作符时(例如一个生成器源)。

此外,您可以用 StepVerifierOptions 创建验证器,从而把一个测试专用的初始 Context 关联到 StepVerifier。

下面的片段演示了这些特性:

StepVerifier.create(Mono.just(1).map(i -> i + 10),
StepVerifierOptions.create().withInitialContext(Context.of("thing1", "thing2"))) 1
.expectAccessibleContext() 2
.contains("thing1", "thing2") 3
.then() 4
.expectNext(11)
.verifyComplete(); 5
  • 用 StepVerifierOptions 创建 StepVerifier,并传入一个初始 Context。

  • 开始对 Context 传播设置期望。仅凭这一点即可保证曾有一个 Context 被传播。

  • 这是一个 Context 专用期望的示例。它必须对键 "thing1" 包含值 "thing2"。

  • 我们 then() 切回对数据设置普通期望。

  • 别忘了 verify() 验证整组期望。

5. 用 TestPublisher 手动发出数据​

对于更高级的测试用例,完全掌控数据源、精确触发与您想测试的特定情形相吻合的信号,可能会很有用。

另一种情形是:您实现了自己的操作符,想验证它在响应式流规范(Reactive Streams specification)方面的行为,尤其是当其源行为不端时。

对于这两种情形,reactor-test 提供了 TestPublisher 类。它是一个 Publisher<T>,让您以编程方式触发各种信号:

  • next(T) 和 next(T, T…) 触发 1 到 n 个 onNext 信号。

  • emit(T…) 触发 1 到 n 个 onNext 信号并执行 complete()。

  • complete() 以一个 onComplete 信号终止。

  • error(Throwable) 以一个 onError 信号终止。

您可以通过 create 工厂方法获得一个行为规范的 TestPublisher。此外,您可以用 createNonCompliant 工厂方法创建一个行为不端的 TestPublisher。后者接收 TestPublisher.Violation 枚举中的一个或多个值。这些值定义了该 publisher 可以无视规范的哪些部分。枚举值包括:

  • REQUEST_OVERFLOW:允许在请求量不足时仍调用 next,而不触发 IllegalStateException。

  • ALLOW_NULL:允许以 null 值调用 next,而不触发 NullPointerException。

  • CLEANUP_ON_TERMINATE:允许连续多次发送终止信号。这包括 complete()、error() 和 emit()。

  • DEFER_CANCELLATION:允许 TestPublisher 忽略取消信号,并继续发出信号,仿佛取消信号在与这些信号的竞争中落败。

最后,TestPublisher 会跟踪订阅后的内部状态,可通过其各种 assert* 方法进行断言。

您可以通过转换方法 flux() 和 mono(),把它当作 Flux 或 Mono 使用。

6. 用 PublisherProbe 检查执行路径​

在构建复杂的操作符链时,您可能会遇到存在多条可能执行路径的情形,它们由不同的子序列来体现。

大多数时候,这些子序列会产生足够特定的 onNext 信号,您只需查看最终结果就能断言它是否被执行过。

例如,考虑下面这个方法:它从一个源构建一条操作符链,并使用 switchIfEmpty,在源为空时回退到某个备选路径:

public Flux<String> processOrFallback(Mono<String> source, Publisher<String> fallback) {
return source
.flatMapMany(phrase -> Flux.fromArray(phrase.split("\\s+")))
.switchIfEmpty(fallback);
}

您可以如下测试 switchIfEmpty 的哪个逻辑分支被使用了:

@Test
public void testSplitPathIsUsed() {
StepVerifier.create(processOrFallback(Mono.just("just a phrase with tabs!"),
Mono.just("EMPTY_PHRASE")))
.expectNext("just", "a", "phrase", "with", "tabs!")
.verifyComplete();
}

@Test
public void testEmptyPathIsUsed() {
StepVerifier.create(processOrFallback(Mono.empty(), Mono.just("EMPTY_PHRASE")))
.expectNext("EMPTY_PHRASE")
.verifyComplete();
}

然而,设想这样一个示例:该方法改为产生一个 Mono<Void>。它等待源完成,执行一项额外任务,然后完成。如果源为空,则必须改为执行一个类似回退 Runnable 的任务。下例展示了这样一种情形:

private Mono<String> executeCommand(String command) {
return Mono.just(command + " DONE");
}

public Mono<Void> processOrFallback(Mono<String> commandSource, Mono<Void> doWhenEmpty) {
return commandSource
.flatMap(command -> executeCommand(command).then()) 1
.switchIfEmpty(doWhenEmpty); 2
}
  • then() 会丢弃命令结果。它只关心命令是否已完成。

  • 如何区分这两种都是空序列的情形?

要验证您的 processOrFallback 方法确实经过了 doWhenEmpty 路径,您需要写一点样板代码。具体来说,您需要这样一个 Mono<Void>:

  • 记录下它已被订阅这一事实。

  • 让您能在整个流程结束_之后_对这一事实进行断言。

在 3.1 版本之前,您需要为每个想断言的状态手动维护一个 AtomicBoolean,并在想评估的 publisher 上挂上相应的 doOn* 回调。当需要经常套用这种模式时,样板代码可能会很多。幸运的是,3.1.0 引入了 PublisherProbe 作为替代。下例展示如何使用它:

@Test
public void testCommandEmptyPathIsUsed() {
PublisherProbe<Void> probe = PublisherProbe.empty(); 1

StepVerifier.create(processOrFallback(Mono.empty(), probe.mono())) 2
.verifyComplete();

probe.assertWasSubscribed(); 3
probe.assertWasRequested(); 4
probe.assertWasNotCancelled(); 5
}
  • 创建一个对应于空序列的 probe。

  • 通过调用 probe.mono(),用该 probe 代替 Mono<Void>。

  • 序列完成后,probe 让您断言它曾被使用。您可以检查它是否被订阅……

  • ……以及是否真的被请求了数据……

  • ……以及它是否被取消。

您也可以调用 .flux() 而非 .mono(),用该 probe 代替一个 Flux<T>。对于既需要探测执行路径、又需要 probe 发出数据的情形,您可以用 PublisherProbe.of(Publisher) 包装任意 Publisher<T>。