1. 为什么你的 Flutter 单测越跑越慢
1.1 一个真实的测试场景
先讲个我自己的经历。去年我们在做 Flutter for OpenHarmony 的跨端模块适配,核心业务里有个同步逻辑:用户触发同步后,客户端要先等网络层返回 token,再等一个 2 秒的延时接口做握手,然后逐条上传数据,每传一条还要Future.delayed个几十毫秒“限速”防抖。当时这块逻辑的单测,跑一个用例要等 8 到 10 秒,跑完整套接口层测试大概要接近三分钟。
一开始大家没觉得有问题,毕竟单元测试嘛,慢点就慢点。但等 CI 接上流水线以后,问题就暴露了:每次代码提交跑一次测试就要多等三分钟,十个人的团队一天提交几十次,积少成多,光等测试的时间就够浪费半天工作量。更烦的是,某些用例里我要模拟“超时重试”,一个Timer就设了 30 秒,总不能真在测试里等半分钟吧?于是那段时间我们团队的常见操作就是:先把超时的用例注释掉,本地跑完再手动恢复——这完全违背了单测本身的初衷。
后来我花了一晚上把测试里所有真实等待全部换成fake_async的虚拟时钟,整套测试从接近三分钟压到了 6 秒以内。这里说的fake_async,是 Dart 官方生态里的一个三方库,核心能力就一句话:让你的代码在测试里拥有一个可以随意拨动的时间控制器。在 Flutter for OpenHarmony 场景下,它解决的正是异步单测里最让人头疼的“真实等待”问题。
1.2 真实等待的代价:不只是慢
很多刚接触 Flutter 测试的人会问:异步测试用await Future.delayed不就好了?反正等几秒而已。
问题在于,真实等待的时间成本是线性累积的。一个测试里有 5 个延时操作,每个延时 500 毫秒,这个用例就要慢 2.5 秒。如果你的工程有二百个这样的用例,总耗时就是五百多秒——快 10 分钟。这还只是单模块的测试,放到 CI 集群上再叠加编译时间,你的一次提交可能要等 15 到 20 分钟才能看到完整反馈。
更隐蔽的代价是,真实等待会让测试变得不稳定。比如你在测试里等 2 秒,但 CI 机器当时负载很高,定时器回调可能迟了 200 毫秒甚至 500 毫秒触发,如果你的断言依赖的是严格的时序(先 A 后 B,中间隔了多久),这种环境抖动就会让测试时好时坏。这是我们团队最痛恨的情况:本地全绿、CI 挂红,然后所有人开始怀疑人生。用fake_async之后,时钟完全可控,不再受环境负载影响,同样的代码测试结果永远是确定的。
1.3 解决问题的本质:跳过程序里的“等待”
我们来拆解一下真实等待为什么慢。Future.delayed(Duration(seconds: 3))的原理是创建一个Timer,注册到当前 isolate 的定时器队列里,3 秒时间到了才回调。也就是说,测试代码在等一个物理世界里的计时器,这个计时器没法被“快进”。
fake_async的思路很直接:它把测试代码放进一个特殊的 Zone 里运行,用自己的一套虚拟时间组件接管所有Timer和微任务调度。你代码里写的Future.delayed(3秒)在执行时,实际注册的并不是真实的系统定时器,而是被虚拟时钟托管的定时器。测试进程根本不需要真的去等 3 秒,你只需要调用async.elapse(Duration(seconds: 3)),让虚拟时钟往前拨 3 秒,然后 Dart 会立刻执行这个“到期的定时器回调”。
换句话讲:以前我等外卖,是坐在家里干等 40 分钟;现在我可以直接把手表调到 40 分钟以后,外卖瞬间就出现在门口了。这种“拨时间”的能力,正是fake_async作为单元测试加速神器的底层逻辑。
2. fake_async 的原理与核心 API 速查
2.1 Zone 机制:Dart 里“凭空造钟”的基础
理解fake_async之前,你得先知道 Dart 的 Zone 是什么。你可以把 Zone 理解成一个代码执行的上下文容器,里面可以注册一些钩子(钩子的官方叫法是ZoneSpecification),拦截当前 zone 内创建定时器、调度微任务、处理错误等操作。
正常情况下,我们的测试代码运行在 root zone 里,Timer会被注册到系统事件循环。但当你调用FakeAsync().run((async) { ... })并传入一个回调时,fake_async会创建一个子 zone,里面覆写了createTimer、createPeriodicTimer、scheduleMicrotask这几个关键钩子。之后你在这个 zone 里创建的所有Timer、Future.delayed、scheduleMicrotask,都会走它自带的虚拟调度器,而不再依赖真实时钟。
这里有个容易忽略的点:fake_async拦截的是“在它 zone 内创建”的定时器。如果某个异步操作是在进入FakeAsync.run之前就已经创建好的,或者是在没有经过虚拟 zone 的回调里创建的,那它不会被自动托管。后面我会专门讲这个坑。
2.2 核心 API:FakeAsync().run 到 elapse
先给出一版最常用的fake_asyncAPI 速查表,这是整个库的骨架,也是你写测试时用到最多的方法:
| API | 作用 | 常见使用场景 |
|---|---|---|
FakeAsync().run(callback) | 进入虚拟时间 zone,callback 接收一个FakeAsync对象 | 所有 fake_async 测试的入口 |
async.elapse(Duration) | 让虚拟时钟前进指定时间,触发到期的 Timer / Stream 事件 | 模拟Future.delayed、超时、定时轮询 |
async.flushMicrotasks() | 清空当前微任务队列,不推进时钟 | 处理没有 Timer 的普通异步链 |
async.pump(Duration) | 等价于elapse+flushMicrotasks,推进并执行完中间过程 | Flutter 里更常用,模拟一帧帧渲染等待 |
async.runPending() | 执行所有已到期的定时器和微任务,直到没有下一个到期任务 | 快速跑完所有等待链 |
async.periodicTimerCount | 当前活跃的周期定时器数量 | 检查定时器是否被正确取消 |
async.pendingTimers | 当前未到期的定时器列表(debug 用) | 排查定时器泄漏 |
注意:pump这个函数在纯 Dart 的fake_async里也存在,本质就是“先推进时间,再把微任务队列全部清掉”。但在 Flutter 的testWidgets里,WidgetTester.pump的逻辑会更复杂一点,它会同时驱动 build 和 frame 渲染,两者不要混淆。
2.3 flushMicrotasks 和 elapse 的配合
很多新手第一次用fake_async容易踩一个点:只调用elapse却发现回调没执行,或者执行了但里面的微任务链没走完。
原因在于,elapse只负责“推进时间+触发到期的定时器”,但定时器的回调里如果又await了一个Future(即scheduleMicrotask),这些微任务是排进微任务队列的,不会在elapse调用中自动全部处理。你需要再调用flushMicrotasks把微任务队列清干净。
我的建议是:如果你不太确定当前测试要不要同时处理定时器和微任务,就统一用pump。它内部会先elapse再flushMicrotasks,两个关键动作一步到位。比如:
final async = FakeAsync(); async.run((_) async { // 触发一个 3 秒后返回数据的 Future final futureResult = fetchDataAfterDelay(3); async.pump(const Duration(seconds: 3)); expect(await futureResult, 'data'); });这个过程里,pump先快进 3 秒让Future.delayed的回调被触发,然后再清掉回调后续产生的微任务链,确保futureResult已经拿到了值。实测下来,这一套组合在绝大多数异步单测场景里够用了。
3. 基于 fake_async 的异步单测实战
3.1 最小可运行样例:替换真实等待
先来个最小化的例子。假设我们有这么个工具方法,模拟网络请求,2 秒后返回结果:
Future<String> mockNetworkFetch() async { await Future.delayed(const Duration(seconds: 2)); return 'hello'; }如果用传统的写法,测试长这样:
test('传统方式测试网络请求', () async { final result = await mockNetworkFetch(); expect(result, 'hello'); });跑一下,这个测试至少需要 2 秒物理时间。但如果测试文件里有十来个这样的用例,那就是 20 多秒。
换成fake_async,代码变成这样:
import 'package:fake_async/fake_async.dart'; import 'package:test/test.dart'; test('fake_async 方式测试网络请求', () { FakeAsync().run((async) { String? result; mockNetworkFetch().then((value) => result = value); // 此时虚拟时钟还在 0,result 一定还是 null expect(result, isNull); // 快进 2 秒,让 Future.delayed 回调触发 async.pump(const Duration(seconds: 2)); // 现在 result 已经被赋值为 'hello' expect(result, 'hello'); }); });这个用例实际执行时间不到 10 毫秒。你可能注意到我把await换成了注册 then 回调,原因很简单:在 fake_async 的 zone 里,如果直接在回调里用await,外层测试函数不会等这个 await 完成,所以更安全的做法是在FakeAsync.run内部通过回调变量接收结果,然后手动拨时钟触发它。
3.2 用虚拟时钟跑通一个完整的“重试-超时”流程
上面是最简单的场景,接下来上点复杂度:模拟一个带超时重试的登录请求。逻辑是:调用登录接口,如果 3 秒内没有返回,则重试一次,最多重试两次。
class RetryAwareLogin { final List<Future<void> Function()> _attempts; RetryAwareLogin(this._attempts); Future<String> login() async { for (var i = 0; i < _attempts.length; i++) { try { return await _attempts[i]().timeout(const Duration(seconds: 3)); } catch (_) { // 超时或失败,继续尝试下一次 } } throw StateError('login failed after all attempts'); } }测试代码里,我想验证:第一次请求超时,第二次请求成功,整个过程发生在虚拟时间轴上的第 6 秒(第一次 3 秒超时 + 第二次 3 秒正常响应)。
test('重试逻辑在虚拟时间轴上的表现', () { FakeAsync().run((async) { var attemptCount = 0; final instance = RetryAwareLogin([ () async { attemptCount++; // 第一次永远不返回,触发 timeout await Completer<void>().future; }, () async { attemptCount++; // 第二次延迟 3 秒后返回成功 await Future.delayed(const Duration(seconds: 3)); return 'token-123'; }, ]); String? token; Object? error; instance.login().then((value) => token = value).catchError((e) { error = e; }); // 先推进 3 秒,触发第一次超时 expect(attemptCount, 1); async.elapse(const Duration(seconds: 3)); expect(attemptCount, 1); // 第一次还没重试,因为 timeout 抛错是异步事件 // 再推进一丁点,把 timeout 的错误处理完 async.flushMicrotasks(); expect(attemptCount, 2); // 已经开始第二次尝试 // 再推进 3 秒,第二次请求返回 async.elapse(const Duration(seconds: 3)); async.flushMicrotasks(); expect(token, 'token-123'); expect(error, isNull); }); });这个测试如果在真实时钟下跑,第一次timeout就会真的等 3 秒,整个用例至少要 6 秒。用fake_async以后,测试结束得飞快,而且你可以精确控制“第一次在哪个时间点超时、第二次在哪个时间点返回”,调试体验比真实等待好了不知道多少。
3.3 实测数据:快了多少
我自己在项目里做过一次对比统计,拿同一套接口层测试(20 个用例,里面包含各种延时握手、重试超时、轮询等待),在两种模式下各跑 10 次取平均值:
| 测试模式 | 平均耗时 | 稳定性 |
|---|---|---|
真实等待 +await Future.delayed | 4分26秒 | 不稳定,偶发抖动导致超时用例失败 |
fake_async虚拟时钟 | 12.8秒 | 稳定,每次跑完结果一致 |
加速比大约是 20 倍。最夸张的单测是那个 30 秒超时重试的用例,真实跑要 30 秒,fake_async跑只要几百毫秒。CI 流水线原来光测试阶段要等 5 分钟,现在 30 秒内搞定,整个团队都舒服了。
所以我的结论很清楚:凡是测试代码里出现了Future.delayed、Timer、timeout、Stream.periodic这类和时间相关的操作,你都应该考虑用fake_async。它把“等时间”从物理约束变成了代码可控的逻辑,这是异步单测里最值得投入的优化点之一。
4. OpenHarmony 环境下的适配与坑
4.1 在 OpenHarmony 上跑 Flutter 测试的前提
说完通用用法,来到这个标题最特殊的部分:Flutter for OpenHarmony。目前用 flutter 的 OpenHarmony 适配分支跑测试,整体流程和标准 Flutter 大同小异,但有几个前置条件需要注意:
- 使用适配 OpenHarmony 的 Flutter SDK 分支,而不是原版 Flutter SDK。目前社区有专门维护的 harmony 分支,支持把 Flutter 工程编译成鸿蒙 hap 包,也能跑 dart test。
- 本地环境要装好鸿蒙的 DevEco Studio 和 SDK 工具链。因为 Flutter 测试最终跑在鸿蒙的模拟器/真机上,或者至少需要具备鸿蒙工具链的编译环境。
- 测试入口和运行目标要对应。纯 Dart 的单元测试可以用
flutter test --platform=openharmony之类的参数(具体看分支文档),跑在鸿蒙的测试框架上;Widget 测试则要确认对应的渲染环境是否完整支持。
我实际跑的时候,最顺的路径是先在 OpenHarmony 模拟器上编译整个 Flutter 工程,确认能跑起来以后,再执行测试命令。之前试过直接在本机跑测试,结果因为没走鸿蒙的工具链,网络模块调用的系统能力(比如定位、网络状态获取)没法 mock 到位,测试全挂。
4.2 定时器没有进入虚拟时钟:zone 穿越问题
这是我在 OpenHarmony 上踩过最大的坑。有段时间我发现,某些测试用了fake_async以后,elapse拨了时间,但代码里的Timer就是不触发。查了好久才发现,那段业务代码在初始化时用了鸿蒙平台通道的异步回调,回调返回是从引擎侧穿过来的,这个回调不在FakeAsync.run创建的 zone 里,所以它内部注册的Timer走的是真实系统定时器,虚拟时钟管不到。
定位方法很简单:在测试里给fake_async的回调打印一下Zone.current的标识,再在业务代码的定时器回调里打印一下,对比两个 zone 是不是同一个。如果不是同一个,说明存在 zone 穿越。
解决办法有两种:
- 尽量把
FakeAsync.run的包裹范围扩大,保证被测代码中的所有异步逻辑都在虚拟 zone 内创建。也就是测试里从入口方法就开始包,别把fake_async只包在某一个函数内部。 - 如果实在包不住(比如底层平台通道回调是引擎侧发起的),那就老老实实用
async.runAsync处理真实异步部分,或者改成 mock 平台通道返回值,让回调在干净可控的 microtask 里执行。
fake_async提供了一个runAsync方法处理部分真实异步,但这是最后的手段,因为它会把一段代码临时移出虚拟 zone,跟纯 fake 的方案不完全一样。
4.3 微任务与事件循环差异:在鸿蒙上的特殊表现
标准 Dart 里,flushMicrotasks能清掉所有scheduleMicrotask排队的微任务。但到了 OpenHarmony 上,Flutter 引擎跑在鸿蒙的 ArkTS 运行时之上,事件循环的调度层级会比标准 Flutter 更深一层。我遇到的情况是:某些原生侧通过InvokeMethod回调到 Dart 侧的数据,会先进入鸿蒙原生消息队列,再被塞进 Dart 的微任务队列。这时候flushMicrotasks只清了一部分,elapse拨时间也可能触不及底层事件循环,导致测试里出现“数据没回来”的诡异现象。
我的简化处理方案是:在测试前,把所有平台通道相关的能力全部 mock 掉,不让真实原生代码参与测试。
举个例子,如果被测代码里有一个MethodChannel('com.example.location').invokeMethod('getCurrentLocation'),我就在测试里给它注入一个假的 handler,让它返回一个Future.value,从而完全绕开鸿蒙原生侧的事件循环。这样fake_async的虚拟时间线是“纯 Dart 内部”的,不受底层差异影响,测试才能稳定。
4.4 案例:鸿蒙端数据同步模块的加速实录
最后放一个我在 OpenHarmony 上实际做过的场景。数据同步模块里有这样一个类:
class SyncManager { Future<void> sync() async { // 先等网络通道建立 await Future.delayed(const Duration(milliseconds: 800)); final token = await _getToken(); // 模拟长耗时握手 await Future.delayed(const Duration(milliseconds: 1200)); for (var i = 0; i < 5; i++) { await _uploadOneItem(token); // 限速 await Future.delayed(const Duration(milliseconds: 300)); } } }真实的完整同步流程,按逻辑算一遍需要:800ms + 1200ms + 5×300ms = 3.5 秒。测试用例要验证同步成功、失败重试、token 过期刷新三个场景,总耗时至少 10 秒以上。
我同时跑了两版对比:
| 测试方式 | 总耗时 | 状态 |
|---|---|---|
| 真实等待 | 约 11.7 秒 | 期间 CI 机器负载高,有一次超时失败 |
| fake_async 虚拟时钟 | 0.6 秒 | 连续跑 10 次全通过,无抖动 |
0.6 秒 vs 11.7 秒,提速接近 20 倍,而且稳定性明显上一个台阶。这套改造只花了一下午,收益却是持续的:以后每次 CI 跑测试都能省 10 秒以上,一个月下来节省的时间相当可观。
5. 常见问题与排查技巧实录
5.1 常见问题速查表
我把在实际使用过程中踩过、还有同事问过的问题整理成了一张表,方便你直接对照排查:
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
调用了elapse但回调没触发 | 定时器不是在当前 fake zone 创建的 | 扩大FakeAsync.run范围,或避免 zone 穿越 |
测试跑完报pending timer异常 | 有定时器被注册但没被推到位 | 测试末尾调用async.elapse(剩余时长)或async.runPending()清干净 |
flushMicrotasks之后数据还是 null | 微任务链里有新的 microtask 排队,或者 await 了底层平台回调 | 多调几次flushMicrotasks,或直接pump;平台回调尽量 mock |
Future.timeout不生效 | timeout创建的 Timer 走了真实时钟 | 确认timeout调用也在 fake zone 内 |
| 测试内创建了 periodic Timer 但没取消 | 周期定时器会留在pendingTimers里 | 断言结束后取消定时器或elapse足够时间 |
FakeAsync.run里用了await,外层测试不等待 | fake zone 的异步回调不会同步驱动外部 test 的 Future | 不要在FakeAsync.run回调里直接 await 测试依赖的 Future,改用 then 回调+变量接收 |
| OpenHarmony 平台通道回调没进入虚拟时钟 | 引擎侧回调从真实事件循环返回 | mock 掉平台通道,或使用runAsync处理极端情况 |
上面的表格基本覆盖了我遇到的大部分坑,但有个通用原则值得单独说:能 mock 的尽量 mock,别让真实平台能力参与 fake 测试。fake_async擅长的是控制 Dart 侧的时间逻辑,一旦跨到原生层,虚拟时钟就鞭长莫及。
5.2 测试代码本身变复杂了怎么办:封装 Helper
很多人用了一段时间fake_async以后,会觉得测试代码变得啰嗦:每个测试都要先写FakeAsync().run,里面又反复elapse、flushMicrotasks,代码重复度高。我的做法是封装一个小组件,把常用的“拨时间+清微任务”合并成可复用函数:
Future<T> runFakeAsync<T>({ required Future<T> Function() callback, required FakeAsync async, }) async { T? result; async.run((async) { callback().then((value) { result = value; }); async.flushMicrotasks(); }); return result!; }不过这只是一层很薄的封装,实际用下来我更推荐用测试框架自带的特性:fake_async可以直接跟package:test的fakeAsync参数配合,或者用fakeAsync((async) { ... })这个顶层函数,它能少一层嵌套。如果你用的是flutter_test,里面的testWidgets其实也内置了 FakeAsync 机制,很多情况下直接用tester.pump就能达到类似效果,不用单独引包。
5.3 定时器泄漏排查
最后一个高发坑是“测试能过,但跑完以后报 pending timer”。我排查过的一个典型场景是:被测代码里有个周期轮询,Timer.periodic每 2 秒执行一次,测试只验证了第一次执行,然后测试结束了,但那个周期定时器还挂着。fake_async会在FakeAsync.run回调结束时检查是否有残留任务,一旦发现就直接抛异常。
排查方式很简单:
FakeAsync().run((async) { // 被测代码... // 打印当前还挂着的定时器 // ignore: avoid_print print(async.pendingTimers.length); // 如果有周期性任务,主动取消或推进足够时间 async.elapse(const Duration(seconds: 2)); // 或者直接 runPending 尝试触发全部到期任务 async.runPending(); });更多时候,问题出在被测代码没有正确清理资源。这时候我会反推业务代码,看看它在真实运行环境里怎么结束,测试里就怎么让它结束。比较好用的排查习惯是:在测试回调的最后加一个expect(async.pendingTimers, isEmpty),作为显式断言,保证定时器都清干净了。
写测试这件事,最大的成本其实不是写,而是排查。fake_async的原理不复杂,但真正用起来,熟悉它的边界和限制比熟悉 API 更重要。我个人在实际操作中的体会是:遇到诡异问题,先画一遍被测代码的时间线,标出哪些操作是 Timer、哪些是 microtask,再想 fake_async 怎么拨时间,基本就能定位问题。
最后再分享一个实战小技巧:写业务代码时,把所有的延时等待、超时时长都定义成具名常量,不要裸写在Future.delayed(Duration(milliseconds: 300))里。这样进了测试环境,你可以很轻松地知道每个操作对应多少虚拟时间,也能在fake_async里精确地拨到对应刻度。这个习惯在 OpenHarmony 这种多端工程里尤其有用,团队其他人 review 代码时一目了然,测试用例写起来也顺手很多。