【问题标题】:How to mock Spring WebFlux WebClient?如何模拟 Spring WebFlux WebClient?
【发布时间】:2017-12-31 06:10:30
【问题描述】:

我们编写了一个小型 Spring Boot REST 应用程序,它在另一个 REST 端点上执行 REST 请求。

@RequestMapping("/api/v1")
@SpringBootApplication
@RestController
@Slf4j
public class Application
{
    @Autowired
    private WebClient webClient;

    @RequestMapping(value = "/zyx", method = POST)
    @ResponseBody
    XyzApiResponse zyx(@RequestBody XyzApiRequest request, @RequestHeader HttpHeaders headers)
    {
        webClient.post()
            .uri("/api/v1/someapi")
            .accept(MediaType.APPLICATION_JSON)
            .contentType(MediaType.APPLICATION_JSON)
            .body(BodyInserters.fromObject(request.getData()))
            .exchange()
            .subscribeOn(Schedulers.elastic())
            .flatMap(response ->
                    response.bodyToMono(XyzServiceResponse.class).map(r ->
                    {
                        if (r != null)
                        {
                            r.setStatus(response.statusCode().value());
                        }

                        if (!response.statusCode().is2xxSuccessful())
                        {
                            throw new ProcessResponseException(
                                    "Bad status response code " + response.statusCode() + "!");
                        }

                        return r;
                    }))
            .subscribe(body ->
            {
                // Do various things
            }, throwable ->
            {
                // This section handles request errors
            });

        return XyzApiResponse.OK;
    }
}

我们是 Spring 新手,在为这个小代码 sn-p 编写单元测试时遇到了麻烦。

是否有一种优雅的(反应式)方式来模拟 webClient 本身或启动 webClient 可以用作端点的模拟服务器?

【问题讨论】:

    标签: spring rest unit-testing mocking reactive-programming


    【解决方案1】:

    我们通过提供一个自定义的ExchangeFunction 来实现这一点,该ExchangeFunction 只是将我们想要的响应返回给WebClientBuilder

    
    webClient = WebClient.builder()
                .exchangeFunction(clientRequest -> 
                        Mono.just(ClientResponse.create(HttpStatus.OK)
                        .header("content-type", "application/json")
                        .body("{ \"key\" : \"value\"}")
                        .build())
                ).build();
    
    myHttpService = new MyHttpService(webClient);
    
    Map<String, String> result = myHttpService.callService().block();
    
    // Do assertions here
        
    

    如果我们想使用 Mokcito 来验证是否调用了,或者在类中的多个单元测试中重用 WebClient,我们还可以模拟交换函数:

    @Mock
    private ExchangeFunction exchangeFunction;
    
    @BeforeEach
    void init() {
        WebClient webClient = WebClient.builder()
                .exchangeFunction(exchangeFunction)
                .build();
    
        myHttpService = new MyHttpService(webClient);
    }
    
    @Test
    void callService() {
        when(exchangeFunction.exchange(any(ClientRequest.class)))
       .thenReturn(buildMockResponse());
        Map<String, String> result = myHttpService.callService().block();
    
        verify(exchangeFunction).exchange(any());
    
        // Do assertions here
    }
        
    

    注意:如果您在调用 when 时收到与发布者相关的空指针异常,则您的 IDE 可能导入了 Mono.when 而不是 Mockito.when

    来源:

    【讨论】:

    • 我更喜欢不要在测试中使用.block(),因为这有点违背如何在 Reactor 中编写测试的主要方法。改用StepVerifer - 包装你的异步链并断言它的结果。见这里projectreactor.io/docs/test/release/api/reactor/test/…
    • 非常有效的点@povisenko。有时我只是发现在单元测试一些琐碎的事情时使用 .block() 更具可读性。
    • 正如@homeOfTheWizard 提到的,这应该是公认的答案。
    • @Renette 是的,同意你的看法。 .block() 确实可以满足 medium.com/swlh/stepverifier-vs-block-in-reactor-ca754b12846b
    • 我应该为 List 使用什么匹配器? webClient.post().uri(url).body(listOfCustomObjects, CustomObject.class) 这就是我的 webclient 调用的编写方式。如何模拟body 方法?
    【解决方案2】:

    通过以下方法,可以使用 Mockito 模拟 WebClient 以进行如下调用:

    webClient
    .get()
    .uri(url)
    .header(headerName, headerValue)
    .retrieve()
    .bodyToMono(String.class);
    

    webClient
    .get()
    .uri(url)
    .headers(hs -> hs.addAll(headers));
    .retrieve()
    .bodyToMono(String.class);
    

    模拟方法:

    private static WebClient getWebClientMock(final String resp) {
        final var mock = Mockito.mock(WebClient.class);
        final var uriSpecMock = Mockito.mock(WebClient.RequestHeadersUriSpec.class);
        final var headersSpecMock = Mockito.mock(WebClient.RequestHeadersSpec.class);
        final var responseSpecMock = Mockito.mock(WebClient.ResponseSpec.class);
    
        when(mock.get()).thenReturn(uriSpecMock);
        when(uriSpecMock.uri(ArgumentMatchers.<String>notNull())).thenReturn(headersSpecMock);
        when(headersSpecMock.header(notNull(), notNull())).thenReturn(headersSpecMock);
        when(headersSpecMock.headers(notNull())).thenReturn(headersSpecMock);
        when(headersSpecMock.retrieve()).thenReturn(responseSpecMock);
        when(responseSpecMock.bodyToMono(ArgumentMatchers.<Class<String>>notNull()))
                .thenReturn(Mono.just(resp));
    
        return mock;
    }
    

    【讨论】:

    • 似乎是一个非常简单的解决方案,没有存根
    • @IgorsSakels 如何用这种方式验证?
    【解决方案3】:

    您可以使用 OkHttp 团队的MockWebServer。基本上,Spring 团队也将它用于他们的测试(至少他们怎么说here)。这是一个参考来源的示例:

    根据Tim's blog post,假设我们有以下服务:

    class ApiCaller {
        
       private WebClient webClient;
        
       ApiCaller(WebClient webClient) {
          this.webClient = webClient;
       }
        
       Mono<SimpleResponseDto> callApi() {
           return webClient.put()
                           .uri("/api/resource")
                           .contentType(MediaType.APPLICATION_JSON)
                           .header("Authorization", "customAuth")
                           .syncBody(new SimpleRequestDto())
                           .retrieve()
                           .bodyToMono(SimpleResponseDto.class);
        }
    }
    

    然后可以按以下方式设计测试(与 origin 相比,我使用 StepVerifier 更改了在 Reactor 中测试异步链的方式):

    class ApiCallerTest {
      
      private final MockWebServer mockWebServer = new MockWebServer();
      private final ApiCaller apiCaller = new ApiCaller(WebClient.create(mockWebServer.url("/").toString()));
      
      @AfterEach
      void tearDown() throws IOException {
         mockWebServer.shutdown();
      }
      
      @Test
      void call() throws InterruptedException {
           mockWebServer.enqueue(new MockResponse().setResponseCode(200)
                                                   .setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
                                                   .setBody("{\"y\": \"value for y\", \"z\": 789}")
          );
          
          //Asserting response
          StepVerifier.create(apiCaller.callApi())
                      .assertNext(res -> {
                            assertNotNull(res);
                            assertEquals("value for y", res.getY());
                            assertEquals("789", res.getZ());
                      })
                      .verifyComplete();
     
         //Asserting request
         RecordedRequest recordedRequest = mockWebServer.takeRequest();
         //use method provided by MockWebServer to assert the request header
         recordedRequest.getHeader("Authorization").equals("customAuth");
         DocumentContext context = >JsonPath.parse(recordedRequest.getBody().inputStream());
         //use JsonPath library to assert the request body
         assertThat(context, isJson(allOf(
                withJsonPath("$.a", is("value1")),
                withJsonPath("$.b", is(123))
         )));
      }
    }
    

    【讨论】:

    • 请注意,在引用其他人撰写的材料时,帖子底部的链接不足以给予信任。了解更多here
    • 在尝试此代码时,我不断收到以下错误...有人能帮忙吗? ApiCallerTest.java:19:错误:无法访问 ExternalResource private final ApiCaller apiCaller = new ApiCaller(WebClient.create(mockWebServer.url("/").toString())); ^ org.junit.rules.ExternalResource 的类文件未找到
    • 这是一个集成测试而不是单元测试。我们不是在这里嘲笑WebClient
    • @povisenko 这是您的粗鲁评论。但我足够包容,可以把我的知识告诉你。您正在使用模拟服务器模拟响应。您没有在这里完全测试您的 Web 客户端,而是在断言响应。尝试对您的代码 sn-p 运行突变测试/坑测试,它会撕掉这个测试套件。这就是我对你的回答投票赞成 stackoverflow.com/a/54254718/2852528 的原因。并对抄袭说不。正如鲍姆在这篇文章的第一条评论中所说,你需要努力阅读这个stackoverflow.com/help/referencing。祝你好运!
    • @AkhilGhatiki 我已经更清楚地引用了,同意这是相关评论。我认为您不介意我在集成测试方面的一些清晰性来扩展您的敏锐度。集成测试的常用方法假设将模拟量减少到零。当有很多交叉依赖时,集成测试可以使用模拟,但显然这根本不是我们的情况。我可以建议您查看此 SE 主题至 softwareengineering.stackexchange.com/q/347323/307798 并查看 M. Fowler martinfowler.com/bliki/IntegrationTest.html 的博客文章。干杯!
    【解决方案4】:

    我使用WireMock 进行集成测试。我认为它比 OkHttp MockeWebServer 更好,支持更多的功能。这是一个简单的例子:

    public class WireMockTest {
    
      WireMockServer wireMockServer;
      WebClient webClient;
    
      @BeforeEach
      void setUp() throws Exception {
        wireMockServer = new WireMockServer(WireMockConfiguration.wireMockConfig().dynamicPort());
        wireMockServer.start();
        webClient = WebClient.builder().baseUrl(wireMockServer.baseUrl()).build();
      }
    
      @Test
      void testWireMock() {
        wireMockServer.stubFor(get("/test")
            .willReturn(ok("hello")));
    
        String body = webClient.get()
            .uri("/test")
            .retrieve()
            .bodyToMono(String.class)
            .block();
        assertEquals("hello", body);
      }
    
      @AfterEach
      void tearDown() throws Exception {
        wireMockServer.stop();
      }
    
    }
    

    如果你真的想模拟它,我推荐JMockit。没有必要多次调用when,您可以像在测试代码中一样使用相同的调用。

    @Test
    void testJMockit(@Injectable WebClient webClient) {
      new Expectations() {{
          webClient.get()
              .uri("/test")
              .retrieve()
              .bodyToMono(String.class);
          result = Mono.just("hello");
      }};
    
      String body = webClient.get()
          .uri(anyString)
          .retrieve()
          .bodyToMono(String.class)
          .block();
      assertEquals("hello", body);
    }
    
    

    【讨论】:

      【解决方案5】:

      Wire mocks 适用于集成测试,而我认为单元测试不需要它。在进行单元测试时,我只想知道是否使用所需的参数调用了我的 WebClient。为此,您需要一个 WebClient 实例的模拟。或者你可以注入一个 WebClientBuilder。

      让我们考虑一下执行如下发布请求的简化方法。

      @Service
      @Getter
      @Setter
      public class RestAdapter {
      
          public static final String BASE_URI = "http://some/uri";
          public static final String SUB_URI = "some/endpoint";
      
          @Autowired
          private WebClient.Builder webClientBuilder;
      
          private WebClient webClient;
      
          @PostConstruct
          protected void initialize() {
              webClient = webClientBuilder.baseUrl(BASE_URI).build();
          }
      
          public Mono<String> createSomething(String jsonDetails) {
      
              return webClient.post()
                      .uri(SUB_URI)
                      .accept(MediaType.APPLICATION_JSON)
                      .body(Mono.just(jsonDetails), String.class)
                      .retrieve()
                      .bodyToMono(String.class);
          }
      }
      

      createSomething 方法只接受一个字符串,为简化示例而假定为 Json,在 URI 上执行发布请求并返回假定为字符串的输出响应正文。

      该方法可以使用 StepVerifier 进行单元测试,如下所示。

      public class RestAdapterTest {
          private static final String JSON_INPUT = "{\"name\": \"Test name\"}";
          private static final String TEST_ID = "Test Id";
      
          private WebClient.Builder webClientBuilder = mock(WebClient.Builder.class);
          private WebClient webClient = mock(WebClient.class);
      
          private RestAdapter adapter = new RestAdapter();
          private WebClient.RequestBodyUriSpec requestBodyUriSpec = mock(WebClient.RequestBodyUriSpec.class);
          private WebClient.RequestBodySpec requestBodySpec = mock(WebClient.RequestBodySpec.class);
          private WebClient.RequestHeadersSpec requestHeadersSpec = mock(WebClient.RequestHeadersSpec.class);
          private WebClient.ResponseSpec responseSpec = mock(WebClient.ResponseSpec.class);
      
          @BeforeEach
          void setup() {
              adapter.setWebClientBuilder(webClientBuilder);
              when(webClientBuilder.baseUrl(anyString())).thenReturn(webClientBuilder);
              when(webClientBuilder.build()).thenReturn(webClient);
              adapter.initialize();
          }
      
          @Test
          @SuppressWarnings("unchecked")
          void createSomething_withSuccessfulDownstreamResponse_shouldReturnCreatedObjectId() {
              when(webClient.post()).thenReturn(requestBodyUriSpec);
              when(requestBodyUriSpec.uri(RestAdapter.SUB_URI))
                      .thenReturn(requestBodySpec);
              when(requestBodySpec.accept(MediaType.APPLICATION_JSON)).thenReturn(requestBodySpec);
              when(requestBodySpec.body(any(Mono.class), eq(String.class)))
                      .thenReturn(requestHeadersSpec);
              when(requestHeadersSpec.retrieve()).thenReturn(responseSpec);
              when(responseSpec.bodyToMono(String.class)).thenReturn(Mono.just(TEST_ID));
      
      
              ArgumentCaptor<Mono<String>> captor
                      = ArgumentCaptor.forClass(Mono.class);
      
              Mono<String> result = adapter.createSomething(JSON_INPUT);
      
              verify(requestBodySpec).body(captor.capture(), eq(String.class));
              Mono<String> testBody = captor.getValue();
              assertThat(testBody.block(), equalTo(JSON_INPUT));
              StepVerifier
                      .create(result)
                      .expectNext(TEST_ID)
                      .verifyComplete();
          }
      }
      

      请注意,'when' 语句会测试除请求正文之外的所有参数。即使其中一个参数不匹配,单元测试也会失败,从而断言所有这些。然后,请求主体在单独的验证和断言中被断言,因为“单声道”不能等同。然后使用步骤验证器验证结果。

      然后,我们可以使用线模拟进行集成测试,如其他答案中所述,以查看此类是否正确连接,并使用所需的主体调用端点等。

      【讨论】:

      • 回购代码在哪里?我正在测试它,但它不起作用……它看起来非常好,但不起作用,至少对我来说是这样。
      【解决方案6】:

      我已经尝试了这里已经给出的答案中的所有解决方案。 你的问题的答案是: 这取决于您是要进行单元测试还是集成测试。

      出于单元测试目的,模拟 WebClient 本身过于冗长并且需要太多代码。模拟 ExchangeFunction 更简单更容易。 为此,接受的答案必须是 @Renette 的解决方案。

      对于集成测试,最好的方法是使用 OkHttp MockWebServer。 其使用简单灵活。使用服务器可以让您处理一些错误情况,否则您需要在单元测试用例中手动处理。

      【讨论】:

        【解决方案7】:

        我想使用 webclient 进行单元测试,但是 mockito 设置起来太复杂了,所以我创建了一个 library,它可以用来在单元测试中构建 mock webclient。这还会在发送响应之前验证 url、方法、标头和请求正文。

            FakeWebClientBuilder fakeWebClientBuilder = FakeWebClientBuilder.useDefaultWebClientBuilder();
            
                    FakeRequestResponse fakeRequestResponse = new FakeRequestResponseBuilder()
                    .withRequestUrl("https://google.com/foo")
                    .withRequestMethod(HttpMethod.POST)
                    .withRequestBody(BodyInserters.fromFormData("foo", "bar"))
                    .replyWithResponse("test")
                    .replyWithResponseStatusCode(200)
                    .build();
            
            
            
                    WebClient client =
                    FakeWebClientBuilder.useDefaultWebClientBuilder()
                    .baseUrl("https://google.com")
                    .addRequestResponse(fakeRequestResponse)
                    .build();
            
                    // Our webclient will return `test` when called. 
                   // This assertion would check if all our enqueued responses are dequeued by the class or method we intend to test.
                   Assertions.assertTrue(fakeWebClientBuilder.assertAllResponsesDispatched());
        

        【讨论】:

          【解决方案8】:

          使用spring-cloud-starter-contract-stub-runner,您可以使用 Wiremock 来模拟 API 响应。 Here 你可以找到我在medium 上描述的一个工作示例。 AutoConfigureMockMvc 注释在您的测试之前启动一个 Wiremock 服务器,暴露您在 classpath:/mappings 位置(可能是磁盘上的 src/test/resources/mappings)中的所有内容。

          @SpringBootTest
          @AutoConfigureMockMvc
          @AutoConfigureWireMock(port = 0)
          class BalanceServiceTest {
              private static final Logger log = LoggerFactory.getLogger(BalanceServiceTest.class);
              @Autowired
              private BalanceService service;
          
              @Test
              public void test() throws Exception {
          
                  assertNotNull(service.getBalance("123")
                          .get());
              }
          }
          

          以下是映射文件的示例。 balance.json 文件包含您需要的任何 json 内容。您还可以在静态配置文件中或以编程方式模拟响应延迟或故障。有关他们的website 的更多信息。

          {
            "request": {
              "method": "GET",
              "url": "/v2/accounts/123/balance"
            },
            "response": {
              "status": 200,
              "delayDistribution": {
                "type": "lognormal",
                "median": 1000,
                "sigma": 0.4
              },
              "headers": {
                "Content-Type": "application/json",
                "Cache-Control": "no-cache"
              },
              "bodyFileName": "balance.json"
            }
          }
          

          【讨论】:

            猜你喜欢
            • 1970-01-01
            • 2018-05-09
            • 2018-08-18
            • 1970-01-01
            • 2021-08-06
            • 2019-12-28
            • 1970-01-01
            • 2021-12-19
            • 2021-09-10
            相关资源
            最近更新 更多