在 Flutter 应用开发里,网络层测试一直是个让人头疼的环节。特别是当你负责的应用需要跑在鸿蒙设备上,又要兼容 Android、iOS 多端时,想稳定复现“超时、拥塞、脏数据”这类网络异常,简直是靠天吃饭。我这两年一直在做跨端应用的网络健壮性测试,试过各种 mock 工具和代理方案,踩了不少坑,今天专门聊聊如何用 fake_http_client 这个三方库,在鸿蒙环境下搭建一套无代码侵入的脱网测试矩阵,模拟各级超时、拥塞与脏数据回调,把网络层的隐患一次性肃清。
这篇文章的内容适合三类人:一是 Flutter 应用测试工程师,想给项目补上网络异常场景的自动化覆盖;二是鸿蒙应用开发者,正为 ohos 平台上的网络调试发愁;三是做跨端 SDK 或基础组件维护的同事,需要一套不污染业务代码的 mock 方案。我会从方案选型、核心原理、实际配置到问题排查,完整走一遍,所有代码都可以直接抄。
1. 方案选型:为什么是 fake_http_client
1.1 脱网测试的常规思路与痛点
先聊个背景。网络层测试最麻烦的地方在于:你不知道线上用户手里那台设备的网络环境到底什么样。可能是地铁隧道里信号时断时续,可能是弱网下面延迟飙到几秒,也可能是服务端偶发返回一段被截断的 JSON。传统做法大概分几派:一是起一个本地 mock server,让应用指向本地端口,好处是可控,坏处是要改代码、要维护一套独立服务;二是用代理工具(比如 Charles、Fiddler)做断点和改写,抓包能力强,但没法自动化跑冒烟,也不适合进 CI;三是直接在业务代码里封装一个假的 http client,由上层判断环境切换,这种方式侵入性最大,经常出现“测试环境好好的,一关开关就出事”的情况。
我最初在鸿蒙设备上做网络测试时也陷入过这个困境。鸿蒙的 ohos 平台虽然支持 Flutter 框架,但好多通用的 mock 工具要么没适配,要么内部依赖的 HTTP 栈和鸿蒙不一致,拦截不到请求。直到后来发现 fake_http_client 这个库,它走的是 Dart 层 HttpOverrides 机制,完全不碰底层通信协议,天然跨端,这正好解决了鸿蒙适配的大问题。
1.2 fake_http_client 的优势拆解
fake_http_client 的核心卖点有三个。第一,无代码侵入。它不需要你在业务代码里写任何 if/else,也不要求修改 api service 层的调用逻辑,只要在启动测试环境时做一次初始化,之后所有 HTTP 请求都会自动走 mock 路由。第二,拦截粒度细。它不仅能按 URL、Method 过滤,还能针对同一个接口根据不同条件返回不同结果,比如匹配 header、匹配请求体、匹配 query 参数,这样构造测试矩阵时非常灵活。第三,可编程地模拟异常。你可以为每个路由指定响应延迟时长、错误码、自定义 body,甚至抛一个异常出来,这正好覆盖了“超时、拥塞、脏数据回调”这三大类网络异常场景。
对比我以往用过的 mock server 方案,fake_http_client 省去了进程管理和端口监听,测试用例像普通单元测试一样 run 起来就能跑,速度也快很多。而且它依赖的 HttpOverrides 机制是 Dart 官方提供的扩展点,不涉及任何私有 API,因此适配鸿蒙时稳定性有保障。这个选择在后续实践中反复证明是靠谱的。
1.3 鸿蒙适配的考量点
说到鸿蒙,得单独讲一点。Flutter 应用跑在 HarmonyOS 上时,网络栈最终走的是 ohos 的 socket 能力,但 Dart 侧的 HttpClient 抽象依然保留,所以从 Dart 层拦截请求的办法在鸿蒙上依然成立。fake_http_client 正是基于这个抽象层工作的。这也意味着,不管你底层是 Android 的 okhttp、iOS 的 NSURLSession,还是鸿蒙的 socket,库本身不关心也不感知,它只负责在 Dart 代码执行到“发请求”之前截住,扔给你配置好的假数据。整个方案天然兼容 ohos,无需额外写平台代码。
需要留意的是,鸿蒙应用有严格的权限声明要求,如果你的测试场景涉及真实网络访问,需要在 module.json5 里配置 ohos.permission.INTERNET。不过脱网测试矩阵本来就是离线环境下跑的,这一步可以省略。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心机制:拦截与路由匹配是怎么工作的
2.1 HttpOverrides 与 HttpClient 拦截原理
要理解 fake_http_client 为什么能做到“无侵入”,必须先搞明白 Dart 的 HttpOverrides 机制。你可以把 HttpOverrides 理解成 Dart 给开发者留的后门,它允许你全局替换 HttpClient 的工厂方法。Flutter 的 http 包、dio 包,绝大多数最终都会调用 HttpClient 来发起真实请求,而你只需要在应用启动早期调用 HttpOverrides.global = myOverrides,就能让所有后继请求改走自定义的 HttpClient。
fake_http_client 内部正是干这件事。它注册了一个 FakeHttpClientFactory,并重写了 createHttpClient 方法,返回一个 FakeHttpClient 实例。这个实例本身不会真的去建连、发包,而是把请求参数(url、method、headers、body)记录进内部路由匹配器,再根据你预先填好的响应配置返回结果。从业务代码的视角看,它还是同一个 HttpClient,调用方式没有任何变化,这就是无代码侵入的底层逻辑。
我记得第一次看这个机制时还担心它对 dio 是否有效,实测发现 dio 只要不强制配置自定义 HttpClientAdapter,默认就是走 HttpClient 的,所以同样会被拦截。这一点对团队里既有 http 又有 dio 的混合项目特别好用。
2.2 路由匹配的优先级与匹配规则
fake_http_client 的路由匹配并不是简单查表,它有一套优先级机制。当请求进来时,匹配器会按顺序检查你注册的规则,命中之后立即返回对应响应配置。如果多个规则重叠,优先使用先注册的,所以注册时要把“精确路径匹配”放在前面,“通配符/正则匹配”放在后面,避免把通用规则先抢走。
匹配维度上,建议按“Method + URL + 条件”三层来设计。Method 精确匹配 GET、POST、PUT、DELETE 等,URL 支持完整匹配和正则,条件层则可写 header、query 参数、body 字段的判断。举个例子,你可以只对登录接口在 header 带 test-token 时才返回模拟 token,其余情况照常放行。这一层灵活性非常重要,因为测试矩阵里需要精确控制某个接口的返回,而又不能影响其他接口的正常调用。
2.3 响应构造器的参数说明
每个路由规则最终对应一个响应构造器,构造器里能设置的参数,基本决定了你能模拟哪些异常。常用参数包括 statusCode、headers、body(可以是字符串或字节数组)、delay(Duration 类型,控制响应延迟)、error(抛出一个异常对象,比如 SocketException 或 TimeoutException)。fake_http_client 还支持回调函数式构造器,也就是你可以根据请求对象动态生成响应,这个能力在构造并发测试矩阵时是利器。
例如想模拟服务端 500 错误,就设置 statusCode 为 500,body 随便写一段错误信息;想模拟请求超时,就设置 delay 为 10 秒,但真实测试不可能等 10 秒,那么配合测试框架里的超时断言,就能在毫秒级验证业务回调是否正确处理了超时。想模拟脏数据,就直接把 body 设置成一段非 JSON 的字符串、残缺的 JSON、或者奇怪的编码字节流,看应用是否崩溃、是否报解析错误。
3. 实操落地:搭建鸿蒙脱网测试矩阵
3.1 工程接入与初始化
第一步,当然是把 fake_http_client 加进 Flutter 工程的依赖。在 pubspec.yaml 的 dev_dependencies 里加入后,执行 flutter pub get 即可。这里我直接给出依赖片段。
yaml复制dev_dependencies:
fake_http_client: ^1.2.0
第二步,需要在测试代码或者一个独立的“测试环境初始化文件”里完成全局设置。我的做法是写一个 initializeMockNetwork 函数,在 main() 里最先调用,并只在 debug 模式下生效。具体代码如下:
dart复制import 'dart:io';
import 'package:fake_http_client/fake_http_client.dart';
void initializeMockNetwork() {
assert(() {
final fakeClient = FakeHttpClient()..registerAll();
HttpOverrides.global = fakeClient;
return true;
}());
}
这个 assert 技巧有点冷门,但很实用:release 构建时 assert 里的代码不会执行,所以相当于自动关闭了 mock,避免误带上线。在这个基础上,你可以再包一层环境判断,比如读取平台环境变量,只允许在测试环境开启,更保险。
3.2 注册基础路由:正常响应与通用模板
搭测试矩阵之前,先把基础路由注册好。这部分对应的是“正常网络”场景,保证应用能跑通主流程。我的建议是按照业务模块划分,而非按页面划分,例如用户模块、商品模块、订单模块各建一个 register 函数,维护起来更清晰。
dart复制void registerBasicRoutes(FakeHttpClient client) {
client.register(
method: HttpMethod.get,
pathPattern: RegExp(r'^/api/user/info$'),
response: (request) => MockResponse(
statusCode: 200,
body: jsonEncode({'id': 1001, 'name': '测试用户'}),
headers: {'content-type': 'application/json'},
),
);
client.register(
method: HttpMethod.post,
pathPattern: RegExp(r'^/api/order/create$'),
response: (request) => MockResponse(
statusCode: 200,
body: jsonEncode({'orderId': 'MOCK20240001'}),
),
);
}
基础路由的价值在于给整个应用提供一份“看上去在线”的数据底座,让后面设备能跑起来,从而把测试焦点集中到网络异常处理上。这里特别注意 content-type 一定要正确,否则容易引发解析层误判。
3.3 模拟超时与拥塞场景
基础路由搞定后,进入核心环节:模拟超时、拥塞和脏数据。先说超时。在 fake_http_client 中模拟超时不是真的等那么久,而是构造一个“延迟超过业务侧超时阈值”的响应,然后结合测试断言语义来判断。比如你的应用里设置的是 5 秒超时,那 mock 响应 delay 给 6 秒,但测试用例不可能等 6 秒,所以通常配合 fakeAsync 或 pumpAndSettle 加速,或者在初始化时把 delay 设成略高于业务阈值再结合自定义断言。
dart复制client.register(
method: HttpMethod.get,
pathPattern: RegExp(r'^/api/order/list$'),
response: (request) => MockResponse(
statusCode: 200,
body: '{}',
delay: const Duration(seconds: 6),
),
);
拥塞的模拟稍微复杂一些,单靠一个路由不够,需要多个请求并发延迟。比如首页要同时请求轮播、推荐、用户信息三个接口,你就可以分别给它们设置不同的延迟时间和返回结果,让部分接口先回来、部分接口后回来,以此验证 UI 是否因为顺序错乱而崩溃。
另一个常见做法是“随机延迟”。在 MockResponse 的 delay 参数里你没法直接传随机值,但可以用回调式响应构造器,在每次请求时生成不同的延迟。这样更接近真实弱网下延迟抖动的情况。
3.4 脏数据与异常回调注入
脏数据回调指的是服务端返回了不符合预期的数据,比如 HTTP 状态码是 200,但 body 是一段无效 JSON,或者 JSON 结构缺字段。fake_http_client 对这类场景支持很直接,直接配置 body 内容即可。
比如模拟“成功状态下的无效 JSON”:
dart复制client.register(
method: HttpMethod.get,
pathPattern: RegExp(r'^/api/goods/detail$'),
response: (request) => MockResponse(
statusCode: 200,
body: 'this is not json at all',
headers: {'content-type': 'application/json'},
),
);
这个场景特别值得测,因为很多团队只在状态码非 200 时做异常处理,却忽略了 200 但数据格式错误的情况。再比如模拟“字段缺失”:
dart复制client.register(
method: HttpMethod.get,
pathPattern: RegExp(r'^/api/user/order/list$'),
response: (request) => MockResponse(
statusCode: 200,
body: jsonEncode({'list': [{'id': '1'}]}), // 缺少 name、price 字段
),
);
如何验证“脏数据”是否被正确处理?我的做法是在测试用例里捕获回调抛出的异常,断言 UI 层没有白屏、没有卡死。你可以在 fake_http_client 的响应回调里主动抛出一个 FormatException,来模拟解析器在解析脏 JSON 时的行为,这比单纯给坏字符串更接近真实崩溃路径。
3.5 构建“脱网测试矩阵”的完整配置
现在把基础路由、超时、拥塞、脏数据四类配置汇总成一份“测试矩阵配置文件”。这里的关键是设计一个“场景开关”,让同一个接口在不同测试用例里走不同配置。我习惯用枚举来定义场景,比如 NetworkScenario.normal、timeout、congestion、dirtyData,然后在初始化函数里根据场景开关,决定注册哪些路由以及如何覆盖默认响应。
dart复制enum NetworkScenario { normal, timeout, congestion, dirtyData }
void applyScenario(NetworkScenario scenario) {
HttpOverrides.global = null; // 清理旧配置
final client = FakeHttpClient();
registerBasicRoutes(client);
switch (scenario) {
case NetworkScenario.timeout:
overrideTimeoutRoutes(client);
break;
case NetworkScenario.congestion:
overrideCongestionRoutes(client);
break;
case NetworkScenario.dirtyData:
overrideDirtyRoutes(client);
break;
case NetworkScenario.normal:
break;
}
HttpOverrides.global = client;
}
这个 applyScenario 函数就是你脱网测试矩阵的入口。跑测试时,只需要将场景枚举传进去,就能快速切到对应的网络异常环境。我在实际项目中还把它封装成了一个集成测试的启动参数,命令行传 --dart-define=MOCK_SCENARIO=timeout 即可自动选择场景,非常适合 CI 流水线里跑多组矩阵用例。
3.6 与 Flutter 测试框架的集成
测试矩阵建好了,怎么跑起来?我的建议是用 integration_test 或 flutter_test 的 testWidgets 来组织用例,每个用例开头调用 applyScenario 设置好场景,然后驱动页面操作,最后用 expect 验证回调行为。因为 fake_http_client 是同步注入的,配合 pumpAndSettle 时要注意:如果某个请求设置了很长的 delay,pumpAndSettle 会一直等待直到超时。所以我在拥塞场景中给 delay 设置 2 秒以内,配合 pump 的 duration 参数推进时间,这样测试执行速度快,逻辑也稳。
下面是一个简化版的集成测试用例片段:
dart复制testWidgets('timeout scenario should show error toast', (tester) async {
applyScenario(NetworkScenario.timeout);
await tester.pumpWidget(const MyApp());
await tester.tap(find.text('刷新订单'));
await tester.pump(const Duration(seconds: 7));
expect(find.text('请求超时,请重试'), findsOneWidget);
});
注意这里的 7 秒不是真的等待,而是通过 fake clock 推过去,所以整个测试执行很快,实测一秒内就能跑完。如果你用的是真机集成测试,没有 fake clock,那 delay 就要设置得小一点,比如 1 秒,然后等待 1~2 秒。
4. 常见问题与排查技巧实录
4.1 拦截失效:请求还是发出去了
最常见的问题就是明明设置了 HttpOverrides.global,但请求还是走了真实网络。根据我排过的一堆案例,原因基本集中在两个:一是初始化时机太晚,业务代码已经创建好了 HttpClient 实例,后设置的全局 overrides 对它不生效。解决方法是把 applyScenario 放到 runApp() 之前,或者至少在第一个网络请求发生之前。二是有的三方库在内部自己创建了 Zone,并且在 Zone 里设置了独立的 HttpOverrides,这会让全局配置失效。遇到这种情况,先确认这个三方库是否允许传入自定义 HttpClient,或者尝试在同一个 Zone 内部重新设置。
还有一个冷知识:Dart 的 HttpClient 本身有连接复用机制,如果你在测试中途切换了 fake_http_client 实例,旧实例建立的连接池可能还在,导致新配置没走进去。最简单的规避方法是切换场景时把 HttpOverrides.global 先置为 null,再加一层延迟,确保连接池清空后再设置新的实例。
4.2 回调顺序与异步时序问题
用 fake_http_client 模拟多个并发请求时,响应回调的完成顺序可能和注册顺序不一致,这会导致测试用例里对“先回谁的响应”产生错误预期。解决办法是在断言前不要依赖回调顺序,而是等所有请求都结束后再统一断言。可以利用 Future.wait 收集多个请求的 future,等全部完成后再对结果做校验。另外,fake_http_client 的响应虽然是模拟的,但底层也是异步 event loop 调度,所以真机测试时依然要避免断言一颗“必须谁先回来”的心。
调试这类问题时,我通常会在 MockResponse 的构造器里带上一个日志,打印请求路径和响应状态码,这样能直观看到每个请求的回调顺序以及是否走到了预期的 mock 分支。
4.3 脏数据导致测试进程崩溃
模拟脏数据时,最怕的不是业务处理出错,而是错误没有被捕获,直接导致测试进程崩溃。比如 body 给了一段畸形字节流,某些解析器内部会抛出一个未被捕获的异常,进而触发 Flutter 的 zone 错误,测试直接挂掉。解决办法是在测试入口统一加一个 FlutterError.onError 和 PlatformDispatcher.instance.onError 的兜底,把异常记录到测试日志中,而不是直接抛出。
另一个容易忽略的坑是:当 MockResponse 里抛出的 error 是 SocketException 时,dio 的 HttpClientAdapter 会对错误做包装,最终业务回调拿到的可能是 DioError,而不是 SocketException。断言时要注意这一层包装,最好直接用 dio 的异常类型做校验,而不是期望原始异常类型。
4.4 鸿蒙设备上的真机差异
虽然 fake_http_client 是纯 Dart 实现,理论上是跨端一致的,但我在鸿蒙真机上跑测试时还是踩过几个坑。第一个坑是 HarmonyOS 的 Flutter 引擎对 HttpOverrides 的支持存在一个旧版本 bug,个别版本上全局设置无效。这个问题的规避方法是升级 Flutter SDK 到官方适配鸿蒙的稳定版本,并且确认 ohos 平台的引擎补丁已合入。第二个坑是鸿蒙的调试模式连不上抓包工具,导致排查问题时看不到真实请求是否被 mock 替代。我后来统一在 MockResponse 的回调里打日志,用日志代替抓包,反而效率更高。
第三个坑和证书有关。鸿蒙平台对自签名证书的校验策略和 Android 不太一样,如果你的业务代码里做了证书校验,fake_http_client 的模拟响应不会真正走证书校验流程,这会导致某些在 release 环境才会出现的证书错误在测试环境根本复现不出来。我的建议是保留一条真实网络请求的测试用例,不 mock,专门用来验证证书链路,其余测试全走 fake_http_client。
4.5 常见问题速查表
我把日常排查的知识点整理成一个速查表,方便你遇到问题时快速定位:
| 问题现象 | 可能原因 | 排查思路 |
|---|---|---|
| 请求走了真实网络 | 初始化时机太晚 | 确保在 runApp 前设置 HttpOverrides.global |
| mock 修改不生效 | 连接池复用旧配置 | 切换场景前将 HttpOverrides.global 置空再设置 |
| 并发回调顺序不稳定 | 依赖了注册顺序 | 使用 Future.wait 统一等待后再断言 |
| 脏数据导致进程崩溃 | 异常未被捕获 | 设置全局异常兜底,断言用包装后的异常类型 |
| 鸿蒙真机上 mock 失效 | Flutter SDK 版本较旧 | 升级到适配鸿蒙的稳定版本 |
| 证书类问题无法复现 | mock 不走证书链路 | 保留独立用例走真实网络做验证 |
5. 一些值得留意的配置细节
5.1 通配 URL 正则的写法
注册路由时,URL 正则的写法决定匹配精确度,太宽松可能误伤其他接口。例如你想匹配所有 /api/order 开头的接口,正则写成 ^/api/order 就好,不要加上 .* 末尾匹配,否则可能把 /api/orderExtra 也匹配进去。如果项目路径规则复杂,强烈建议先用一小段测试代码验证正则匹配是否符合预期,再正式写进路由表。
5.2 环境切换与发布安全
脱网测试矩阵只在开发和测试阶段使用,绝对不能带到生产包。前面讲的 assert 技巧是最基础的一道防线,但还不够。我还会在初始化函数里读取 dart-define 的环境标识,只有标识明确等于 test 或 dev 时才允许注册 mock。两个条件同时满足,才能最大限度防止误操作把 mock 带到 release 构建里。
5.3 团队协作时的配置维护
测试矩阵的配置文件最好独立成模块,路由注册函数按照业务模块拆分。多团队共用时容易发生“你改了路由,我这边测试挂了”的问题,因此建议为每个路由注册函数设计 whitelist 参数,只在当前模块的测试用例里生效。另外,新增路由时一定要补注释,说明这个 mock 对应什么业务场景,避免后续维护的人无从下手。表结构清晰、注释到位的 mock 配置,能省掉很多无效沟通。
6. 写在最后的实操体会
我在这套体系上反复打磨了挺长时间,最大的感受是:fake_http_client 把“模拟网络异常”这个原本又慢又不稳定的活儿,变成了可以像单元测试一样快速迭代的工作。你不需要再靠拔网线来测超时,也不需要修改业务代码来注入脏数据。尤其是脱网测试矩阵的思路,把正常、超时、拥塞、脏数据这些场景排列组合成一批可重复执行用例,真正做到让网络异常在 CI 阶段就被挡在门外。另外再分享一个小技巧:如果你要把这套方案推广给团队,先在两个最容易出问题的接口上试点,比如登录和首页聚合接口,跑通后再扩展到全模块。这样可以避免一次性改造过多导致排查困难,也让团队更容易接受这个新流程。
