1、使用StepVerifier测试响应式流
由于响应式框架不同于常规框架,测试复杂度相对困难。出于测试目的,Reactor 提供了额外的 reactor-test 模块,该模块提供了 StepVerifier。StepVerifier 提供了一个流式 API,用于为任何 Publisher 构建验证流程。
1.1、StepVerifier 要点
验证 Publisher 主要有两种方法。第一种是 StepVerifier.<T>create(Publisher<T> source)。使用此技术构建的测试如下所示:
@Test
public void test1() {
StepVerifier
.create(Flux.just("bar", "foo"))
//声明发出的事件:当前为订阅事件
.expectSubscription()
//声明订阅收到的第一个元素
.expectNext("foo")
//第二个元素
.expectNext("bar")
//声明当前流信号终止完成
.expectComplete()
.log()
//对上述声明进行验证
.verify();
}
在此示例中,Publisher 应生成两个特定元素,后续操作将验证特定元素是否已传递给最终订阅者。该类提供的构建器技术可以定义验证过程中事件发生的顺序。根据前面的代码,第一个发出的事件必须是与订阅相关的事件,紧跟其后的事件必须是 foo 和 bar 字符串。最后, StepVerifier#expectCompletion 定义终止信号的存在。
在此例中,必须是 Subscriber#onComplete 的调用,或者成功完成给定的 Flux。要执行验证,或者说对创建流进行订阅,就必须调用 .verify() 方法。verify() 是一个阻塞调用,它阻塞执行,直到流发出所有预期的事件。
通过使用这种简单的技术,可以使用可计数的元素和事件来验证 Publisher。但是,用大量元素来验证流程是很困难的。如果检查的是该发布者已发出元素是否达到特定数量,可以使用 .expectNextCount() 。如下代码:
@Test
public void test2() {
StepVerifier
//创建一个响应式序列
.create(Flux.range(0, 100))
//声明处理的是订阅事件
.expectSubscription()
//期望下一个元素与指定的元素符合
.expectNext(0)
//声明从上一个元素开始,期望收到指定个数的元素
.expectNextCount(98)
//期望收到的下一个元素与指定的元素符合
.expectNext(99)
//期望收到完成信号
.expectComplete()
//执行阻塞验证
.verify();
}
尽管 .expectNextCount() 方法解决了一部分问题,但在某些情况下,仅仅检查发出元素的数量是不够的。例如,在验证负责按特定规则过滤或选择元素的代码时,检查所有发出的项是否与过滤规则匹配非常重要。为此,StepVerifier 可以使用 Java Hamcrest 等工具立即记录发出的数据及其验证。如下代码:
package cn.blnp.net.reactboot;
import cn.blnp.net.reactboot.entity.BookEntity;
import org.junit.jupiter.api.Test;
import org.reactivestreams.Publisher;
import reactor.core.publisher.Flux;
import reactor.test.StepVerifier;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.*;
import java.util.ArrayList;
/**
* <p></p>
*
* @author lyb 2045165565@qq.com
* @version 1.0
* @since 2025/1/16 9:51
*/
public class DemoTest {
@Test
public void test3() {
//模拟接口查询取得数据
Publisher<BookEntity> books = findAllBooks();
//验证测试当前流结果
StepVerifier
.create(books)
.expectSubscription()
.recordWith(ArrayList::new)
.expectNextCount(1)
.consumeRecordedWith(book -> assertThat(
//待判定对象
book,
//比对规则
everyItem(
//判定传入对象是否有指定名字的属性
hasProperty("title",
//当上述判定有指定属性时,则验证属性值是否与给定的一致
equalTo("The Martian"))
)
)
)
.expectComplete()
.verify();
}
public Publisher<BookEntity> findAllBooks() {
Flux<BookEntity> books = Flux.just(
new BookEntity("The Martian", 2011),
new BookEntity("The Blue Mars", 1996),
new BookEntity("The War of the Worlds", 1898),
new BookEntity("The Artemis", 2016),
new BookEntity("The Expanse: Leviathan Wakes", 2011),
new BookEntity("The Expanse: Caliban's War", 2012)
);
return books;
}
}
与前面的示例相反,每个期望仅涵盖一个元素或指定数量元素的验证, .consumeRecordedWith() 可以验证给定 Publisher 发布的所有元素。应该注意的是 .consumeRecordedWith() 只有在指定了 .recordWith() 时才有效。反过来,我们应该仔细定义存储记录的集合类。对于多线程发布者而言,用于记录事件的集合类型应该支持并发访问,因此在这些情况下,最好使用 .recordWith(ConcurrentLinkedQueue :: new) 而不是 .recordWith(ArrayList :: new) ,因为与 ArrayList 相比, ConcurrentLinkedQueue 是线程安全的。
除此之外,还有其他功能相似的方法。例如,对下一个元素的期望的定义,如以下代码所示:
@Test
public void test4() {
StepVerifier
.create(Flux.just("alpha-foo", "betta-bar"))
.expectSubscription()
.expectNextMatches(e -> e.startsWith("alpha"))
.expectNextMatches(e -> e.startsWith("betta"))
.expectComplete()
.log()
.verify();
}
.expectNextMatches() 和 .expectNext() 之间的唯一区别是,前者可以定义自定义的匹配器 Predicate ,这使其比后者更灵活。这是因为 .expectNext() 基于元素之间的比较,而这种比较使用元素的 .equals() 方法。
类似地, .assertNext() 和 .consumeNextWith() 使编写自定义断言成为可能。要注意, .assertNext() 是 .consumeNextWith() 的别名。 .expectNextMatches() 和 .assertNext() 之间的区别在于前者接受 Predicate,必须返回 true 或 false,而后者接收可能抛出异常的Consumer,并且捕获消费者抛出的任何 AssertionError,然后通过 .verify() 方法抛出。如下面的代码所示:
package cn.blnp.net.reactboot;
import cn.blnp.net.reactboot.entity.BookEntity;
import org.junit.jupiter.api.Test;
import org.reactivestreams.Publisher;
import reactor.core.publisher.Flux;
import reactor.test.StepVerifier;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.*;
import java.util.ArrayList;
/**
* <p></p>
*
* @author lyb 2045165565@qq.com
* @version 1.0
* @since 2025/1/16 9:51
*/
public class DemoTest {
public Publisher<BookEntity> findAllBooks() {
Flux<BookEntity> books = Flux.just(
new BookEntity("The Martian", 2011),
new BookEntity("The Blue Mars", 1996),
new BookEntity("The War of the Worlds", 1898),
new BookEntity("The Artemis", 2016),
new BookEntity("The Expanse: Leviathan Wakes", 2011),
new BookEntity("The Expanse: Caliban's War", 2012)
);
return books;
}
@Test
public void test5() {
StepVerifier
.create(findAllBooks())
.expectSubscription()
.assertNext(book -> assertThat(book, hasProperty("title",
equalTo("USD")))
)
.expectComplete()
.verify();
}
}
最后,只剩下未覆盖的错误情况,这也是正常系统生命周期的一部分。可以检查错误信号的API 方法不是很多,最简单的是 .expectError() 方法,该方法没有参数。如以下代码所示:
@Test
public void test6() {
StepVerifier.create(Flux.error(new RuntimeException("Error")))
.expectError()
.verify();
}
在某些情况下,测试特定错误类型至关重要。例如,如果用户在登录期间输入了错误的凭据,则安全服务应发出 BadCredentialsException.class 。为了验证发出的错误,我们可以使用 .expectError(Class<? extends Throwable>) 。如以下代码所示:
@Test
public void test7() {
StepVerifier.create(securityService.login("admin", "wrong"))
.expectSubscription()
.expectError(BadCredentialsException.class)
.verify();
}
还可以使用名为 .expectErrorMatches() 和 .consumeErrorWith() 的扩展,它们能与发出的 Throwable 进行直接交互。
1.2、高级测试
无限流验证:
发布者测试的第一步是验证无界 Publisher。根据响应式流规范,无限流意味着流永远不会调用 Subscriber#onComplete() 方法。由于 StepVerifier 将无限期地等待完成信号,因此,测试将被阻塞,直到它被杀死。为了解决这个问题,StepVerifier 提供了一个取消 API,在满足某些期望时,它可以取消对源的订阅。如下面的代码所示:
@Test
public void test7() {
//假设发布的是无限流
Flux < String > webSocketPublisher = null;
StepVerifier.create(webSocketPublisher)
.expectSubscription()
.expectNext("Connected")
.expectNext("Price: $12.00")
//取消订阅
.thenCancel()
.verify();
}
上述代码表示,在收到 Connected 以及 Price:$ 12.00 消息后,我们将断开或取消订阅 WebSocket。
背压验证:
系统验证过程的另一个关键阶段是检查 Publisher 的背压行为。例如,通过 WebSocket与外部系统交互会产生一个只推式的 Publisher。防止此类行为的一种简单方法是使用 .onBackpressureBuffer() 操作符保护下游。要使用所选的背压策略检查系统是否按预期运行,必须手动控制用户需求。为此,StepVerifier 提供了 .thenRequest() 方法,它允许我们控制用户需求。这由以下代码描述:
@Test
public void test8() {
//假设发布无限流
Flux < String > websocketPublisher = null;
Class < Exception > expectedErrorClass = (Class < Exception > ) Exceptions.failWithOverflow().getClass();
StepVerifier
.create(
//使用背压控制
websocketPublisher.onBackpressureBuffer(5), 0
)
.expectSubscription()
.thenRequest(1)
.expectNext("Connected")
.thenRequest(1)
.expectNext("Price: $12.00")
.expectError(expectedErrorClass)
.verify();
}
外部交互:
在前面的示例中,使用的是 StepVerifier.create() 方法的重载,它接收初始订阅者的请求作为第二个参数。在单参数方法的重载中,默认需求是 Long.MAX_VALUE ,即无限需求。如果元素生成过程需要一些额外的外部交互,我们可以使用 .then() 方法。
TestPublisher 实现了响应式流 Publisher,可以直接触发 onNext()、onComplete()和 onError()事件以进行测试。下一个示例演示了如何在测试执行期间触发新事件:
@Test
public void test9() {
//指定发布者
TestPublisher < String > idsPublisher = TestPublisher.create();
//模拟查询相关数据验证
StepVerifier.create(walletsRepository.findAllById(idsPublisher))
.expectSubscription()
.then(() -> idsPublisher.next("1"))
.assertNext(w -> assertThat(w, hasProperty("id", equalTo("1"))))
.then(() -> idsPublisher.next("2"))
.assertNext(w -> assertThat(w, hasProperty("id", equalTo("2"))))
.then(idsPublisher::complete)
.expectComplete()
.verify();
}
通过这种方式,可以验证在该操作之后是否找到已发出的 ID,以及 walletsRepository 的行为是否与预期一致。
1.3、处理虚拟时间
尽管测试的本质在于覆盖业务逻辑,但还有另一个非常重要的部分应该被考虑在内。要理解这个特性,我们首先应该考虑以下示例代码:
public Flux < String > sendWithInterval() {
return Flux.interval(Duration.ofMinutes(1))
.zipWith(Flux.just("a", "b", "c"))
.map(Tuple2::getT2);
}
要使用 StepVerifier 验证此类代码,我们最终可能实现以下测试用例:
@Test
public void test10() {
StepVerifier.create(sendWithInterval())
.expectSubscription()
.expectNext("a", "b", "c")
.expectComplete()
.verify();
}
正在进行的测试将被传递给之前的 sendWithInterval()实现,而该实现是真正想要获取的。但是,此测试存在问题。如果运行几次,测试的平均持续时间超过 3 分钟。
发生这种情况是因为 sendWithInterval()方法在每个元素之前产生 3 个延迟一分钟的事件。在时间间隔或调度时间为几小时或几天的情况下,系统的验证可能花费大量时间,这在当今的持续集成中是不可接受的。为了解决这个问题,Reactor Test 模块提供了用虚拟时间替换实际时间的能力,如下面的代码所示:
@Test
public void test11() {
// 使用虚拟时间
StepVerifier.withVirtualTime(() -> sendWithInterval())
.expectSubscription()
.then(
() -> VirtualTimeScheduler.get().advanceTimeBy(Duration.ofMinutes(3))
)
.expectNext("a", "b", "c")
.expectComplete()
.verify();
}
当使用 .withVirtualTime()构建器方法时,使用 reactor.test.scheduler.VirtualTimeScheduler 显式替换 Reactor 中的每个 Scheduler。表示 Flux.interval 也将在该 Scheduler 上运行。可以使用 VirtualTimeScheduler#advanceTimeBy 完成对时间的所有控制。
将 .then() 与 VirtualTimeScheduler API 结合使用以使时间适当提前。如果运行该测试,它只需要几毫秒而不是几分钟!这个结果要好得多,因为测试表现现在与数据产生的实际时间间隔无关。还可以用.thenAwait()替换.then()和 VirtualTimeScheduler 的组合,它们的行为完全相同。
为了限制在验证方案上花费的时间,可以使用.verify(Duration t)重载方法。如果测试在允许的持续时间内无法完成验证,它将抛出 AssertionError。此外,.verify()方法会返回验证过程实际花费的时间。以下代码描述了这样一个用例:
@Test
public void test12() {
Duration took = StepVerifier
.withVirtualTime(
() -> sendWithInterval()
)
.expectSubscription()
.thenAwait(Duration.ofMinutes(3))
.expectNext("a", "b", "c")
.expectComplete()
.verify();
System.out.println("Verification took: " + took);
}
如果重点是检查在指定的等待时间内是否没有生成事件,则可以使用一个名为.expectNoEvents()的附加 API 方法。使用此方法,可以检查事件是否按照指定的时间间隔生成,如下所示:
@Test
public void test13() {
StepVerifier.withVirtualTime(
() -> sendWithInterval()
)
.expectSubscription()
//用于表示在指定的时间内没有收到事件
.expectNoEvent(Duration.ofMinutes(1))
.expectNext("a")
.expectNoEvent(Duration.ofMinutes(1))
.expectNext("b")
.expectNoEvent(Duration.ofMinutes(1))
.expectNext("c")
.expectComplete()
.verify();
}
没有参数的.thenAwait()方法的主要思路是:触发尚未执行的任务以及计划在当前虚拟时间或该时间之前执行的任务。
例如,要在下一次设置中接收第一个预定事件,Flux.interval(Duration.ofMillis(0), Duration.ofMillis (1000)) 将需要额外调用.thenAwait(),如下面的代码所示:
@Test
public void test14() {
StepVerifier.withVirtualTime(
() -> Flux.interval(Duration.ofMillis(0), Duration.ofMillis(1000))
.zipWith(Flux.just("a", "b", "c"))
.map(Tuple2::getT2)
)
.expectSubscription()
.thenAwait()
.expectNext("a")
.expectNoEvent(Duration.ofMillis(1000))
.expectNext("b")
.expectNoEvent(Duration.ofMillis(1000))
.expectNext("c")
.expectComplete()
.verify();
}
如果没有.thenAwait(),测试将永远挂起。
1.4、验证响应式上下文
最后,最不常见的验证是 Reactor 的 Context。假设要验证身份验证服务的响应式 API。为了验证用户身份,LoginService 希望订阅者提供一个包含身份验证信息的 Context:
@Test
public void test15() {
StepVerifier
.create(securityService.login("admin", "admin"))
.expectSubscription()
.expectAccessibleContext()
.hasKey("security")
.then()
.expectComplete()
.verify();
}
.expectAccessibleContext() 验证只可能在一种情况下失败,即返回的 Publisher 不 是Reactor 类型(Flux 或 Mono)时。
因此,只有可访问的上下文存在时,后续的 Context 验证才会被执行。除了.hasKey(),还有许多其他方法可以对当前上下文进行详细验证。要退出上下文验证,构建器提供了.then()方法。
2、WebFlux 测试
如测试支付服务。在这种场景下,假设支付服务支持 /payments 端点的 GET 和POST 方法。第一个 HTTP 调用负责检索当前用户的已执行支付列表。而第二个可以提交新的支付。该 REST 控制器的实现如下所示:
@RestController
@RequestMapping("/payments")
public class PaymentController {
private final PaymentService paymentService;
public PaymentController(PaymentService paymentService) {
this.paymentService = paymentService;
}
@GetMapping("/")
public Flux < Payment > list() {
return paymentService.list();
}
@PostMapping("/")
public Mono < String > send(Mono < Payment > payment) {
return paymentService.send(payment);
}
}
WebTestClient 类似于 org.springframework.test.web.servlet.MockMvc 。这些测试 Web 客户端之间的唯一区别是WebTestClient 旨在测试 WebFlux 端点。例如,使用 WebTestClient 和 Mockito 库,可以通过以下方式编写用于检索用户支付列表的验证:
@Test
public void verifyRespondWithExpectedPayments() {
PaymentService paymentService = Mockito.mock(PaymentService.class);
PaymentController controller = new PaymentController(paymentService);
prepareMockResponse(paymentService);
WebTestClient
.bindToController(controller)
.build()
.get()
.uri("/payments/")
.exchange()
.expectHeader()
.contentTypeCompatibleWith(APPLICATION_JSON)
.expectStatus()
.is2xxSuccessful()
.returnResult(Payment.class)
.getResponseBody()
.as(StepVerifier::create)
.expectNextCount(5)
.expectComplete()
.verify();
}
在此示例中,使用 WebTestClient 构建了对 PaymentController 的验证。反过来,可以使用WebTestClient 流式 API 检查响应的状态码和消息头的正确性。此外,可以使用.getResponseBody()来获取 Flux 响应,最后使用 StepVerifier 对其进行验证。此示例显示,两个工具可以轻松地相互集成。
从前面的示例中可以看到,模拟了 PaymentService,并且在测试 PaymentController时不与外部服务通信。但是,要检查系统完整性,就必须运行完整的组件,而不仅仅是其中几层。
要运行完整的集成测试,就需要启动整个应用程序。为此,可以将@SpringBootTest 注解与@AutoConfigureWebTestClient 注解结合起来使用。WebTestClient 提供与 HTTP 服务器建立 HTTP 连接的功能。此外,WebTestClient 可以借助模拟的请求和响应对象直接绑定到基于 WebFlux 的应用程序,而无须 HTTP 服务器。它在测试 WebFlux 应用程序中扮演的角色与TestRestTemplate 在 WebMVC 应用程序中扮演的角色一样,如以下代码所示:
@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureWebTestClient
public class PaymentControllerTests {
@Autowired
WebTestClient client;
// ...
}