vlambda博客
学习文章列表

读书笔记《hands-on-reactive-programming-in-spring-5》测试反应式应用程序

第 9 章测试反应式应用程序

到目前为止,我们已经 几乎 涵盖了关于使用 Spring 5.x 进行反应式编程的所有内容。我们还研究了如何使用 Project Reactor 3 构建干净的异步执行,以及如何使用这些知识使用 WebFlux 构建 Web 应用程序。此外,我们了解了 Reactive Spring Data 如何补充整个系统,以及使用 Spring Cloud  和 Spring Cloud Streams 将应用程序升级到云级别的速度有多快。

在本章中,我们将通过学习如何测试系统中的每个组件来完成我们的知识库。我们将介绍有助于验证代码的测试技术和实用程序,这些代码是使用 Reactor 或任何与 Reactive Streams 规范兼容的库编写的。我们还将查看 Spring 框架提供的用于端到端测试反应式应用程序的功能。

在本章中,我们将介绍以下主题:

  • 对额外测试工具的需求

  • Publisher的要点 使用StepVerifier进行测试

  • 高级StepVerifier使用场景

  • 用于端到端 WebFlux 测试的工具集

 

为什么反应流很难测试?


如今,企业应用程序 非常庞大。这就是为什么此类系统的验证在任何现代开发生命周期中都是一个非常重要的阶段。但是,我们应该记住,在大型系统中,有大量的组件和服务,而这些组件和服务又可能包含大量的类。出于这个原因,我们应该遵循 Test Pyramid的建议以涵盖所有内容。在这里,系统测试的基本部分是单元测试。

在我们的例子中,测试的主题是使用反应式编程技术编写的代码。正如我们在 第 3 章 中发现的,反应式流 - 新流的标准  和第4章, Project Reactor -响应式应用的基础,响应式编程给我们带来了很多好处。首先是通过启用异步通信来优化资源使用的能力。反过来,这样的模型也非常适合构建非阻塞 I/O 通信。此外,丑陋的异步代码可以使用 Reactor 等反应库转换为干净的代码。 Reactor 带来了可简化应用程序开发的大量功能。

然而,除了好处之外,测试此类代码也存在相当大的缺点。首先,由于该代码是异步的,因此没有简单的方法来选择返回的元素并检查它是否正确。正如我们可能记得的 第 3 章反应式流 - 新流的标准< /em>,我们可以从任何 Publisher 通过实现 Subscriber接口收集元素并使用它来检查发出的结果的准确性。我们可能最终会得到一个复杂的解决方案,这对于开发人员在测试代码时不是一个理想的情况。

幸运的是,Reactor 的团队尽了最大的努力来简化使用 Reactive Streams 编写的代码的验证。

使用 StepVerifier 测试反应流


出于测试目的,Reactor 提供一个额外的 reactor-test 提供 StepVerifier的模块。 StepVerifier 提供了一个流畅的API,用于为任何构建验证流程 发布者。在以下小节中,我们将介绍关于 反应堆测试的所有内容< /strong> 模块,从基本要素开始,到高级测试用例结束。

 

StepVerifier 的要点

验证 Publisher 的主要方法有两种。第一个是 StepVerifier.<T>create(Publisher<T> source)。可以使用此技术 构建的测试如下所示:

StepVerifier
   .create(Flux.just("foo", "bar"))
   .expectSubscription()
   .expectNext("foo")
   .expectNext("bar")
   .expectComplete()
   .verify();

在这个例子中,我们的 Publisher 应该产生两个特定的元素,随后的操作验证特定的元素是否已经交付给最终的订阅者。从前面的示例中,我们可能会了解 StepVerifier API 的一部分的工作原理。该类提供的构建器技术允许我们定义在验证过程中事件发生的顺序。根据上述代码,第一个发出的事件必须是与订阅有关的事件,接下来的事件必须是 "foo" and "bar" strings. 最后,StepVerifier#expectCompletion 定义了终端信号的存在,在我们的例子中它必须是调用Subscriber#onComplete 或简单地成功完成给定的 Flux。要执行 验证,或者换句话说,订阅创建流——我们必须调用 .verify() 方法。这是一个阻塞调用,因此它将阻塞执行,直到流发出所有预期事件。

通过使用这种简单的技术,我们可以验证 Publisher 有大量的元素和事件。但是,很难用大量元素来验证流程。在检查我们的发布者是否发出特定数量的元素而不是 特定值更重要的情况下,.expectNextCount()方法可能有用。这个如下代码所示:

StepVerifier
   .create(Flux.range(0, 100))
   .expectSubscription()
   .expectNext(0)
   .expectNextCount(98)
   .expectNext(99)
   .expectComplete()
   .verify();

 

 

正如我们在前面的章节中可能记得的那样, Flux.range(0, 100) 从 099 包括在内。在这种情况下,更重要的是检查这是否发出了特定数量的元素,例如,元素是否以正确的顺序发出。 .expectNext() and .expectNextCount()  ;一起。根据代码, .expectNext(0) 语句将检查第一个元素。然后测试流程将检查给定的发布者是否产生了另一个98元素,因此给定的生产者在给定点发出99元素总共。由于我们的发布者应该产生100元素,那么最后一个元素应该是99,这是使用 .expectNext(99) 语句。

尽管 .expectNextCount() 方法解决了部分问题,但在某些情况下,仅检查发射元素的计数是不够的。例如,在验证负责按特定规则过滤或选择元素的代码部分时,检查所有发出的项目是否与定义的过滤规则匹配非常重要。为此, StepVerifier 可以使用 Java Hamcrest(参见 http://hamcrest .org/JavaHamcrest)。下面的代码描述了一个使用这个库的单元测试:

Publisher<Wallet> usersWallets = findAllUsersWallets();
StepVerifier
   .create(usersWallets)
   .expectSubscription()
   .recordWith(ArrayList::new)
   .expectNextCount(1)
   .consumeRecordedWith(wallets -> assertThat(
      wallets,
      everyItem(hasProperty("owner", equalTo("admin")))
   ))
   .expectComplete()
   .verify();

使用前面的示例,我们可以看到如何记录所有元素,然后将它们与给定的匹配器匹配。与前面的示例相比,每个期望仅涵盖对一个元素或指定数量元素的验证,.consumeRecordedWith()使得验证所有元素成为可能 由给定的 Publisher 发布。需要注意的是,.consumeRecordedWith() 仅在指定了.recordWith() 时有效。反过来,我们应该仔细定义将存储记录的集合类。在多线程发布者的情况下,用于记录事件的集合类型应该支持并发访问,所以在这些情况下,最好使用.recordWith(ConcurrentLinkedQueue::new)而不是.recordWith(ArrayList::new),因为与ConcurrentLinkedQueue是线程安全的>数组列表。

 

从前面几段中,我们可以熟悉 Reactor Test API 的要领。除此之外,还有其他功能类似的方法。例如,下一个元素的 expectation 可能被定义为如下代码所示:

StepVerifier
   .create(Flux.just("alpha-foo", "betta-bar"))
   .expectSubscription()
   .expectNextMatches(e -> e.startsWith("alpha"))
   .expectNextMatches(e -> e.startsWith("betta"))
   .expectComplete()
   .verify();

.expectNextMatches().expectNext() 之间的唯一区别在于前者可以定义 the 自定义匹配器Predicate,使其比后者更灵活。这是因为 .expectNext() 是基于元素的比较使用 .equals() 方法。

类似地, .assertNext() 和.consumeNextWith()使得 可以编写自定义断言。需要注意的是.assertNext().consumeNextWith()的别名。 .expectNextMatches() 和.assertNext() 是前者接受一个谓词< /code>,它必须返回 truefalse,而后者接受 可能抛出异常的消费者,消费者抛出的任何 AssertionError都会被 .verify( ) 方法,如下代码所示:

StepVerifier
   .create(findUsersUSDWallet())
   .expectSubscription()
   .assertNext(wallet -> assertThat(
      wallet,
      hasProperty("currency", equalTo("USD"))
   ))
   .expectComplete()
   .verify();

最后,我们还有 未发现的错误案例,它们也是 正常系统生命周期的一部分。很少有 API 方法可以检查错误信号。最简单的是 .expectError(), 不带参数,如下代码所示:

StepVerifier
   .create(Flux.error(new RuntimeException("Error")))
   .expectError()
   .verify();

尽管我们可以验证错误是否已发出,但在某些情况下测试特定错误类型至关重要。例如,如果在用户登录期间输入了不正确的凭据,则安全服务应该发出 BadCredentialsException.class。为了验证发出的错误,我们可以使用 .expectError(Class<? extends Throwable>),如下面的代码所示:

StepVerifier
   .create(securityService.login("admin", "wrong"))
   .expectSubscription()
   .expectError(BadCredentialsException.class)
   .verify();

在错误类型检查仍然不够的情况下,还有称为 .expectErrorMatches() 和 的附加扩展。 consumeErrorWith() 允许与信号 Throwable直接交互。

在这一点上,我们可能会发现使用 Reactor 3 或任何 Reactive Streams 规范兼容库编写的测试代码的要点。 StepVerifier API 涵盖了大部分反应式工作流程。但是,当涉及到实际开发时,还有一些额外的情况。

使用 StepVerifier 进行高级测试

发布者测试的第一步是验证无限Publisher .根据 Reactive Streams 规范,无限流意味着该流永远不会调用 Subscriber#onComplete() 方法。反过来,这意味着我们之前可能学过的测试技术将不再有效。这里的问题是 StepVerifier 将无限等待完成信号。因此,测试将被阻止,直到它被杀死。为了解决这个问题, StepVerifier 提供了一个取消订阅的 API,当某些期望得到满足时,它会从源中取消订阅,如以下代码所示:

Flux<String> websocketPublisher = ...
StepVerifier
   .create(websocketPublisher)
   .expectSubscription()
   .expectNext("Connected")
   .expectNext("Price: $12.00")
   .thenCancel()
   .verify();

 

 

上面的代码告诉我们,在获得 连接 并且收到 价格:$12.00< /代码> 消息。

系统验证期间的另一个关键阶段是检查Publisher 的背压行为。例如,通过 WebSocket 与外部系统交互会导致只推送 Publisher。防止此类行为的一种简单方法是使用 .onBackpressureBuffer() 操作符保护下游。为了检查系统是否按预期 选择的背压策略运行,我们必须手动控制用户需求。为了做到这一点, StepVerifier 提供了 .thenRequest() 方法,它允许我们控制 订户需求。 这在以下代码中有所描述:

Flux<String> websocketPublisher = ...
Class<Exception> expectedErrorClass = 
    reactor.core.Exceptions.failWithOverflow().getClass();

StepVerifier
   .create(websocketPublisher.onBackpressureBuffer(5), 0)
   .expectSubscription()
   .thenRequest(1)
   .expectNext("Connected")
   .thenRequest(1)
   .expectNext("Price: $12.00")
   .expectError(expectedErrorClass)
   .verify();

从前面的示例中,我们可以了解如何使用 .thenRequest() 方法来验证背压行为。反过来,预计在某个时间点会发生溢出,我们将收到溢出错误。请注意 在前面的例子中,我们使用了 StepVerifier.create() 方法的重载,它接受初始订阅者的需求作为第二个参数。在一种参数方法的重载中, 默认需求是 Long.MAX_VALUE, 指无限需求。

 StepVerifiers API 提供的高级功能之一是能够在特定验证后运行附加操作。例如,在产生 过程的元素需要一些额外的外部交互的情况下,这可以通过 .then() 方法来完成。

 

 

我们还将使用 Reactor Core 库的测试包中的 TestPublisherTestPublisher 实现了 Reactive Stream Publisher 并可以直接触发 用于测试目的的 onNext()onComplete() 和 onError() 事件。下一个示例演示如何在测试执行期间触发新事件:

TestPublisher<String> idsPublisher = TestPublisher.create();

StepVerifier
   .create(walletsRepository.findAllById(idsPublisher))
   .expectSubscription()
   .then(() -> idsPublisher.next("1"))                               // (1)
   .assertNext(w -> assertThat(w, hasProperty("id", equalTo("1"))))  // (2)
   .then(() -> idsPublisher.next("2"))                               // (3) 
   .assertNext(w -> assertThat(w, hasProperty("id", equalTo("2"))))  // (4)
   .then(idsPublisher::complete)                                     // (5)
   .expectComplete()
   .verify();

在此示例中,需要验证 WalletRepository 搜索钱包是 正确 给定ids。反过来,钱包存储库的具体要求之一是搜索数据,这意味着上游应该是热门 Publisher。 在我们的示例中,我们使用 TestPublisher.next() 结合 StepVerifier.then()。步骤(1)(3)仅在之前的步骤经过验证后才发送新请求。 Step(2)验证使用 step(1) 生成的请求是否已成功并相应地处理,而 line (4) 验证步骤 (3)。 Step (5) 命令TestPublisher 完成请求流 之后StepVerifier 验证响应流是否也已完成。

此技术通过在订阅实际发生后启用事件 生产 发挥重要作用。通过这种方式,我们可以验证在该操作之后是否立即找到了发出的 ID,并且 walletsRepository 的行为符合预期。

处理虚拟时间

尽管 testing 的本质在于覆盖业务逻辑,但还有另一个非常重要的部分应该考虑为出色地。要理解这个特性,我们应该首先考虑下面的代码示例:

public Flux<String> sendWithInterval() {
   return Flux.interval(Duration.ofMinutes(1))
      .zipWith(Flux.just("a", "b", "c"))
      .map(Tuple2::getT2);
}

 

这个 示例 展示了一种以特定时间间隔发布事件的简单方法。在现实世界的场景中,在同一个 API 的背后,可能隐藏着一个更复杂的机制,包括长时间的延迟、超时和事件间隔。要使用 StepVerifier 验证此类代码,我们可能会得到以下测试用例:

StepVerifier
   .create(sendWithInterval())
   .expectSubscription()
   .expectNext("a", "b", "c")
   .expectComplete()
   .verify();

后续测试将传递给我们之前的 sendWithInterval() 实现,这是我们真正想要实现的目标。但是,这个测试有一个问题。如果我们运行它几次,我们会发现 测试的平均持续时间是三分钟多一点。发生这种情况是因为 sendWithInterval() 方法在每个元素之前产生三个延迟一分钟的事件。在时间间隔或计划时间为数小时或数天的情况下,系统验证可能需要大量时间,这对于当今的持续集成来说是不可接受的。为了解决这个问题,Reactor Test 模块提供了用虚拟时间代替实时的能力,如下代码所示:

StepVerifier.withVirtualTime(() -> sendWithInterval())
   // scenario verification ...

当使用 .withVirtualTime() builder方法时,我们将Reactor中的每个Scheduler 显式替换为 reactor.test.scheduler.VirtualTimeScheduler。反过来,这样的替换意味着 Flux.interval 也将在该 调度程序 上运行。因此,所有的时间控制都可以使用 VirtualTimeScheduler#advanceTimeBy 来完成,如下面的代码所示:

StepVerifier
   .withVirtualTime(() -> sendWithInterval())
   .expectSubscription()
   .then(() -> VirtualTimeScheduler
      .get()
      .advanceTimeBy(Duration.ofMinutes(3))
   )
   .expectNext("a", "b", "c")
   .expectComplete()
   .verify();

 

正如我们可能注意到的, 在前面的示例中,我们使用 .then() 结合 VirtualTimeScheduler  API 将时间提前一定量。如果我们运行该测试,它将需要几毫秒而不是几分钟!这个结果要好得多,因为现在我们的测试行为与生成数据的实际时间间隔无关。最后,为了使我们的测试干净,我们可以用 .then()VirtualTimeScheduler 的组合literal">.thenAwait(),其行为相同。

笔记

请注意,如果 StepVerifier 没有足够的提前时间,测试可能会永远挂起。

为了限制在验证场景上花费的时间,可以使用 .verify(Duration t) 重载。这将抛出 AssertionError 当测试在允许的持续时间内无法验证时。 此外, .verify() 方法返回验证过程实际花费的时间。以下代码描述了这样一个用例:

Duration took = StepVerifier
   .withVirtualTime(() -> sendWithInterval())
   .expectSubscription()
   .thenAwait(Duration.ofMinutes(3))
   .expectNext("a", "b", "c")
   .expectComplete()
   .verify();

System.out.println("Verification took: " + took);

如果在指定的等待时间内检查是否没有事件很重要,则有一个额外的 API 方法称为 .expectNoEvents()。使用此方法,我们可以检查是否使用指定的时间间隔产生了事件,如下所示:

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(), 如下代码所示:

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(), 测试将永远挂起。

验证反应式上下文

最后,最不常见的验证是 Reactor的 Context .我们在第4章中介绍了 Context 的作用和机制,  ;Project Reactor - 响应式应用的基础。假设我们想要 验证身份验证服务的响应式 API。 为了 对用户进行身份验证, LoginService 预计订阅者将提供一个  ;Context 持有 身份验证信息的 :

StepVerifier
   .create(securityService.login("admin", "admin"))
   .expectSubscription()
   .expectAccessibleContext()
   .hasKey("security")
   .then()
   .expectComplete()
   .verify();

 

在前面的代码中,我们可以看到如何检查是否存在可访问的 Context 实例。我们可以看到,.expectAccessibleContext() 验证可能失败的情况只有一种。仅当返回的 Publisher 不是 Reactor 类型(Flux单声道)。因此,后续的 Context 只有在存在可访问的上下文时才会执行验证。除了 .hasKey()之外,还有许多其他方法可以对当前上下文进行详细验证。为了退出上下文验证,构建器提供了 .then() 方法。

总结本节,我们了解了 Reactor 测试如何帮助 Reactive Streams 测试。本节几乎涵盖了对一小段响应式代码进行单元测试所需的所有内容,但 Spring Framework 仍然提供了更多 用于测试响应式系统的功能。

测试 WebFlux


在本节中,我们将介绍为基于 WebFlux 的应用程序的 验证 引入的新增功能。在这里,我们将重点检查模块的兼容性、应用程序完整性、公开的通信协议、外部 API 和客户端库。所以它不再是简单的单元测试,而是更多关于组件和集成测试。

使用 WebTestClient 测试控制器

假设我们测试一个 Payment 服务。在这种情况下,假设 Payment服务支持 GET/payments 端点的 ="literal">POST 方法。第一个 HTTP 调用是 responsible 用于检索当前用户的已执行付款列表。反过来,第二个可以提交新的付款。该休息控制器的实现如下所示:

@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);
   }
}

验证我们服务的第一步是写下与服务进行网络交换的所有期望。为了与 WebFlux 端点交互,更新后的 spring-test 模块为我们提供了新的 org.springframework.test.web.reactive.server.WebTestClient< /code> 类。 WebTestClient 类似于 org.springframework.test.web.servlet.MockMvc。这些测试 Web 客户端之间的唯一区别是 WebTestClient 旨在测试 WebFlux 端点。例如,使用 WebTestClientMockito 库,我们可以在以下方式:

@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 fluent 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;
    ...
}

在这里,我们不再需要配置 WebTestClient 。这是因为所有必需的默认值都是由 Spring Boot 的自动配置配置的。

笔记

请注意,如果应用程序使用 Spring Security 模块,则可能需要额外的测试配置。我们可以添加对 spring-security-test 模块的依赖,该模块提供 @WithMockUser 注解,特别针对模拟用户身份验证。开箱即用的@WithMockUser 机制支持WebTestClient。然而,即使 @WithMockUser 完成了它的工作,默认启用的 CSRF 可能会给端到端测试增加一些不良障碍。注意;仅 @SpringBootTest 或除 @WebFluxTest 之外的任何其他 Spring Boot 测试运行器需要额外配置 CSRF,默认情况下禁用 CSRF .

为了测试 Payment ser​​vice 示例的第二部分,我们需要查看 的业务逻辑PaymentService 实现。这是使用以下代码定义的:

@Service
public class DefaultPaymentService implements PaymentService {

   private final PaymentRepository paymentRepository;
   private final WebClient         client;

   public DefaultPaymentService(PaymentRepository repository, 
                                WebClient.Builder builder) {
      this.paymentRepository = repository;
      this.client = builder.baseUrl("http://api.bank.com/submit").build();
   }

   @Override
   public Mono<String> send(Mono<Payment> payment) {
     return payment
              .zipWith(
                 ReactiveSecurityContextHolder.getContext(),
                 (p, c) -> p.withUser(c.getAuthentication().getName())
              )
              .flatMap(p -> client
                 .post()
                 .syncBody(p)
                 .retrieve()
                 .bodyToMono(String.class)
                 .then(paymentRepository.save(p)))
              .map(Payment::getId);
   }

   @Override
   public Flux<Payment> list() {
      return ReactiveSecurityContextHolder
               .getContext()
               .map(SecurityContext::getAuthentication)
               .map(Principal::getName)
               .flatMapMany(paymentRepository::findAllByUser);
   }
}

首先,这里要注意的是,返回所有用户付款的方法只与数据库交互。相比之下,支付的提交逻辑以及数据库交互需要通过 WebClient 与外部系统进行额外交互。在我们的示例中,我们使用了响应式 Spring Data MongoDB 模块,该模块支持用于测试目的的嵌入式模式。相比之下,无法嵌入与外部银行提供商的交互。因此,我们需要使用 WireMock (http://wiremock.org ) 或以某种方式模拟传出的 HTTP 请求。使用 WireMock 模拟服务是 WebMVC 和 WebFlux 的有效选项。

 

但是,当从测试的角度比较 WebMVC 和 WebFlux 功能时,前者具有优势,因为它具有开箱即用的模拟传出 HTTP 交互的能力。不幸的是,在 Spring Boot 2.0 和 Spring Framework 5.0.x 中不支持与 WebClient 类似的功能。但是,这里有一个技巧可以模拟传出 HTTP 调用的响应。在开发人员遵循WebClient 使用WebClient.Builder构建技术的情况下,可以模拟< code class="literal">org.springframework.web.reactive.function.client.ExchangeFunction,在 WebClient 请求处理中起着至关重要的作用,如以下代码所示:

public interface ExchangeFunction {
   Mono<ClientResponse> exchange(ClientRequest request);
   ...
}

使用以下测试的配置,可以自定义 WebClient.Builder 并提供一个mocked ,或存根实现 ExchangeFunction

@TestConfiguration
public class TestWebClientBuilderConfiguration {
   @Bean
   public WebClientCustomizer testWebClientCustomizer(
      ExchangeFunction exchangeFunction
   ) {
      return builder -> builder.exchangeFunction(exchangeFunction);
   }
} 

这个 hack 让我们能够验证形成的 ClientRequest 的正确性。反过来,通过正确实施 ClientResponse,我们可以 模拟 网络活动和与 外部服务的交互。完成的测试可能如下所示:

@ImportAutoConfiguration({
   TestSecurityConfiguration.class,
   TestWebClientBuilderConfiguration.class
})
@RunWith(SpringRunner.class)
@WebFluxTest
@AutoConfigureWebTestClient
public class PaymentControllerTests {
   @Autowired
   WebTestClient client;
    
   @MockBean
   ExchangeFunction exchangeFunction;

   @Test
   @WithMockUser
   public void verifyPaymentsWasSentAndStored() {
      Mockito
         .when(exchangeFunction.exchange(Mockito.any()))
         .thenReturn(
            Mono.just(MockClientResponse.create(201, Mono.empty())));

      client.post()
            .uri("/payments/")
            .syncBody(new Payment())
            .exchange()
            .expectStatus().is2xxSuccessful()
            .returnResult(String.class)
            .getResponseBody()
            .as(StepVerifier::create)
            .expectNextCount(1)
            .expectComplete()
            .verify();

      Mockito.verify(exchangeFunction).exchange(Mockito.any());
   }
}

在这个例子中,我们使用了 @WebFluxTest 注解来禁用完全自动配置并且只应用WebFlux的相关配置,包括WebTestClient@MockBeen 注解用于将 ExchangeFunction 的模拟实例注入 Spring IoC 容器。反过来, Mockito 和 WebTestClient 的组合允许我们构建一个端到端的验证所需的业务逻辑。

尽管可以以类似于 WebMVC 的方式在 WebFlux 应用程序中模拟传出 HTTP 通信,但请谨慎操作!这种方法有其缺陷。现在,应用程序测试是在所有 HTTP 通信都使用 WebClient 实现的假设下构建的。这不是服务合同,而是实施细节。因此,如果任何服务出于任何原因更改其 HTTP 客户端库,相应的测试将无缘无故地中断。因此,最好使用WireMock来模拟外部服务。这种方法不仅 假设一个实际的 HTTP 客户端库,而且还测试通过网络发送的实际请求-响应有效负载。根据经验,在测试具有业务逻辑的单独类时模拟 HTTP 客户端库可能是可以接受的,但对于整个服务的黑盒测试绝对不行。

 

 

一般来说,在 以下技术中,我们可以验证构建在标准 Spring WebFlux API 之上的所有业务逻辑。 WebTestClient 有一个表达 API 允许我们使用 .bindToRouterFunction() or  .bindToWebHandler() 验证常规 REST 控制器和新路由器功能。此外,使用 WebTestClient, 我们可以使用 .bindToServer()进行黑盒测试,并提供完整的服务器 HTTP 地址。以下测试检查有关网站 http://www.bbc.com 的一些假设并失败(如预期)由于预期和实际响应机构之间的差异:

WebTestClient webTestClient = WebTestClient
   .bindToServer()
   .baseUrl("http://www.bbc.com")
   .build();

webTestClient
   .get()
   .exchange()
   .expectStatus().is2xxSuccessful()
   .expectHeader().exists("ETag")
   .expectBody().json("{}");

这个例子展示了WebFlux的 WebClientWebTestClient 类不仅 给我们 异步非阻塞客户端进行HTTP通信,但也是用于 integration 测试的流畅 API。

测试 WebSocket

最后,这里应该讨论的一个主题是 验证 流系统。在本节中,我们将仅介绍 WebSocket 服务器和客户端的测试。正如我们将在 第 6 章中学到的, WebFlux 异步非阻塞通信,除了 WebSocket API,还有一个 Server-Sent EventsSSE) 流数据协议,它为我们提供了类似的功能。尽管如此,由于 SSE 的实现几乎与常规控制器的实现相同,因此上一节中的所有 verification 技术将对这种情况也有效。因此,现在唯一不清楚的是如何测试 WebSocket。

不幸的是,WebFlux 没有为 WebSocket API 测试提供开箱即用的解决方案。尽管如此,我们可以使用标准工具集来构建验证类。即我们可以使用WebSocketClient 连接到目标服务器并验证接收到的数据的正确性。以下代码描述了这种方法:

new ReactorNettyWebSocketClient()
      .execute(uri, new WebSocketHandler() {...})

 

尽管我们可以连接到服务器,但很难使用 StepVerifier 来验证传入的数据。首先, .execute() 返回 Mono  而不是从WebSocket 连接。反过来,我们需要检查双方的交互,这意味着在某些情况下,检查传入数据是否是传出消息的结果很重要。这种系统的一个例子可能是交易平台。假设我们有一个加密交易平台,该平台提供发送交易和接收交易结果的能力。业务要求之一是进行比特币交易的能力。这意味着用户可以使用该平台出售或购买比特币并观察交易结果。为了验证功能,我们需要检查传入的交易是传出请求的结果。从测试的角度来看,很难处理  WebSocketHandler 检查所有极端情况。因此,从测试的角度来看,WebSocket 客户端的界面理想情况下如下所示:

interface TestWebSocketClient {
   Flux<WebSocketMessage> sendAndReceive(Publisher<?> outgoingSource);
}

为了使标准 WebSocketClient 适应提议的 TestWebSocketClient,我们需要经过以下步骤。

首先,我们需要通过 Mono 处理 WebSocketSession,在 WebSocketHandler 中给出code>,如下代码所示:

Mono.create(sink ->
   sink.onCancel(
         client.execute(uri, session -> {
                    sink.success(session);
                    return Mono.never();
               })
               .doOnError(sink::error)
               .subscribe()
    )
);

使用 Mono.create() 和 MonoSink, 我们可以采用 老派的处理方式带有会话的异步回调并将其重定向到另一个流。反过来,我们需要关心 WebSocketHandler#handle方法的 正确的返回类型。这是因为返回类型控制了打开的连接的生命周期。另一方面,应在 MonoSink 通知我们后立即取消连接。因此, Mono.never()  是最佳候选者,它与 .doOnError( sink::error) 以及 通过 sink.onCancel() 处理取消, 完成了采用。

 

关于提议的 API 应该执行的第二步是使用以下技术调整 WebSocketSession

public Flux<WebSocketMessage> sendAndReceive(
   Publisher<?> outgoingSource
) {
   ...
   .flatMapMany(session ->
       session.receive()
              .mergeWith(
                 Flux.from(outgoingSource)
                     .map(Object::toString)
                     .map(session::textMessage)
                     .as(session::send)
                     .then(Mono.empty())
               )
    );
}

在这里,我们下游传入 WebSocketMessages 并将传出消息发送到服务器。正如我们可能已经注意到的,在这个例子中,我们使用了普通的对象转换,它可能被复杂的消息映射所取代。

最后,使用该 API,我们可以为上述功能构建以下验证流程:

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = 
                SpringBootTest.WebEnvironment.DEFINED_PORT)
public class WebSocketAPITests {
   @Test
   @WithMockUser
   public void checkThatUserIsAbleToMakeATrade() {
      URI uri = URI.create("ws://localhost:8080/stream");
      TestWebSocketClient client = TestWebSocketClient.create(uri);
      TestPublisher<String> testPublisher = TestPublisher.create();
      Flux<String> inbound = testPublisher
         .flux()
         .subscribeWith(ReplayProcessor.create(1))
         .transform(client::sendAndReceive)
         .map(WebSocketMessage::getPayloadAsText);

      StepVerifier
         .create(inbound)
         .expectSubscription()
         .then(() -> testPublisher.next("TRADES|BTC"))
         .expectNext("PRICE|AMOUNT|CURRENCY")
         .then(() -> testPublisher.next("TRADE: 10123|1.54|BTC"))
         .expectNext("10123|1.54|BTC")
         .then(() -> testPublisher.next("TRADE: 10090|-0.01|BTC"))
         .expectNext("10090|-0.01|BTC")
         .thenCancel()
         .verify();
    }
}

我们在这个例子中做的第一件事是配置 WebEnvironment。通过设置 WebEnvironment.DEFINED_PORT,我们告诉 Spring Framework 它应该在配置的端口上可用。这是一个必不可少的步骤,因为 WebSocketClient 只能通过真正的 HTTP 调用连接到定义的处理程序。然后我们准备入站流。在我们的例子中,缓存第一条消息很重要,它是通过 TestPublisher 在 .then()中发送的 一步。这是因为 .then() 可能会在会话被检索之前被调用,这意味着 第一条消息可能会被忽略,并且我们可能无法连接到我们的比特币交易。下一步是验证发送的交易是否已通过,并且我们已收到正确的响应。

最后需要提一下的是,伴随着WebSocket API 验证,可能会有我们需要mock的情况 通过 WebSocketClient 与外部服务交互。不幸的是,没有简单的方法来模拟交互。首先,这是因为我们没有一个通用的 WebSocketClient.Build 这可能会被嘲笑。反过来,自动装配WebSocketClient 没有开箱即用的方法。因此,我们可能拥有的唯一解决方案是连接模拟服务器。

概括


在本章中,我们学习了如何测试使用 Reactor 3 或任何基于 Reactive Streams 的库编写的异步代码。反过来,我们介绍了基于 WebFlux 模块和 Spring Test 模块测试反应式 Spring 应用程序的要点。然后,使用 WebTestClient,我们学会了一种方法来单独验证单个控制器,或模拟外部交互的整个应用程序。此外,我们了解到 知道如何测试 Reactor 3 有助于我们在集成中测试整个系统。除了日常业务逻辑检查之外,我们还学习了一些使用模拟安全性的技巧,这也是现代 Web 应用程序的重要组成部分。最后,本章以 WebSocket 测试的一些技巧和窍门结束。在这里,我们看到了 5.0.x Spring Test 模块的一些限制。尽管如此,通过采用 WebSocketClient,我们学会了如何构建可测试的数据流并检查客户端-服务器交互的正确性。不幸的是,我们还发现没有简单的方法来模拟 WebSocketClient 以进行服务器到服务器的交互。

 

 

由于我们已经完成了系统的测试,是时候学习如何将 Web 应用程序部署到云中并学习如何在生产环境中对其进行监控了。因此,在下一章中,我们将介绍如何使用 Pivotal Cloud,它是帮助我们监控整个反应式系统的工具集。我们还将介绍 Spring 5 如何帮助我们解决问题。