测试
目录
无论您编写的是一条简单的 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。
别忘了 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 的实例。
请格外注意确保 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>。