这张照片由Brett Jordan 在 Unsplash 分享。
测试非常重要。在反应式编程的世界中,这可能尤为重要,因为更加函数式的编程方式需要以不同的方式来组织代码。通常来说,测试的可能性是无穷无尽的。因此,我想通过我的个人观点向你介绍如何在Reactor框架的上下文中测试反应式编程的代码。
纯粹从理论讲会很枯燥,所以让我们从示码开始。
借阅和库存管理让我们假设,我们有一个借书的服务。人们可以借阅在库中有现货的书籍。借书和库存服务是通过REST API相互连接的小型微服务,就像现在大家通常做的那样。在某一个时刻,BookBorrowService
使用了一个 InventoryClient
,这是一个小型封装,用于通过Spring的 WebClient
抽象进行非阻塞HTTP调用来调用库存服务。本文将重点讨论这个库存客户端,因为需要处理和测试很多不同类型的错误。
库存管理客户端
在我们的示例应用程序中,库存客户端软件仅提供一个名叫 getByName
的方法,用于根据给定的书名查找 InventoryEntry
。非常简单,但这一点对于这个小示例来说已经足够了。
如前所述,我们在这里使用WebClient
来进行非阻塞的HTTP调用,以满足非阻塞反应式链的要求。请求通过调用get()
发起,并传入URI,最后调用retrieve()
完成。
如果成功的话,响应将会被转换成 Mono<InventoryEntry>
,如第20行那样。可以通过在 retrieve
方法中添加相应的调用来处理失败情况。例如,两个对 onStatus
的调用分别用于处理库存服务返回的不同 HTTP 状态码,而 retryWhen
方法让我们可以定义在何种情况下重试失败的请求是合理的。
在你的例子中,我们是在构造函数中定义重试策略和web客户端。你也可以创建一个Spring配置类。两者各有其利与弊。
我们将自定义超时添加到初始套接字连接以及套接字的读写超时,以缓解服务器端的小问题。此外,我们定义了一个指数退避重试机制,如果发生了 ExternalCommunicationException
,则应重试库存服务的请求。当服务器返回5xx HTTP状态码时,我们会抛出此异常。
通过配置属性来设置最大重试次数和初始重试延迟,以便外部化这些值。
我们有哪些需要测试的?把一切都准备好之后,就出现了一个重要问题:我们想要测试哪些情形呢?
通过检查我们的代码,至少可以发现以下几种情况:
- 状态码 200:OK → 一切正常
- 状态码 404:NOT_FOUND → 我们期望一个
Mono.empty()
- 状态码不是404(例如 BAD_REQUEST)→ 我们期待一个
Mono.error()
- 状态码 5xx 且未达到最大重试次数 → 一切正常
- 状态码 5xx 且已达到最大重试次数 → 我们期待一个
Mono.error()
- 服务响应缓慢,响应时间在读取超时之内 → 一切正常
- 服务响应缓慢,响应时间超出读取超时 → 我们期待一个
Mono.error()
我们可以再试一次上次的情况,但是假设服务反应太慢,新的请求同样不会有什么效果。
现在有一个重要的问题出现了:我们有哪些方法来测试响应式系统?这与为非响应式代码编写测试的常规方法有何不同?
使用 block() 还是使用 StepVerifier?
总的来说,为响应式链写测试主要有两种方法:
- 直接调用方法来测试,并通过调用结果上的
block()
来获取结果。 - 用
StepVerifier
来包裹这些调用。
这两种方法都有其存在的依据,让我们先制定一个测试来比较这两种方法的差异。我将使用由 OkHttp3 依赖提供的 MockWebServer
来模拟服务器端的行为。另一种方法是模拟 WebClient
本身,但这种方法会相当繁琐,因为需要模拟其流畅的 API。测试的设置如下。
setUp
方法确保 MockWebServer
对测试类进行正确的初始化和启动。在测试类执行完毕后,tearDown
方法会在测试结束后停止 web 服务器并释放其资源。在 before
方法中,我们将要测试的类初始化,并将 web 客户端的 URL 绑定到模拟 web 服务器的 URL。
在我们搭建好测试环境之后,我们现在可以开始写我们的第一个测试,从 block
开始:
在这里,我们调用被测试的类的getByName
方法来检查,成功的请求是否确实返回了预期的库存条目。如第9行所示,我们只是通过调用block()
来等待Mono
返回的结果。看起来一切都很熟悉。
以下测试确保了相同的行为表现,不过这次我们使用了由 Reactor 框架提供的内置 StepVerifier:
StepVerifier.create
方法封装了我们方法返回的 Mono/Flux 流,并提供了构建期望或断言的 API,用于反应式流。此外,我们可以在调用 assertNext
时验证并消费发出的项。链需要以合适的 verify
方法结束。在这里我使用了 verifyComplete
,因为我希望确保没有其他的项被发出,并且反应式链确实发送了完成信号。乍一看,这看起来非常相似。那么现在这两种方法的区别是什么呢?
两者,StepVerifier
和 block
都允许我们验证方法的输出。返回的结果是否正确?或者结果为空?该方法是否抛出异常?这些问题都相当容易回答。
对于 block
变体,预期会返回以下内容:
- 成功:some
InventoryEntry
- 未知资源(404 NOT_FOUND):null
- 执行失败:抛出原始的异常
现在可以使用所选的断言框架(比如 AssertJ)来验证这些结果。
对于 StepVerifier
来说,返回值依然在反应式世界里,因此有以下几种情况:
- 成功结果:返回一些
Mono<InventoryEntry>
对象 - 未知资源(因为返回了 404 — NOT_FOUND 错误):
Mono.empty()
- 执行失败:
Mono.error()
,并且原始异常被封装
通过使用流畅的构建器API(Fluent Builder API),我们可以将这些验证和断言(verifications and assertions)加入到测试链中。
我们之前见过assertNext
方法。我们也可以用其他方法来验证,但我在这边更倾向于使用AssertJ来进行断言。还有其他的选择,比如说:
- expectNext → 可以接受一个
InventoryEntry
参数来进行验证。 - consumeNextWith → 期望一个
Consumer<InventoryEntry>
,你可以在其中使用 AssertJ。 - expectNextMatches → 接受一个
Predicate<InventoryEntry>
参数来验证下一个发出的项。
对于 Mono.empty()
情况下,我们可以使用期望 expectNextCount(0)
,因为不应有任何项被发出,但我们仍然期望它完成(即验证完成:verifyComplete
)。
异常情况通过适当的方法来验证,并且遵循相同的命名模式。我使用了expectErrorSatifies
来添加AssertJ断言。其他可能的解决方案包括expectError
、consumeErrorWith
或expectErrorMatches
。因为错误信号也是一个终止信号,我们不能在最后使用verifyComplete
,而是应该从verify
开始来验证此次执行。
重要提示:使用StepVerifier时,别忘了最后要调用verify
。只有调用了verify
方法,测试才会开始执行,否则测试会显示通过但实际上什么也没有执行或验证!就像普通的反应式链一样,直到你订阅,它才会有任何动作。这是一开始很容易被忽视的一个误区。
到目前为止,跟块版本比起来,我们到底得到了什么好处?
- 两者都可以验证反应性操作的结果。
- 两者都可以和断言框架一起使用。
- StepVerifier 的流畅构建器 API 表现力很强,还把内部细节隐藏起来。不过,别忘了最后一定要调用 verify 方法哦。
- StepVerifier 更贴近反应式链,让你能够适当消费信号。
最后一个要点也很重要。当你在生产代码中使用反应式编程时,你也可以在测试代码中使用它。从技术角度看,这并不是必要的,但我强烈建议这样做,这样可以增强团队对反应式概念的理解。
另外,在处理反应式链时,除了直接的返回结果之外,还需要测试其他方面。这里所说的反应式链中的元素,比如订阅和取消信号,延迟执行,或调度安排,这些方面无法通过黑盒测试充分测试。
我们之前定义的测试用例实际上包含这样一种情境,可以利用虚拟时间来测试我们的 getByName
方法及其指数回退策略的重试特性。
但使用block
时,测试设置中有什么问题?
此测试因为第一次失败尝试后有10秒的重试等待时间,大约需要10秒完成。
我们可以解决这个问题,通过将测试用例的重试延迟覆盖为一个合理的较小值。实际上,我们之前已经将重试延迟配置属性外部化了,因为这样做更方便。
然而,我们无法验证在该时间段之前是否确实没有任何事件被触发。如果我们错误地定义了重试规范,会有什么后果?我们可能无法利用block
变体来确认这一点。
步验证器实际上提供了一个功能,可以用来验证这种行为的具体表现。我们使用 StepVerifier.withVirtualTime
而不是 StepVerifier.create
插入一个特殊的时间调度器,利用自定义调度器来控制时间,从而避免长时间运行的测试。
The StepVerifier.withVirtualTime
方法设置了专门的虚拟时间调度器,该调度器会替换此测试用例中的默认调度器。之后,您可以向链中添加期望,例如 expectNoEvent
,或者您可以直接通过 thenAwait
快进时间。因此,测试只需几毫秒就能完成。
不过,别忘了在调用expectNoEvent
之前先加上expectSubscription
,因为虽然我们对这个信号没有兴趣,但订阅本身确实是一个信号。否则,测试就会失败。
重要提示:Mono或Flux必须在supplier函数内部创建!你不能在supplier函数之外创建这些变量,例如:
发布者需要延迟生成,否则虚拟时间可能根本无法正常工作?
这是我们在反应式世界中的小探索的结束。如果你只关注实际结果,而不在乎内部细节,那么使用 block
还是 StepVerifier
都没关系,两者都能工作。然而,我个人强烈建议使用 StepVerifier
,这样每个人都能了解从生产到测试的反应式流程。否则,这将在代码库中形成范式转变。
当涉及到虚拟时间或反应链内部的具体工作时,只有StepVerifier才能胜任。在本文中,我只介绍了一些日常使用的StepVerifier的核心概念和功能。我明确没有涉及以下主题:
- 删除元素后的断言验证
- 上下文环境测试
- TestPublisher 用于模拟数据源或测试你自己的操作符
- PublisherProbe 用于检查数据的实际流通路径
请参考Reactor参考文档,以防您以后需要用到这些更高级的主题。
谢谢阅读!如果您有任何问题或建议,欢迎留言或私信。您可能也会对我们在 Digital Frontiers 博客 上的其他文章感兴趣,这些文章也会在我们的 Twitter 上发布。
共同学习,写下你的评论
评论加载中...
作者其他优质文章