ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

RestClient单元测试实战:MockRestServiceServer与MockWebServer

2026/9/10 0:30:47 拓冰建站 浏览量
RestClient单元测试实战:MockRestServiceServer与MockWebServer 1. 先说版本你手里到底是不是 Spring Boot 6.11.1 版本编号最容易搞混的一个点看到项目标题里的“Spring Boot 6.1”很多同学第一反应是去 Maven 仓库搜spring-boot-starter-web:6.1.0搜完就懵了——根本没有这个版本。这里必须先澄清一个关键概念RestClient 是 Spring Framework 6.1 版本正式引入的同步 HTTP 客户端而 Spring Boot 到目前为止并没有 6.x 这个版本线。Spring Boot 3.2 开始内置 Spring Framework 6.1所以你只要在工程里看到spring-boot-starter-web或者spring-boot-starter-webflux的版本是 3.2.x 及以上就可以直接使用 RestClient。这其实是一个很典型的“版本号张冠李戴”场景。技术方案讨论的时候经常会拿 Framework 版本说事一落到 Boot 工程里就乱套。如果你在写作、面试或团队分享中遇到“Spring Boot 6.1 RestClient”这种说法正确理解就是说的是基于 Spring Framework 6.1 的 RestClient对应 Boot 版本是 3.2。下文所有代码都基于这个组合你可以直接复制到 Spring Boot 3.2/3.3/3.4 工程里跑。1.2 RestClient 解决了什么为什么值得单独测RestClient 的定位很直接RestTemplate 太老了从 Spring 框架诞生初期就在经历了太多版本迭代API 越来越臃肿方法模板模式用起来别扭而且很多高级能力比如拦截器、自定义消息转换器、响应式风格的流式 API全靠硬扩展WebClient 又偏响应式对大多数“就是同步调用一下第三方接口”的场景来说学习和心智成本偏高。RestClient 正好补了这个空档。它的 API 设计借鉴了 WebClient 的 fluent 写法但保持同步阻塞内部可以复用 RestTemplate 已有的ClientHttpRequestFactory、MessageConverter、拦截器体系。用一个简单例子感受一下User user restClient.get() .uri(/users/{id}, 1L) .accept(MediaType.APPLICATION_JSON) .retrieve() .body(User.class);这段代码比 RestTemplate 的exchangeResponseEntity那套看着舒服太多。但问题也随之而来RestClient 是外部服务调用入口如果不对它做单元测试你就只能在联调环境里靠“真实服务是否返回正确”来验证一旦接口地址搭错、请求头拼错、响应结构对不上问题会拖延到集成阶段才爆出来。单元测试的意义就是把这类 IO 边界的 bug 在提交代码之前拦下来。这也是我写这篇实践笔记的核心动机把一个可复现、可参考的 RestClient 单测方案完整落地。2. 单测方案怎么选三条路线的对比与取舍2.1 MockRestServiceServer和 Spring 生态结合最紧第一套方案是 Spring Test 模块自带的MockRestServiceServer。它最明显的优势是不启动真实 HTTP 端口直接在你的测试代码和 RestClient/RestTemplate 之间架设一层 mock 拦截器从内存里完成请求匹配和响应返回。MockRestServiceServer从 Spring 3.1 开始就服务于 RestTemplate在 Spring Framework 6.1 里扩展出了支持RestClient.Builder的绑定方式。它的匹配能力非常强可以校验请求方法GET/POST、请求 URL、请求头、请求体、查询参数返回时可以精确指定状态码、响应头和 JSON 响应体。因为是纯内存操作测试速度非常快几千个用例跑下来基本就是秒级。代价是什么呢它模拟的 HTTP 请求是在框架内部被拦截的没有经过真实的 Socket 通信。也就是说它验证的是“你的 RestClient 代码是否正确发出了我们预期的请求、是否正确处理了我们预设的响应”而不是“你配置的底层连接池、超时、DNS 解析是否真的工作”。这个边界要心里有数。2.2 MockWebServer最接近真实 HTTP第二套方案是 OkHttp 团队的MockWebServer。它会在本机随机端口启动一个真实的 HTTP 服务器你的 RestClient 会通过真实的 TCP 连接访问它。从代码角度看你写的baseUrl(http://localhost:xxxxx)是真实生效的请求会走完整的连接建立、HTTP 报文解析、响应封装的链路。这套方案的优势在于真实性。它能模拟连接超时、响应延迟、异常断开、返回非 JSON 的垃圾内容等极端情况这些都是MockRestServiceServer很难做到的。缺点是测试速度稍慢毕竟是真实端口 IO而且需要手动管理 MockWebServer 的生命周期关闭不及时会造成测试进程挂起。2.3 WireMock功能最全的模拟服务WireMock 是三套方案里功能最重的它不只是 mock 一个接口而是 mock 出一整套 HTTP API 服务。常用在契约测试、模拟第三方支付/短信/地图等外部服务甚至可以直接启动独立进程给多个服务共享使用。WireMock 3.x 提供了 JUnit 5 的WireMockTest注解用起来也不复杂。但说实话对 RestClient 的普通单元测试来说WireMock 有时“杀鸡用牛刀”了。它更适合的场景是你需要在测试里同时 mock 五六个第三方 API、需要校验复杂的 JSON Path、需要做故障注入或者你所在团队想把这套 mock 服务进行跨服务复用。2.4 选型建议维度MockRestServiceServerMockWebServerWireMock是否真实端口否内存拦截是是测试速度最快中等中等偏慢模拟超时/断连难容易容易独立的请求匹配能力强一般最强配置复杂度低低中高适合场景常规单测、快跑需要验证 HTTP 协议层多服务契约测试、故障注入我的建议很直接默认用 MockRestServiceServer它跟 Spring 官方对 RestClient 的测试文档是对齐的代码量最少维护成本最低。当你要验证超时重试、连接池异常、真实报文编解码时再引入 MockWebServer。WireMock 放到集成测试阶段去用。下面两套方案的代码我会都给出方便你直接切换。3. 方案一实操MockRestServiceServer 从零跑通 RestClient 测试3.1 准备依赖与一个可测试的 Client先看一下 Maven 依赖。核心是spring-boot-starter-web提供 RestClient 相关的 Spring 基础设施和spring-boot-starter-test内含 spring-test、JUnit 5、Mockito、AssertJ 等。如果你还没有 spring-boot-starter-parent记得先加上版本号选 3.2.x 以上dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-test/artifactId scopetest/scope /dependency然后设计一个可以被测试的 Client。这里有个关键点永远不要把RestClient.create()直接写在业务代码里一旦你通过静态方法创建 RestClient测试时就没有 builder 可以绑定 mock server 了。正确做法是注入RestClient.BuilderService public class UserClient { private final RestClient restClient; public UserClient(RestClient.Builder builder) { this.restClient builder .baseUrl(https://api.example.com) .build(); } public User getUserById(Long id) { return restClient.get() .uri(/users/{id}, id) .accept(MediaType.APPLICATION_JSON) .retrieve() .body(User.class); } public User createUser(User user) { return restClient.post() .uri(/users) .contentType(MediaType.APPLICATION_JSON) .body(user) .retrieve() .body(User.class); } }在 Spring Boot 3.2 中RestClient.Builder会被自动配置为一个 bean所以你直接注入它是没问题的。测试时则通过MockRestServiceServer.bindTo(builder)来接管这个 builder。3.2 写第一个单元测试用例这里直接用 JUnit 5 的ExtendWith(MockitoExtension.class)就能跑不需要启动 Spring 容器。测试的核心逻辑是先 mock 好请求的期望和响应再调用被测方法最后verify()确认所有期望都发生过。class UserClientTest { private MockRestServiceServer mockServer; private UserClient userClient; BeforeEach void setUp() { RestClient.Builder builder RestClient.builder(); mockServer MockRestServiceServer.bindTo(builder).build(); userClient new UserClient(builder); } Test void shouldReturnUserWhenGetById() { mockServer.expect(requestTo(https://api.example.com/users/1)) .andExpect(method(HttpMethod.GET)) .andRespond(withSuccess( {\id\:1,\name\:\Tom\}, MediaType.APPLICATION_JSON)); User user userClient.getUserById(1L); assertEquals(1L, user.getId()); assertEquals(Tom, user.getName()); mockServer.verify(); } }跑完之后你会发现几个关键点。第一requestTo的字符串必须是完整的 URL因为 Client 里已经配置了baseUrl实际发出的请求是https://api.example.com/users/1如果你只写/users/1匹配会直接失败并报“No further requests expected”或者找不到对应 expectation。第二withSuccess返回的是一个ResponseCreator它负责设置状态码和响应体默认状态码是 200。第三verify()一定不要漏它负责断言所有 expectation 都恰好被请求命中一次。3.3 进阶场景请求头、路径参数、异常分支真实项目里很少只有 GET 一个场景。POST JSON 请求体、自定义 Token 请求头、404 异常处理这些都得覆盖到。我直接给一个组合示例Test void shouldSendPostWithAuthHeaderAndBody() { mockServer.expect(requestTo(https://api.example.com/users)) .andExpect(method(HttpMethod.POST)) .andExpect(header(Authorization, Bearer test-token)) .andExpect(content().contentType(MediaType.APPLICATION_JSON)) .andExpect(jsonPath($.name).value(Tom)) .andRespond(withCreatedEntity(URI.create(/users/99))); User user new User(Tom); User created userClient.createUser(user); assertNotNull(created); mockServer.verify(); }这里的jsonPath($.name)会解析请求体里的 JSON能帮你在不启动容器的前提下校验请求参数是否拼对了。withCreatedEntity可以直接构造一个 201 响应。再看异常场景。如果第三方接口返回 404我们希望业务代码抛出明确的异常而不是让反序列化报一个让人摸不着头脑的错。先给 Client 加上异常处理public User getUserByIdOrThrow(Long id) { return restClient.get() .uri(/users/{id}, id) .retrieve() .onStatus(HttpStatusCode::is4xxClientError, (request, response) - { throw new UserNotFoundException(user not found: id); }) .body(User.class); }测试如下Test void shouldThrowUserNotFoundExceptionWhen404() { mockServer.expect(requestTo(https://api.example.com/users/999)) .andRespond(withResourceNotFound()); assertThrows(UserNotFoundException.class, () - userClient.getUserByIdOrThrow(999L)); mockServer.verify(); }withResourceNotFound()会返回 404 响应。这样异常分支就被钉死了后续哪怕有人调整了onStatus的逻辑测试也能第一时间告诉你有没有破坏功能。4. 方案二实操MockWebServer 模拟真实 HTTP 场景4.1 为什么需要真实端口的 MockMockRestServiceServer 有一个场景覆盖不了超时和连接异常。假设你的 RestClient 配置了连接超时 3 秒、读取超时 5 秒MockRestServiceServer 不会真的产生 Socket 超时它只是在内存里拦截请求并立即返回。你没法用它验证超时重试逻辑是否正常工作。MockWebServer 就不一样了。它在本机随机端口起了一个真实的 HTTP 服务你可以用MockResponse.setBodyDelay(10, TimeUnit.SECONDS)人为延迟响应也可以不 enqueue 任何响应来模拟服务端不发报文然后配合 Client 的超时配置验证异常路径。4.2 MockWebServer 的依赖与生命周期管理先加依赖。注意 OkHttp 的mockwebserver和okhttp版本要配套实际中我用 4.12.0 比较稳dependency groupIdcom.squareup.okhttp3/groupId artifactIdmockwebserver/artifactId version4.12.0/version scopetest/scope /dependency生命周期管理我建议放在 JUnit 5 的BeforeEach和AfterEach里class UserClientMockWebServerTest { private MockWebServer mockWebServer; private UserClient userClient; BeforeEach void setUp() throws IOException { mockWebServer new MockWebServer(); mockWebServer.start(); userClient new UserClient(RestClient.builder() .baseUrl(mockWebServer.url(/).toString()) .build()); } AfterEach void tearDown() throws IOException { mockWebServer.shutdown(); } }mockWebServer.url(/).toString()返回的字符串类似http://localhost:60493/这个端口是随机分配的测试之间不会冲突。测试用例写好之后shutdown()无论如何都要调用否则 MockWebServer 的非 daemon 线程会让你的测试进程一直挂住。4.3 模拟超时、断连等极端场景用 MockWebServer 模拟正常响应其实比 MockRestServiceServer 更简单就是enqueue一个MockResponseTest void shouldReturnUserWhenRemoteRespondsNormally() throws InterruptedException { mockWebServer.enqueue(new MockResponse() .setResponseCode(200) .setHeader(Content-Type, application/json) .setBody({\id\:1,\name\:\Tom\})); User user userClient.getUserById(1L); assertEquals(Tom, user.getName()); RecordedRequest recordedRequest mockWebServer.takeRequest(); assertEquals(/users/1, recordedRequest.getPath()); assertEquals(GET, recordedRequest.getMethod()); }这里值得注意的一个细节takeRequest()是阻塞方法它会等待直到收到一个请求再返回。如果 Client 的调用因为某种原因根本没发出去测试会一直卡住所以推荐配合takeRequest(5, TimeUnit.SECONDS)使用超过时间直接返回null再通过断言报错。超时场景的模拟是 MockWebServer 的拿手好戏Test void shouldTimeoutWhenServerDoesNotRespond() { mockWebServer.enqueue(new MockResponse() .setBodyDelay(10, TimeUnit.SECONDS) .setBody({})); // 假设业务代码里已经把 connectTimeout / readTimeout 配成 2 秒 assertThrows(ResourceAccessException.class, () - userClient.getUserById(1L)); }setBodyDelay让服务端延迟 10 秒才返回报文而 Client 的读取超时只有 2 秒所以必然抛出ResourceAccessException。这类测试验证的是网络层真实行为MockRestServiceServer 给不了你这么强的仿真。5. 高频问题排查与避坑技巧5.1 我在实践中踩过的五个坑现象根因解决办法requestTo一直匹配不上baseUrl 配置后实际请求是全路径 URL写相对路径必然不匹配使用完整 URL或用requestTo(uri - uri.getPath().equals(/users/1))自定义匹配器MockRestServiceServer 在测试间相互影响多个测试复用同一个MockRestServiceServerexpectation 残留在每个BeforeEach里新建 builder 和 server不要定义为静态字段反序列化时报InvalidDefinitionExceptionspring-boot-starter-web引入的 Jackson 对未知属性默认报错或者 User 类缺少无参构造器确保 User 有无参构造器、属性有 setter需要忽略未知属性时在类上加JsonIgnoreProperties(ignoreUnknown true)测试里手工 sleep 等异步结果异步线程还没跑完断言就执行了优先使用 MockWebServer 的takeRequest(超时时间)或 Awaitility 等待条件成立不要盲目 sleep直接用RestClient.create()导致无法 mock静态创建方式切断了 builder 绑定链路业务代码统一构造器注入RestClient.Builder测试用bindTo接管5.2 单测和集成测试的边界怎么切聊到 RestClient 测试很多人会把单测和集成测试搅在一起。我的习惯是单测只验证客户端代码本身的正确性——URL 拼没拼对、请求头带没带、JSON 序列化正不正确、对 404/500 的异常处理有没有生效。这些用 MockRestServiceServer 就够了跑完不会碰真实网络。集成测试才去做真实的端到端验证。比如用SpringBootTest MockWebServer 或 WireMock把整个 Spring 容器拉起来验证自动配置的RestClient.Builder、拦截器、连接池配置是否真的编排正确。两套测试各有分工单测追求速度和可定位性集成测试追求真实性和覆盖度不要试图用一套方案通吃。5.3 关于这套实践的最终心得在实际项目里用 RestClient 跑了将近一年的测试我最大的体会是可测试性的基础是在写业务代码时就埋好的。如果当初图省事直接调RestClient.create()后面所有 Mock 方案都白搭。所以我会在团队规范里强调一条RestClient 实例必须通过构造器注入RestClient.Builder创建禁止在类内部静态创建。这不是什么高深技术但不这么干你连第一行测试代码都写不下去。另外一个小建议测试资源文件尽量集中管理。如果多个 Client 测试都要 mock 用户接口、订单接口的响应 JSON别在测试代码里到处拼字符串把 JSON 放到src/test/resources/json/下用TestUtils.readJson(json/user_1.json)读取。这样响应数据变更时只需要改一个文件测试的可读性和维护性会好很多。最后再分享一个我自己用的检查清单写完 Client 代码后先问自己三个问题——URL 和参数有没有可能拼错异常时有没有按预期抛错请求头有没有遗漏然后针对这三个问题各写一个测试用例。这样一个 RestClient 的最小测试覆盖就成型了能拦住大部分线上才会暴露的问题。