几个月前,团队接到一个有点特殊的需求:把手头一个 Flutter 项目的单元测试底座做一次“换芯”,确保测试代码能跑在鸿蒙(HarmonyOS)端侧,同时还要顺手把整套 Mock 逻辑从繁琐的模板代码里解放出来。核心要适配的对象是一个内部封装的 Dart 测试工具库,代号就叫dart_test_utils。这个库在原有 Android/iOS 侧已经跑得很稳,但鸿蒙的运行时、编译链、测试入口和事件循环机制都有自己的一套,照搬显然不行。
这篇文章就是纯经验复盘,不会写成厂商文档。我会把dart_test_utils鸿蒙化的完整思路、适配细节、踩坑记录、端侧测试流水线的搭建过程都摊开讲透。如果你正在做 Flutter 鸿蒙化适配,或者手里有一个重度依赖单元测试增强和 Mock 简化的 Flutter 工程,这篇文章可以直接当参考手册用。
1. 项目整体设计与思路拆解
1.1 需求里真正要解决的是“测试底座迁移”
先说结论:鸿蒙化适配最难的部分从来不是某一行 API 的替换,而是整个测试执行模型的差异。Flutter 测试生态默认是跑在 Dart VM 上的,测试用例的生命周期、异步调度、平台通道都是围绕 VM 设计的。鸿蒙端侧的 Flutter 运行机制虽然基于同一套 Dart 层引擎,但底层宿主环境换成了鸿蒙运行时,部分平台相关的能力(比如事件循环挂起、消息回调分发、文件路径访问)行为会不一致。
dart_test_utils这个名字听起来只是一个工具集,但它内部实际上承担了三类职责:
- 单元测试增强:包括超时控制、测试分组、失败重跑、数据驱动用例生成、异步闭包自动等待。
- Mock 逻辑简化:提供一套比
mockito更轻量的 Mock 对象创建方式,减少胶水样板代码。 - 执行环境适配:检测当前运行平台,决定测试用例的调度方式和资源释放策略。
所以,鸿蒙化不是把dart_test_utils的源码“翻译”一遍,而是要让它的三个职责在鸿蒙端侧各自找到正确的落点。
1.2 三个关键设计决策
这里直接说结论,也是我们在方案评审阶段争论最久的三件事:
第一个决策:测试执行入口不做二次封装。很多人一提到鸿蒙化就想在鸿蒙侧写一段 Java/ArkTS 来桥接,但 Flutter 测试的入口仍然是flutter test或dart test跑起的 Dart 层逻辑。鸿蒙化要做的是保证这些 Dart 层逻辑在鸿蒙宿主环境下能稳定执行,而不是另起炉灶。我们在适配层只提供宿主侧的工具链配置和反射接入点,把测试引擎的控制权留在 Dart 层。
第二个决策:Mock 能力走代码生成 + 运行时解析双通道。dart_test_utils原先的 Mock 简化封装大量依赖dart:mirrors,问题是 Flutter 在 release 模式会禁用这个库,鸿蒙侧更不可能支持。所以我们改造成首选用build_runner生成 Mock 实现类的代码,保留一套精简的运行时标注解析作为 fallback。这个改造工作量不小,但换来的是鸿蒙端侧可以直接跑原本存量的测试用例,不需要重写。
第三个决策:测试报告与日志走统一适配接口。鸿蒙侧的设备日志系统和 Android 的logcat有差异,测试执行结果如果各写各的,流水线根本没法接。我们让dart_test_utils的所有输出统一走一个可替换的Reporter接口,鸿蒙侧提供一个harmony_reporter实现,把结构化结果上报到流水线。这样无论是本地跑还是流水线跑,结果都能对齐。
1.3 适配的边界在哪里
这一点特别想强调。鸿蒙化适配不是“所有代码必须跑在鸿蒙上”,而是“你的测试能力在鸿蒙上可用且结果可信”。
我们明确划了几个不在本次范围的项:
- 不追求让每个测试断言在鸿蒙设备上比 Android 设备跑得快。
- 不把纯 Dart 逻辑层的单元测试强行拉到真机上跑,能走宿主机 Mock 就走宿主机。
- 不碰 UI 测试录制回放这类与具体设备强耦合的工具链。
边界的意义在于控制风险。因为鸿蒙适配首先是要让原有用例能迁移、能跑出可信结果,慢慢再做增强。如果一开始就贪大求全,最后大概率是套了一个“能跑起来”的壳,真出问题的时候谁也说不清是测试代码错了还是适配层错了。
2. 核心细节解析:dart_test_utils 的功能拆解与鸿蒙运行时的映射关系
2.1 dart_test_utils 的模块结构
dart_test_utils不是一个大而全的魔法包,它按功能拆成了四个模块:
core:测试上下文、分组、注解、结果模型。mock:Mock 对象构造器、行为录制、回调速写工具。enhancer:超时控制器、数据驱动用例、失败信息增强。platform:平台检测、路径与资源访问、Reporter 接口。
其中core和mock是核心,enhancer是日常测试体验提升的关键,platform在鸿蒙化适配前的地位不高,但恰恰是这个模块承载了本次绝大部分的工作量。
2.2 运行时差异是鸿蒙化隐藏的坑
Flutter 鸿蒙化适配到测试领域,最容易被忽略的是异步事件循环的差异。Dart VM 在 Android 上可以通过Activity生命周期感知到前台后台切换,某些定时器会在后台被节流甚至冻结。鸿蒙侧则有一套分布式任务调度机制,测试代码如果长时间持有某个事件锁,系统层面的调度策略会直接影响用例的最终结果。
举例来说,enhancer模块里的超时控制器原本是用Future.any加Timer实现的:
Future<T> runWithTimeout<T>(Future<T> Function() task, Duration timeout) { return Future.any([ task(), Future.delayed(timeout, () => throw TimeoutException(...)), ]); }这个写法在纯 Dart VM 上没有问题,但在鸿蒙端侧测试场景下,如果task()里发起了一个跨平台的异步调用(比如 HTTP 请求或本地存储读写),主 isolates 的事件循环在等待时可能出现空转问题。简单说,就是Future.any里的两个 Future 都处于挂起状态时,鸿蒙的事件循环调度并不会像 Android 那样尽快唤醒回调,导致实测的超时时间明显长于设定值。
解决方案是我们给超时控制器加了一个心跳探针。在等待期间用一个极低频率的Timer.periodic去主动触碰事件循环,一旦探测到异常的空转状态就立即触发超时逻辑。这个技巧在其他平台可能没必要,但在鸿蒙化场景是必须的。
2.3 Mock 逻辑简化的鸿蒙化思路
mock模块设计的初衷是减少手工 Mock 类。以前用mockito都是这种写法:
class MockUserRepository extends Mock implements UserRepository { @override Future<User> getUser(String id) async { return super.noSuchMethod(Invocation.method(#getUser, [id])); } }dart_test_utils的思路是使用代理对象动态转调,不再为每个类创建 Mock 子类。核心代码类似这样:
final mock = MockFactory.create<UserRepository>(); whenCall(() => mock.getUser('1001')).thenReturn(User(id: '1001', name: 'tester'));如果没有dart:mirrors,MockFactory.create这种动态形式就受限。所以在鸿蒙化适配时,我们改为支持build_runner生成代码,同时提供一个运行时的MockProxy基类:
class MockProxy implements MockSpecProvider { @override dynamic resolveInvocation(Invocation invocation) { final handler = registeredHandlers[Symbol(invocation.memberName.toString())]; return handler?.call(invocation); } }生成代码的方式在鸿蒙上没有任何阻碍,就是标准的 Dart 代码。唯一的区别是生成流程要先跑build_runner,我们把它固化进了端侧流水线里,这属于工程化层面的配套改动,具体在第四章展开。
2.4 单元测试增强模块的细节补全
dart_test_utils的增强模块有几个特别实用的能力,鸿蒙化之后依然保留:
@Timeout注解:可以设定到毫秒级别,底层用上面提到的心跳探针方案实现。retryOnFailure:用例失败自动重试的机制,对偶发性的端侧资源竞争问题很管用。dataTable:数据驱动用例生成器,每一行数据自动生成一个独立用例,适合参数化测试。structuredLog:把关键步骤输出为结构化 JSON,用例失败时可以快速定位到具体执行段。
这套增强能力的底层依赖是test包的标准注解体系,鸿蒙化适配过程中在package_config里显式声明鸿蒙平台的test入口配置即可,不需要魔改test包本身。
3. 端侧测试用例工程化流水线实战
3.1 目标场景:从“本地能跑”到“流水线可收口”
很多团队做鸿蒙化适配,做到“我本机能跑”就停了,但真正硬核的是,把案例接入到流水线,让每次变更提交都能自动在鸿蒙设备上跑通核心用例。不夸张地说,这个环节才是对质量保障最有价值的。
流水线的核心环节如下:
- 代码提交触发鸿蒙构建镜像任务,产出可执行的 Flutter 鸿蒙工程。
- 构建完成后自动安装到指定的鸿蒙真机或模拟器。
- 执行
dart test并指定鸿蒙测试入口脚本。 - 收集测试报告,解析失败用例日志。
- 失败用例自动归档到报表中心,并附上编译期与运行期的日志快照。
3.2 流水线脚本的实测写法
测试拉起脚本是我们的实测版本,可以直接参考:
#!/bin/bash set -e BUILD_ROOT=${BUILD_ROOT:-/data/ci/workspace} PROJECT_DIR=${BUILD_ROOT}/flutter_harmony_demo DEVICE_SN=${DEVICE_SN:-emulator-1} TEST_FILTER=${TEST_FILTER:-tests/devices/harmony_test.dart} cd ${PROJECT_DIR} # 切换 Flutter 鸿蒙工具链 export FLUTTER_HARMONY_SDK=/opt/flutter_harmony_sdk export PATH=${FLUTTER_HARMONY_SDK}/bin:${PATH} # 生成 Mock 代码 flutter pub run build_runner build --delete-conflicting-outputs # 构建鸿蒙 Flutter 工程(可以接 hvigor 的编译任务) flutter build harmony --debug # 安装到鸿蒙设备 hdc install ./build/ohos/outputs/*.hap # 拉起测试用例,注意我们统一走 dart test 入口 dart test --platform=harmony ${TEST_FILTER} --reporter=json > ${WORKSPACE}/test_result.json # 解析报告并归档 dart ./tooling/parse_harmony_report.dart ${WORKSPACE}/test_result.json这里有一个值得细说的点:--platform=harmony并不是dart test官方提供的参数,而是dart_test_utils通过自定义TestPlatform注册进去的。整个流程中也会做一次“重复兜底”——如果dart test因为某些宿主异常没跑完,流水线脚本会从test_result.json里提取部分结果,保证不因为单个测试进程崩溃就丢失全部报告。
3.3 流水线的数据流和存储
测试报告我们存成 JSON,不是为了凑格式,而是为了后续能灵活对接不同平台。parse_harmony_report.dart的简化逻辑大致是这样:
void main(List<String> args) { final jsonPath = args[0]; final raw = File(jsonPath).readAsStringSync(); final decoded = jsonDecode(raw); final suiteName = decoded['suiteName']; final totalCount = decoded['tests'].length; final failedCount = decoded['tests'] .where((t) => t['status'] == 'failed').length; // 写入统一的报表中心接口 }实测下来,这套数据流的好处是,研发拿到test_result.json可以直接对比两次提交的用例报告差异,不需要人工去刷设备日志。
3.4 真机注册与并发调度
流水线不能只用一台鸿蒙设备,否则用例一多就排队排到天荒地老。我们封装了一个设备池管理脚本,核心是根据用例标签做设备分配,比如:
- 纯 Dart 逻辑层用例跑在宿主机模拟器上,不占真实设备。
- 涉及平台通道的用例,按
harmony_device:001到harmony_device:004四个真机槽位轮询分配。 - 每个设备槽位同一时刻只有一个测试任务,避免相互污染。
设备池脚本的关键逻辑是维护一个简单的锁文件:
function acquire_device() { for dev in $DEVICE_LIST; do if mkdir /tmp/ci-device-lock-$dev 2>/dev/null; then echo $dev return 0 fi done return 1 }用锁目录这个做法虽然简单,但在实际流水线里比用 Redis 之类的外部服务要少很多故障点,你不需要额外维护一个“锁服务”的依赖。
4. 实操过程与关键步骤实录
4.1 环境准备细节
这一步看起来没什么技术含量,但恰恰是翻车重灾区。鸿蒙化适配 Flutter 测试,需要有如下几个关键工具就位:
- Flutter 鸿蒙 SDK 工具链,建议在独立目录下解压,不要覆盖原 Flutter。
- 鸿蒙 SDK 以及
hdc工具,注意系统环境变量内部不要配置冲突。 - 一套鸿蒙模拟器或真机设备,并预先打开开发者调试模式。
dart_test_utils源码,以及必要的绿色开发生成依赖。
建议在项目根目录建立一个.harmony_toolchain说明文档,把各自的版本号、安装路径都写清楚。团队协作时,这比口头沟通有效得多。
4.2 依赖改造与 pubspec 配置
在pubspec.yaml里需要做两块改动:
- 把
dart_test_utils的 SDK constraint 放宽,因为鸿蒙适配的某些分支可能需要接入harmony_platform_interface之类的能力包。 - 增加鸿蒙平台下的
dev_dependencies,包括我们自定义的harmony_test_bootstrap。
目标是把平台相关的依赖隔离在 dev dependencies 区域,不要污染正式发布的 lib 代码。
4.3 核心适配层的代码实现
我们新增了一个harmony_test_platform.dart,专门处理鸿蒙宿主环境的初始化:
class HarmonyTestPlatform extends TestPlatform { @override String get name => 'harmony'; @override Future<void> setup() async { // 调用鸿蒙 SDK 提供的能力进行环境检查 final trace = HarmonyRuntimeChecker.check(); if (!trace.isReady) { throw StateError('Harmony runtime is not ready: ${trace.detail}'); } // 注册鸿蒙 Reporter TestReporter.register(HarmonyJsonReporter()); } }在测试入口里显式调用:
void main() { final platform = HarmonyTestPlatform(); platform.setup(); runTests(); }这段代码看起来很短,却是整套适配的“心脏”,因为setup()里会设置鸿蒙运行的关键参数(比如设备 ID、日志目录、超时缩放因子)。如果这里不做好,后面的用例跑起来就是各种玄幻现象,找不到规律。
4.4 指定测试分组与用例标签规范
为了适配端侧流水线,我们对测试用例加了一组规范标签:
@Tag('dart-only'):纯 Dart 逻辑,不需要平台通道。@Tag('platform-channel'):涉及平台通道调用,需真机验证。@Tag('perf-critical'):性能敏感用例,在流水线中晚于普通用例执行。
这套标签体系的价值在下游非常明显:流水线可以精确切分“宿主机用例”与“真机用例”,避免让纯逻辑测试去挤占四台鸿蒙真机的执行队列。性能敏感用例放到后面跑,是因为这类用例经常对 CPU 调度要求高,放到末尾不干扰其他用例。
4.5 端侧编译与打包的特别注意事项
Flutter 鸿蒙工程的构建,和 Android/iOS 有差异,尤其是 HAP 打包阶段:
- 测试产物的
.hap包和正式包应使用不同签名,签名混乱会导致安装失败。 - 鸿蒙端侧的 Flutter 引擎库体积较大,如果 HAP 包超过设备安装上限,需要主动裁剪。
- 测试专用 HAP 建议关闭资源加密,方便拿日志。
我们实际踩过一个坑:测试 HAP 没配置权限声明,导致从流水线安装时能装上去,但运行时无法申请麦克风权限(因为测试用例涉及语音输入场景)。这类权限声明必须在 HAP 的module.json5里显式列出。
5. 适配过程中的常见问题与排查技巧
5.1 疑难杂症速查表
下面这些几乎是每个鸿蒙化适配团队都会遇到的,我把症状和应对一并列出来:
| 现象 | 直接原因 | 处理思路 |
|---|---|---|
| 测试用例执行时莫名超时 | Dart 事件循环空转或宿主调度机制不同 | 换取心跳探针模式,主动唤醒事件队列 |
| Mock 工厂返回 null | 运行时反射被禁用 | 改用 build_runner 代码生成模式,确保生成产物提交到仓库 |
| HAP 安装失败 | 签名信息不一致 | 统一使用测试专用签名,并校验包体内的签名块 |
| 设备日志无法实时刷新 | 日志缓冲策略不同 | 在 Reporter 里主动 flush,或检查 hdc 命令的日志级别参数 |
| 部分用例在宿主机跑通过,真机失败 | 平台通道行为不同 | 给这类用例打上platform-channel标签,强制走真机队列 |
dart test无法识别 harmony 平台 | 自定义 TestPlatform 未在入口初始化 | 在测试入口 main() 中显式调用 setup() |
5.2 排查工程化的经验
再多说一点关于排查的思路。遇到集成测试问题时,不要上来就怀疑dart_test_utils的某个 API 写得不对。先用最小复现路径验证:
- 单独在鸿蒙宿主机上跑一个最简单的
test('hello', ...)。 - 再挂上
HarmonyTestPlatform.setup(),看是否有影响。 - 逐步掺入
dart_test_utils的增强能力和 Mock 工厂。 - 最后才接平台通道。
这套排查法能帮助你快速定位到底是平台适配问题,还是用例自身的逻辑问题。我们实际跑下来,大概 80% 的问题都是用户代码里不小心在setUpAll阶段访问了平台通道导致的,跟dart_test_utils本身并不相关。
5.3 一个典型失败案例的回放
有一次流水线跑测试,发现某个用例在鸿蒙设备上稳定失败。有一个用例是读取本地 JSON 配置,然后校验 5 个字段的值。单独执行时百发百中,放流水线里就癫狂。
最后定位出的原因是测试用例之间共享了同一个全局缓存,前一条用例会提前加载一份旧配置,后一条用例读缓存时拿到脏数据。这个现象在 Android 上也会偶发,但鸿蒙宿主环境进程回收更激进,导致表现更明显。
解决办法是给dart_test_utils的TestContext增加一层isolate级别的命名空间隔离,每个用例上下文带一个边界标记。这套机制后来反向补到了所有平台,也算意外收获。
6. 适配之外的能力扩展与后续规划
适配基本收口后,我又回头把dart_test_utils的能力往下推了两步,让鸿蒙端侧测试的使用体验更顺手。
第一步,支持了跨设备用例标签调度。刚才提到platform-channel标签,其实远不止分类,而是把用例按设备能力自动分派到不同的鸿蒙模拟器或真机。比如,需要录入语音的用例统一跑到带麦克风的真机槽位,纯逻辑测试则留在模拟器上完成。测试排队和资源争抢的问题就这样解决了,关键是这套调度逻辑全部由标签驱动,不需要每个模块单独开发调度接口。
第二步,增加了失败用例的证据链收集。现在跑鸿蒙端侧测试,只要用例失败,自动收集三个信息:启动日志、测试执行日志、device 状态快照。三条数据汇聚成一份 HTML 报告,排查问题时不再需要各种终端窗口之间来回切换。
后续考虑把dart_test_utils的鸿蒙适配抽成独立的共享插件,让团队里其他 Flutter 工程也能直接引入。目前项目里还硬编码了一些内部装置的路径,下一步需要全部替换成环境变量,方便快速落地到不同业务工程。
适配工作虽然大多集中在一个工具库里,但本质上是我们对“测试如何在鸿蒙生态落地”的一次系统性校验。我还是那句话:能跑不是目的,可复用、可回滚、可追溯才是工程化测试的底线。希望这篇经验对正在做 Flutter 鸿蒙化适配的团队有实际价值。