如何模拟 Spring WebFlux WebClient?

Posted

技术标签:

【中文标题】如何模拟 Spring WebFlux WebClient?【英文标题】:How to mock 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 可以用作端点的模拟服务器?

【问题讨论】:

【参考方案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

来源:

WebClient javadoc WebClient.Builder javadoc ExchangeFunction javadoc

【讨论】:

我更喜欢不要在测试中使用.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 运行突变测试/坑测试,它会撕掉这个测试套件。这就是我对你的回答投票赞成 ***.com/a/54254718/2852528 的原因。并对抄袭说不。正如鲍姆在这篇文章的第一条评论中所说,你需要努力阅读这个***.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"
  

【讨论】:

以上是关于如何模拟 Spring WebFlux WebClient?的主要内容,如果未能解决你的问题,请参考以下文章

spring5 webflux,如何返回自定义json数据?

如何在 Spring boot 2 + Webflux + Thymeleaf 中配置 i18n?

WebCL 在主流浏览器上的实现现状如何? [关闭]

如何使用 Spring Boot 对 WebFlux 进行异常处理?

如何使用 spring webflux 读取请求正文

如何将 ModelAndView Spring MVC 转换为 Spring Webflux