十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Mockito模拟WebClient请求的正确姿势:从链式mock到ExchangeFunction

Mockito模拟WebClient请求的正确姿势:从链式mock到ExchangeFunction 上周有个同事抱着这段代码来找我他把WebClient从get()、uri()、retrieve()一路 mock 到bodyToMono()测试还是不断报空指针。我看了他一眼说你 mock 错层了。本文就把这件事彻底讲透——用 Mockito 模拟 WebClient 请求的正确姿势以及为什么大多数项目里常见的链式 mock 方式其实是在给未来埋雷。这套内容适合谁看很简单Spring WebFlux 项目里用 WebClient 调第三方接口想给 Service 层或 Client 层补单元测试的同学以及用 Mockito 写测试时总在 WebClient 的链式调用上交学费的人。读完之后你至少能回答三个问题为什么直接 mock WebClient 的 API 容易碎、正确 mock 的是哪一层、以及什么时候根本不该用 Mockito。1. 别急着 mock先搞清楚 WebClient 在哪一层被测1.1 一个看似简单却总写跑测的单元测试先说个真实场景。假设业务代码长这样这是典型的 WebClient 调用写法Service public class UserClient { private final WebClient webClient; public UserClient(WebClient webClient) { this.webClient webClient; } public MonoUser getUserById(String id) { return webClient.get() .uri(/users/{id}, id) .retrieve() .bodyToMono(User.class); } }很多人的第一反应是我 mock 掉 WebClient让get().uri().retrieve().bodyToMono()按预期返回一个假数据不就行了于是测试代码长成了这样ExtendWith(MockitoExtension.class) class UserClientTest { Mock private WebClient webClient; Mock private WebClient.RequestHeadersUriSpec requestHeadersUriSpec; Mock private WebClient.RequestBodyUriSpec requestBodyUriSpec; Mock private WebClient.ResponseSpec responseSpec; Test void shouldMockChain() { when(webClient.get()).thenReturn(requestHeadersUriSpec); when(requestHeadersUriSpec.uri(anyString(), any(Object[].class))) .thenReturn(requestBodyUriSpec); when(requestBodyUriSpec.retrieve()).thenReturn(responseSpec); when(responseSpec.bodyToMono(User.class)).thenReturn(Mono.just(new User(1, Alice))); User user userClient.getUserById(1).block(); assertNotNull(user); assertEquals(Alice, user.getName()); } }这段代码能不能跑在特定版本下能。但它有一个非常隐蔽的问题你测试的不是UserClient 调用了 WebClient 并正确处理返回值而是Mockito 把这些接口转起来了。换句话说你的测试和真实 HTTP 行为之间的关联度很弱。URI 拼接对不对、请求头有没有带上、响应 JSON 能不能反序列化成 User——这些真正容易出问题的点全部被 mock 掉了。1.2 WebClient 与 RestTemplate 的测试难度差异用过 RestTemplate 的同学可能觉得事情没那么复杂。RestTemplate 时代我们经常这样 mockwhen(restTemplate.getForObject(url, User.class)).thenReturn(user);为什么换成 WebClient 就不行了因为两者的协作方式完全不同。RestTemplate 的 API 是一次调用拿结果getForObject、postForObject都是完整动作mock 一个方法等于 mock 了整件事。WebClient 是响应式 Fluent API它把一次请求拆成了多个阶段get()获取请求准备器uri()设置地址retrieve()发起交换并返回响应持有器bodyToMono()把响应体转换成目标类型。每个阶段返回的都是不同的接口类型。Mockito 要 mock 的不是一个方法而是一整条调用链上所有环节的返回值。少 mock 一个环节测试里就是经典的Mockito 没有配置该方法返回 null然后你调用 null 的下一层方法。1.3 明确测试目标和边界所以在写测试之前先回答一个问题你打算验证什么如果你要验证的是UserClient 把 id 正确地拼到了 URL 里、把响应正确反序列化成了对象那么 mock 的层级应该深一些让真实的 WebClient 去处理 URL、Header、JSON 这些逻辑只把最底层的网络交换换掉。如果你只是想要一个不联网也能调通 UserClient的假环境那用 MockWebServer 或 WireMock 可能比 Mockito 更合适。这里的关键认知是WebClient 是一个请求构造器 请求执行器的复合体。Mockito 模拟 WebClient 请求最佳切入点不是模拟那个公开的 Fluent API而是模拟它内部真正执行请求的ExchangeFunction。2. 为什么链式 mock是错的方向——踩坑过程全复盘2.1 链式 API 的结构决定了 mock 的脆弱性先搞清楚 WebClient 的链式调用来龙去脉。WebClient.get()返回的是RequestHeadersUriSpec接口这个接口里定义了uri(...)方法同时自己还继承了RequestBodyUriSpec后者又继承RequestBodySpec往上还有RequestHeadersSpec。换句话说一个get()返回的对象身上背负了设置 URI、设置 Header、设置请求体、执行请求等一堆能力。这种设计对业务代码很舒服可以一路点下去不用关心中间类型。但对 Mockito 来说它意味着你的测试代码需要精确模拟这一路接口关系。我见过不少真实项目里为了给一个启动流程测试测试类里塞了五六个 mock 接口变量WebClient.RequestHeadersUriSpecWebClient.RequestBodyUriSpecWebClient.RequestHeadersSpecWebClient.ResponseSpecWebClient.RequestBodySpec而且 Spring 版本一升级这些接口的泛型细节可能变测试代码就得跟着改。明明只是想测试业务逻辑测试代码反而成了 WebClient 内部 API 结构的影子测试脆弱到让人想放弃。2.2 复现一次 NPE 排查链路从 when 到 verify很多同学在第一次写 WebClient mock 测试时会遇到这样一个错误java.lang.NullPointerException: null at com.example.UserClient.getUserById(UserClient.java:12)第一反应是业务代码出问题了。但实际上业务代码在真实环境里跑得好好的。问题出在测试里。我们一步步复盘。业务代码第 12 行通常是webClient.get().uri(...)这一句。Mockito 在默认情况下对 mock 对象上未 stub 的方法返回默认值对象类型返回 null基本类型返回 0集合返回空集合这只是表象Mockito 默认对未 stub 的返回实际是 NULL除非用了 RETURNS_SMART_NULLS。也就是说你写了Mock WebClient webClient但忘了 stubget()。那么webClient.get()返回 null。下一步继续执行.uri(/users/{id}, id)实际是在 null 上调用方法。NPE 就此产生。这种 NPE 最迷惑人的地方在于报错位置在业务代码不在测试代码。如果不熟悉 Mockito 的默认行为会花大量时间去看业务代码哪里没判空。还有一种更隐蔽的情况。你确实 stub 了webClient.get()但uri()那一步你没 stub于是webClient.get()正常返回 mock 的requestHeadersUriSpec。调用requestHeadersUriSpec.uri(...)时返回值是 null。下一步.retrieve()在 null 上执行NPE。所以完整 stub 一条链任何一个环节缺失都会出问题。而且一旦 WebClient 的实际调用链多了一个你没想到的环节比如中间有人调了.header(...)、.accept(...)测试又会 NPE。这就是链式 mock 最大的维护痛点。2.3 懒执行与 Mockito 的默认返回值陷阱还有一个和 WebClient 强相关的坑Reactor 的 Mono 是懒执行的。先看这段测试when(responseSpec.bodyToMono(User.class)).thenReturn(null); MonoUser mono userClient.getUserById(1);如果这里bodyToMono()返回 null调用方可能不会立刻报错因为这一行只是组装Mono。真正触发调用的是后续的block()或subscribe()。所以有的测试会变成断言还没执行NPE 就已经在block()那一行炸了报错位置又指向业务代码。正确做法是用Mono.just(...)包一个已完成的 Monowhen(responseSpec.bodyToMono(User.class)).thenReturn(Mono.just(user));这里我特别想说一句在 mock WebClient 链时很多人习惯把整条链写在when()里面比如when(webClient.get().uri(anyString(), any(Object[].class)).retrieve().bodyToMono(User.class)) .thenReturn(Mono.just(user));这段代码在 Java 层面实际上会在测试注册阶段就完整执行一遍链式调用所以中间任何一步没 stub都会立刻 NPE而不是等到请求发出时才报错。想绕过这个问题的同学会用doReturn()方式代替when()但在 WebClient 这种多层接口结构下doReturn()并不会让代码更干净只会让 mock 链更隐晦。这也是我在实际工作中逐渐放弃链式 mock 的原因——它不是不能跑而是每次排查问题都要把链从头到尾复查一遍成本太高。3. 真正的正确姿势mock ExchangeFunction3.1 核心原理WebClient 的上层都是壳网络在这里发生现在说正事。WebClient构建的时候可以通过exchangeFunction(...)指定一个ExchangeFunction。ExchangeFunction是 WebClient 真正发网络请求的入口public interface ExchangeFunction { MonoClientResponse exchange(ClientRequest request); }ClientRequest封装了 HTTP 方法、URL、Header、Body 等完整请求信息返回的是MonoClientResponse。WebClient 上层那些get()、uri()、retrieve()、bodyToMono()本质上都是在构造一个ClientRequest最终交给ExchangeFunction执行。所以正确的 mock 思路是创建一个真实地 WebClient给它换上一个 mock 的 ExchangeFunction让真实的请求构造逻辑走完整只有网络发送这一步被替换掉。这个逻辑很好类比。真实世界里你打电话给餐厅订餐WebClient 是从拨号到点菜再到听对方回应的全套电话交互逻辑而 ExchangeFunction 是那根电话线。现在测试不需要真的拉一条电话线到对方餐厅只要在本地放一个人替你假装接电话即可——你拨号、点菜的过程都是真实发生的。3.2 最小可运行示例对前面那个UserClient测试可以这样写ExtendWith(MockitoExtension.class) class UserClientTest { private ExchangeFunction exchangeFunction; private UserClient userClient; BeforeEach void setUp() { exchangeFunction mock(ExchangeFunction.class); WebClient webClient WebClient.builder() .baseUrl(http://remote-service) .exchangeFunction(exchangeFunction) .build(); userClient new UserClient(webClient); } Test void shouldGetUserById() { ClientResponse response ClientResponse.create(HttpStatus.OK) .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) .body({\id\:\1\,\name\:\Alice\}) .build(); when(exchangeFunction.exchange(any(ClientRequest.class))) .thenReturn(Mono.just(response)); User user userClient.getUserById(1).block(); assertNotNull(user); assertEquals(1, user.getId()); assertEquals(Alice, user.getName()); } }关键点有三个。第一ClientResponse是真实对象不是 mock。它通过ClientResponse.create(HttpStatus.OK)构建可以设置响应头、响应体。这样做出的响应是完整可用的。第二exchangeFunction.exchange(any(ClientRequest.class))这一个 stub就替代了之前一整条链的所有 stub。因为业务代码里的get()、uri()、retrieve()、bodyToMono()全部在真实 WebClient 内部运行只有最后一步网络交换被 mock 掉了。第三这个测试体感上更接近集成测试但执行起来仍然是单元测试的速度不发起任何真实网络请求。3.3 校验请求细节ExchangeFunction 方案还有一个硬核优势你可以在测试里拿到完整ClientRequest对象校验业务代码到底向外部发了一个什么样的请求。用ArgumentCaptor就能做到Test void shouldSendCorrectRequest() { ClientResponse response ClientResponse.create(HttpStatus.OK) .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) .body({\id\:\1\,\name\:\Alice\}) .build(); when(exchangeFunction.exchange(any(ClientRequest.class))) .thenReturn(Mono.just(response)); userClient.getUserById(1).block(); ArgumentCaptorClientRequest captor ArgumentCaptor.forClass(ClientRequest.class); verify(exchangeFunction).exchange(captor.capture()); ClientRequest actualRequest captor.getValue(); assertEquals(HttpMethod.GET, actualRequest.method()); assertEquals(http://remote-service/users/1, actualRequest.url().toString()); }这个校验能力是链式 mock 提供不了的。链式 mock 里uri()方法的调用参数虽然也可以被ArgumentCaptor捕获但捕获到的只是用户传进来的相对路径而不是 WebClient 拼完 baseUrl 之后真正的完整 URL。真实场景中很多问题恰恰出在 baseUrl 拼接、路径参数编码这些细节上ExchangeFunction 方案能把这些细节暴露在测试里。3.4 模拟成功、404、异常三类场景实际生产需求往往不只是返回 200 的情况还包括 404、5xx、连接超时等。用 ExchangeFunction 都能处理。先模拟 404。注意retrieve()的行为当响应状态是 4xx/5xx 时它会通过onStatus回调处理错误。如果你的业务代码这样写public MonoUser getUserById(String id) { return webClient.get() .uri(/users/{id}, id) .retrieve() .onStatus(HttpStatusCode::is4xxClientError, response - Mono.error(new UserNotFoundException(id))) .bodyToMono(User.class); }那么测试里只需返回一个 NOT_FOUND 响应when(exchangeFunction.exchange(any(ClientRequest.class))) .thenReturn(Mono.just(ClientResponse.create(HttpStatus.NOT_FOUND).build())); StepVerifier.create(userClient.getUserById(1)) .expectError(UserNotFoundException.class) .verify();再看模拟 5xxwhen(exchangeFunction.exchange(any(ClientRequest.class))) .thenReturn(Mono.just(ClientResponse.create(HttpStatus.INTERNAL_SERVER_ERROR).build())); StepVerifier.create(userClient.getUserById(1)) .expectError(WebClientResponseException.class) .verify();注意这里有个细节当返回状态是 5xx 时默认的retrieve()会抛WebClientResponseException.InternalServerError而返回 404 时抛的是WebClientResponseException.NotFound。如果你的业务代码里没有自定义onStatus测试断言的异常类型也要和默认行为一致。模拟连接超时或网络异常也非常直接when(exchangeFunction.exchange(any(ClientRequest.class))) .thenReturn(Mono.error(new ConnectTimeoutException(timeout)));这个能力非常重要。用链式 mock 的时候想模拟底层网络异常是一个非常痛苦的事因为exchange()这一步你根本没 mock也不知道该往哪里塞异常。而 mock ExchangeFunction 之后网络层发生了什么事测试就模拟什么事语义完全对齐。4. 次优方案mock WebClient.Builder 和链式接口4.1 Builder 场景不是所有项目都能立刻改成 ExchangeFunction 方案。比如有的代码是这样注入的Service public class UserClient { private final WebClient webClient; public UserClient(WebClient.Builder builder) { this.webClient builder .baseUrl(http://remote-service) .build(); } }这类代码在测试里可以直接 mockWebClient.Builder让build()返回一个 mock 的 WebClient。但下一步你依然面临 WebClient 链式 mock 的问题只是多包了一层而已。所以我的观点是mock Builder 本身并没有解决最核心的问题它只是为了迁就业务代码依赖 Builder这个事实。如果业务代码允许更合理的做法是测试里构造一个真实 WebClient 注入WebClient webClient WebClient.builder() .baseUrl(http://remote-service) .exchangeFunction(exchangeFunction) .build(); userClient new UserClient(webClient);如果必须测那段Builder 配置 baseUrl的逻辑才值得考虑 mock Builder。但那种测试的核心诉求已经变成验证 Builder 是否被设置了 baseUrl而不是验证请求是否正确发出。4.2 链式 mock 的可用但脆弱代码假设你确实希望保留链式 mock那至少要知道一个相对稳定的写法。以下代码在现代 Spring 5/6 Mockito 5 下基本能编译运行但我必须加一个前提声明这类写法对 Spring 版本极其敏感如果升级后接口发生变化第一波被砸到的就是这里。Mock private WebClient webClient; Mock private WebClient.RequestHeadersUriSpec requestHeadersUriSpec; Mock private WebClient.RequestBodyUriSpec requestBodyUriSpec; Mock private WebClient.ResponseSpec responseSpec; Test void testWithChainMock() { User expected new User(1, Alice); doReturn(requestHeadersUriSpec).when(webClient).get(); doReturn(requestBodyUriSpec) .when(requestHeadersUriSpec) .uri(anyString(), any(Object[].class)); doReturn(responseSpec).when(requestBodyUriSpec).retrieve(); doReturn(Mono.just(expected)) .when(responseSpec) .bodyToMono(User.class); User actual userClient.getUserById(1).block(); assertEquals(expected, actual); }代码里的核心技巧是doReturn().when()而不是when().thenReturn()。前面说过when(webClient.get().uri(...))这种写法会在 mock 注册阶段就执行链式调用任何一个环节返回 null 都会立刻 NPE。doReturn().when()可以让 mock 逐步注册不提前触发链式调用。另一个技巧是可变参数的处理。uri(String, Object...)在 Mockito 里匹配时很多人踩过坑// 这种写法看着对但可能匹配不上 when(requestHeadersUriSpec.uri(anyString(), any())).thenReturn(requestBodyUriSpec); // 这种写法更稳一点 doReturn(requestBodyUriSpec) .when(requestHeadersUriSpec) .uri(anyString(), any(Object[].class));为什么因为 Java 的可变参数在编译后本质是数组Mockito 匹配可变参数整体时用any(Object[].class)更贴近真实签名。如果你写any()在某些 Mockito 版本里会按单个 Object 匹配导致 stub 不命中、返回 null又是一轮的排查噩梦。4.3 什么时候才值得用这个方案坦率讲在我的标准里链式 mock 只适合一种场景你被历史代码困住了没有权限改业务代码的 WebClient 创建方式且引入 MockWebServer 的成本又暂时无法接受。它是一个过渡方案不是一个推荐方案。如果项目是你自己搭建的或者业务代码允许小幅重构我强烈建议优先把 WebClient 的创建和配置收口到一处然后在测试中用真实 WebClient mock ExchangeFunction。代码没有更复杂测试的可靠度反而更高。5. 进一步想MockWebServer / WireMock 与 Mockito 如何分工5.1 为什么集成测试往往更划算讲完 Mockito 的方案我想补另一个角度有些场景里根本不值得用 Mockito 去 mock WebClient。如果业务类是 Client你真正关心的是这个 Client 调用外部服务时HTTP 报文长什么样、响应怎么被解析。这种测试用 OkHttp 的 MockWebServer 或者 WireMock 会更舒服。做法是启动一个本地 MockWebServer把 WebClient 的 baseUrl 指到它上面测试代码直接去配置服务端返回什么内容。BeforeEach void setUp() { mockWebServer new MockWebServer(); mockWebServer.start(); webClient WebClient.builder() .baseUrl(mockWebServer.url(/).toString()) .build(); } Test void shouldReturnUser() throws Exception { mockWebServer.enqueue(new MockResponse() .setResponseCode(200) .setHeader(Content-Type, application/json) .setBody({\id\:\1\,\name\:\Alice\})); User user userClient.getUserById(1).block(); assertEquals(Alice, user.getName()); RecordedRequest recordedRequest mockWebServer.takeRequest(); assertEquals(/users/1, recordedRequest.getPath()); }MockWebServer 的好处是它真的是一个 HTTP ServerWebClient 的 HTTP 编解码、连接池、超时配置都会真实生效。如果你的测试目标是这个 Client 和外部服务之间的协议契约是否正确MockWebServer 答案更接近真实。但它也有个代价依赖反射、序列化、网络协议栈执行速度比纯 Mockito 慢而且在持续集成环境里局域网、防火墙都有可能导致偶发失败。所以它更适合放在集成测试阶段而不是几百个用例的单元测试里。5.2 三种方案的选型对比表维度链式 mock WebClientmock ExchangeFunctionMockWebServer / WireMockMock 层级公开 API 层网络交换层真实 HTTP Server是否会真实组装 URL/Header不会全被 mock会真实执行会真实执行是否能验证完整请求报文很难只能校验调用参数能拿到 ClientRequest能拿到 HTTP 原始请求执行速度很快很快较慢对 Spring 版本敏感度高接口一变就崩低ExchangeFunction 接口稳定很低代码可读性差一堆接口变量好逻辑清晰好适用场景历史遗留代码快速打桩标准单元测试契约测试 / 集成测试5.3 让业务代码从源头可测最后补充一个很多人忽略的经验让代码可测最好的时机不是写测试的时候而是设计类的时候。如果你在设计阶段就意识到这个 UserClient 会发生网络请求我需要让它可测做法其实很简单用构造器注入 WebClient不要在自己的类里用WebClient.create()否则测试时没法替换。对第三方服务的调用尽量收敛到一个专门的 Client 类里不要让 Service 层散落一堆 WebClient 调用。在 Client 方法内部尽量把请求构造、响应解析、错误处理封装完整对外暴露的应该是业务语义的方法比如getUserById()而不是一个裸的webClient.get()...。做了这些基础设计之后测试方案的选择才真正自由。你可以按需决定用 ExchangeFunction 还是 MockWebServer而不是被代码结构绑死。我在实际项目里见过很多团队在 WebClient 测试上反复煎熬根子其实不在测试方法而在类的设计业务类里直接WebClient.create()、调完就扔测试时只能疯狂 mock 链。与其把精力花在怎么 mock 一条复杂的链上不如先把 WebClient 收集到洞口让测试能轻松地从一个点注入替身。最后再分享一个小技巧如果你决定用 ExchangeFunction 方案在verify()的时候建议保留一个用例专门校验请求参数。我自己靠这一条抓出过不少 URL 拼接错误和 Header 缺失问题比测试里只断言返回对象正确有用得多。测试的最终目的不是把代码覆盖率跑到数字好看而是当外部接口变动、参数调整时你能第一时间知道哪一块契约被破坏了。
返回列表