上周有个同事抱着这段代码来找我,他把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 Mono<User> 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 时代我们经常这样 mock:
when(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); Mono<User> mono = userClient.getUserById("1");如果这里bodyToMono()返回 null,调用方可能不会立刻报错,因为这一行只是组装Mono。真正触发调用的是后续的block()或subscribe()。所以有的测试会变成:断言还没执行,NPE 就已经在block()那一行炸了,报错位置又指向业务代码。
正确做法是用Mono.just(...)包一个已完成的 Mono:
when(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 ExchangeFunction
3.1 核心原理:WebClient 的上层都是壳,网络在这里发生
现在说正事。
WebClient构建的时候,可以通过exchangeFunction(...)指定一个ExchangeFunction。ExchangeFunction是 WebClient 真正发网络请求的入口:
public interface ExchangeFunction { Mono<ClientResponse> exchange(ClientRequest request); }ClientRequest封装了 HTTP 方法、URL、Header、Body 等完整请求信息,返回的是Mono<ClientResponse>。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(); ArgumentCaptor<ClientRequest> 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 Mono<User> 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();再看模拟 5xx:
when(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 Server,WebClient 的 HTTP 编解码、连接池、超时配置都会真实生效。如果你的测试目标是"这个 Client 和外部服务之间的协议契约是否正确",MockWebServer 答案更接近真实。
但它也有个代价:依赖反射、序列化、网络协议栈,执行速度比纯 Mockito 慢,而且在持续集成环境里,局域网、防火墙都有可能导致偶发失败。所以它更适合放在集成测试阶段,而不是几百个用例的单元测试里。
5.2 三种方案的选型对比表
| 维度 | 链式 mock WebClient | mock ExchangeFunction | MockWebServer / WireMock |
|---|---|---|---|
| Mock 层级 | 公开 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 缺失问题,比测试里只断言"返回对象正确"有用得多。测试的最终目的不是把代码覆盖率跑到数字好看,而是当外部接口变动、参数调整时,你能第一时间知道哪一块契约被破坏了。