鸿蒙上跑 Flutter 单元测试,第一道坎往往不是业务代码本身,而是test_api这个看不见摸不着的底座。我一开始接手鸿蒙端 Flutter 工程化时,直接在 Windows 上把flutter test跑通,切到鸿蒙设备后就傻眼了:依赖原生的插件找不到、测试报告出不来、invocation 卡死一大片。折腾了一圈才明白,问题不在于调用例逻辑,而在于test_api在鸿蒙运行环境下的适配层根本没建立起来。
这篇文章就把我在鸿蒙 Flutter 测试工程化实战中的踩坑记录和完整方案整理出来,供同样在做跨端测试底座的同学参考。
1. 背景与动机:为什么鸿蒙端 Flutter 测试要从 test_api 下手
1.1 鸿蒙 Flutter 的现状痛点
鸿蒙生态这两年起来的势头很猛,应用厂商做 Flutter 跨端迁移时,第一个要保证的就是测试能力不降级。但在鸿蒙设备上跑 Flutter 单元测试,远比想象中复杂。普通 Flutter 项目在 Android/iOS 上能直接跑的flutter test,到了鸿蒙端就面临三个现实问题:
第一,Flutter 引擎在鸿蒙上的官方支持走的是 OpenHarmony 自研引擎路线,很多针对标准 Flutter/Android 的测试基建无法直接复用。第二,三方插件在鸿蒙端的实现往往依赖鸿蒙原生侧的 API,如果测试时这些原生通道没有 mock,用例就会直接挂在MissingPluginException。第三,也是最容易被忽略的一点:test_api这个库作为所有 Dart 测试的上游依赖,其内部对 VM 服务协议、zone、异步调度等机制有强依赖,在标准 Dart VM 和 Flutter 引擎上表现很正常,但换到鸿蒙环境的定制引擎上,很多行为都变了。
换句话说,鸿蒙端 Flutter 测试不是“跑用例”的问题,而是“测试底座是否适配”的问题。test_api就是这个底座的地基,地基不稳,上层再怎么搭都摇摇晃晃。
1.2 test_api 在整个测试体系中的层级与作用
很多刚接触 Flutter 测试的同学会以为flutter_test是最底层的库,其实不是。Flutter 的测试体系是分层的:最底层是test_api,提供测试原语、期望匹配器、invocation 生命周期管理;中间层是test_core和package:test,负责测试编排、runner、reporter;再往上才是flutter_test,它把 WidgetTester、golden 比对这些 Flutter 特有的能力封装到test_api的框架中。
flutter_test(Widget 测试入口) ↓ 依赖 package:test / test_core(runner、reporter、编排) ↓ 依赖 test_api(测试原语 + 虚拟机通道)这个层级关系决定了适配策略:如果只动了最上层的业务测试代码,而test_api的适配不做,那么所有依赖它的库跑起来都会“形似神不似”。反过来,如果能把test_api在鸿蒙上的执行逻辑理顺,上层就能最大程度复用标准 Flutter 生态的测试能力,包括现有的 golden 测试、组件测试、mock 体系。
我做适配时的第一件事,就是先抛开flutter_test层的封装,直接读test_api的源码,把它的职责边界画清楚。只有理解了哪些逻辑是纯 Dart、哪些逻辑会触达引擎原生通道,才能在鸿蒙上找到正确的替换点。
2. test_api 体系拆解与适配切入点
2.1 test_api 暴露的核心原语
test_api包的核心抽象可以从三个维度去看。
第一个维度是scaffolding(脚手架),也就是test()、group()、setUp()、tearDown()这些看似最基础的函数。大多数开发者只把它们当作语法糖,但它们是整个测试框架的 DSL 入口。test()并不仅仅是执行一个回调函数,它会创建一个Test对象,注册成一个待运行的实体,并交给TestHandle去管理。
第二个维度是invocation(调用生命周期)。test_api中定义了一套与测试执行强相关的状态机:等待中、运行中、完成、失败、跳过。每个测试用例在跑的时候,runner 会创建一个Invocation,并通过回调告诉外部当前用例处于什么状态。标准 VM 上,invocation 的生命周期由事件循环严格驱动;鸿蒙定制引擎上,如果事件循环的调度时机有差异,就可能导致用例已经跑完但状态没有同步回 runner,最终表现为“测试挂起”或“报告缺失”。
第三个维度是expectation(期望匹配器)。这一点相对好理解,就是expect(actual, matcher)以及各类内置 matcher。纯 Dart 逻辑,跨端基本不需要改。真正的隐患在于:matcher库内部用了大量Zone相关的操作,如果鸿蒙引擎对Zone的语义实现不完整,某些匹配器的行为就可能偏离预期。
2.2 适配边界分析:哪些依赖 VM、哪些是纯 Dart
我在做鸿蒙化拆分时,画了一个分类表,把test_api源码中的各类模块按“是否需要引擎原生能力”做了切分。
| 模块 | 依赖内容 | 纯 Dart 可覆盖 | 需要适配的边界 |
|---|---|---|---|
| test/group 注册 | 只是数据结构操作 | 是 | 无 |
| Invocation 生命周期 | 依赖事件循环调度 | 基本是 | 引擎事件循环差异 |
| expect/matcher | 纯 Dart 逻辑 | 是 | Zone 行为差异 |
| TestHandle 服务端 | 通过 VM service 暴露 | 否 | 需要替换为鸿蒙侧 reporter 通道 |
| reporter 输出 | 对接格式化与 JSON 输出 | 是 | 输出目标需要适配 |
这个表中的第四行非常关键。标准flutter test在本地启动一个 VM service,测试框架通过 service protocol 获取信息。鸿蒙端没有对应的 VM service 基础设施,所以 TestHandle 这一层就不能沿用默认实现,必须自己写一个“鸿蒙通道实现”,把测试事件实时转发到鸿蒙原生侧或直接输出到日志系统。
2.3 适配的三个关键决策
有了分类表,就进入决策阶段。我的最终方案可以总结为三个关键选择,后续的代码和步骤都围绕这三个决策展开。
第一个决策:保留test_api的公开 API 语义,替换平台层实现。不能为了适配鸿蒙就去改test()这类原语的签名,否则上层所有测试代码都要重写。正确做法是延续test_api提供的onPlatform机制,给鸿蒙注册一套平台实现。
第二个决策:使用test_core的 runner 作为执行骨架,替换 reporter 和后端。package:test_core的定义比package:test更底层,它暴露了Runner和各阶段 hook,适合在鸿蒙上做二次定制。
第三个决策:把“鸿蒙通道”封装成可选依赖,不污染原始 Flutter 工程。适配层作为独立的 package 提供,业务工程通过dependency_overrides引入,保证长期维护时不会侵入主项目代码。
重点提示:不要在现有 Flutter 测试工程里直接大改
test_api。我一开始图省事,直接在本地依赖里改了源码,后续升级 Flutter SDK 版本时全部白费,回滚成本巨大。适配层必须与业务解耦。
3. 鸿蒙化适配的分层架构与实现方案
3.1 整体分层设计
我在工程里把适配拆成了四层,结构如下:
- 第一层:原语层,directly 引用
test_api,保证test()、group()、expect()在有鸿蒙环境标记的包中可用。 - 第二层:调度层,使用
test_core的 runner 能力,保留原有的 setup/teardown 编排逻辑,但将 invocation 事件的监听转发到自定义 handler。 - 第三层:通道层,实现鸿蒙测试通道。该层是本次适配的核心定制点,负责把测试生命周期和期望失败信息上报到宿主原生工程。
- 第四层:业务入口,即鸿蒙原生侧的测试入口,通过鸿蒙 Ability 或页面承载 Flutter 测试运行器。
举个例子,标准 Flutter 测试的flutter test会启动一个TestProcess,通过 observatory 协议与 VM 通信,最后生成 JSON 报告。鸿蒙端不具备同等机制,因此在通道层,我提供一个HarmonyTestReporter类,实现了test_api中的Reporter接口,在其回调中把onTestStarted、onTestCompleted、onError等事件序列化为 JSON,再通过鸿蒙原生的日志接口或 IPC 通道传出。
3.2 用 test_core 做 runner 替换
代码层面,最简单的复用方式是采用test_core暴露的Runner配置入口,避免直接从 runner 内部改逻辑。我在适配包里写了一个入口文件,大致结构如下:
import 'package:test_api/scaffolding.dart'; import 'package:test_core/executable.dart'; import 'package:test_api/harness.dart'; Future<void> harmonyTestMain(List<String> args) async { await runTest(args); }看起来稀松平常,但实际坑点在于:test_core默认会创建Runner时加载平台配置,而鸿蒙端无法使用默认的VMService监听端口。解决办法是提供一个自定义的Runner子类,重写配置加载逻辑。我这里的简化假设是使用test_api/harness.dart提供的入口,但在真实鸿蒙场景中,通常还要把平台检测结果注入进去,告诉 runner 当前环境是harmony。
class HarmonyRunner extends Runner { HarmonyRunner(super.config); @override Future<void> run() async { // 这里可以自行管理 invocation 事件与报告输出 return super.run(); } }这段代码不复杂,但它是整个适配的第一道关卡。许多团队卡在这里的原因不是代码不会写,而是不知道
test_core中Runner是“可继承的”。默认实现中对平台环境的假设比较强,不替换就会去连不存在的服务。
3.3 自定义 Reporter 与生命周期钩子
因为无法依赖 VM service,所以鸿蒙端的测试状态上报必须自己接管。我的实现是写一个HarmonyReporter,把测试事件同步给原生层。test_api中 Reporter 接口本身就是可扩展的,标准库里有JsonReporter、ExpandedReporter,我们完全可以仿照其结构,把输出目标换成鸿蒙通道。
class HarmonyReporter implements Reporter { final void Function(Map<String, Object?> event) onEvent; HarmonyReporter(this.onEvent); @override void onTestStarted(TestInfo test) { onEvent({ 'type': 'test_started', 'test': test.name, }); } @override void onTestCompleted(TestInfo test) { onEvent({ 'type': 'test_completed', 'test': test.name, }); } @override void onError(TestInfo test, Object error, StackTrace stack) { onEvent({ 'type': 'test_error', 'test': test.name, 'error': error.toString(), 'stack': stack.toString(), }); } }有几个细节值得注意。第一,事件结构尽量用 JSON 序列化,这样鸿蒙原生侧拿到后可以直接解析,不用在platform channel里做逐字段映射。第二,事件名称用字符串枚举,后续如果要做测试报告平台对接,可以直接作为数据源。第三,onError里必须把 stack 一起带上,否则排查问题时只有一句错误消息,定位成本很高。
3.4 平台通道 mock 适配
鸿蒙端测试经常卡在平台通道上。一条MethodChannel('com.example/bridge')在 Android 上有原生实现,鸿蒙侧如果没有对应实现,用例一旦调用就会抛MissingPluginException。这个问题不是 test_api 本身的问题,但会直接干扰测试底座的效果。
我在适配层里加了一个HarmonyChannelMock工具,专门用于在测试启动时注册虚假的 MethodChannel handler。注意这不是简单的空实现,需要根据每个 channel 定义返回合理的 mock 数据。
class HarmonyChannelMock { static void register(String channel, Future<Object?> Function(MethodCall) handler) { TestWidgetsFlutterBinding.ensureInitialized(); TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler( MethodChannel(channel), handler, ); } }这里的setMockMethodCallHandler在鸿蒙 Flutter 适配引擎上同样可用,因为它是引擎层通过 binary messenger 实现的能力,不属于某个原生平台特有的 API。用这种方式 mock 通道,既不需要改业务代码,也不需要在鸿蒙原生侧写一套假的 bridge 实现,对测试工程侵入最小。
4. 工程化落地的关键步骤
4.1 pubspec 配置与依赖覆盖
懂了架构,接下来就是工程化落地。我的做法是在项目根目录创建一个harmony_test/目录,专门放鸿蒙测试适配层,同时在 pubspec 中通过dependency_overrides挂载本地测试包。
name: my_app environment: sdk: '>=3.0.0 <4.0.0' dependencies: flutter: sdk: flutter dev_dependencies: flutter_test: sdk: flutter test_api: ^0.7.0 test_core: ^0.6.0 dependency_overrides: # 适配层的本地路径,保持与上游脱离 test_api: path: ./harmony_test/test_api这段配置有几个值得解释的地方。首先,依赖版本要与本机 Flutter SDK 中自带的test_api版本匹配,否则可能出现运行时版本冲突。其次,dependency_overrides使用本地路径可以让我们在适配层打补丁,而不直接改动 SDK 缓存目录,后续升级时只需要重新 diff 一次。
4.2 自定义测试 Bootstrap
为了让鸿蒙原生工程能拉起 Flutter 测试,需要为适配层写一个自定义的 bootstrap 入口。该入口是一个普通 Dart 文件,但会在初始化时注入鸿蒙环境标记。
import 'dart:isolate'; import 'package:flutter_test/flutter_test.dart'; import 'package:test_api/harness.dart'; Future<void> main() async { TestWidgetsFlutterBinding.ensureInitialized(); await harmonyTestMain([]); }flutter_test包中的TestWidgetsFlutterBinding在鸿蒙引擎上也是起作用的,它可以在缺少真实原生窗口的情况下,模拟一个FakeWindow和一个迷你事件循环。这个初始化顺序必须放在测试用例注册之前,否则runTest()调度时会因为 binding 未初始化失败。
4.3 生成可执行测试产物
由于鸿蒙原生工程运行的是 HAP 包,Flutter 测试没法像 PC 端那样直接执行dart run,需要先把测试代码编译成可由鸿蒙原生壳加载的模块。我这里走的是按需编译的方式,先生成一个专门用于鸿蒙测试的动态库或快照文件。
实测过程中,最省心的路径是:保持测试入口文件独立,并在测试入口中使用@TestOn('harmony')或自定义环境变量区分平台。
@TestOn('browser') import 'package:test/test.dart'; // 更务实的方式是在运行时检测 const bool isHarmony = bool.fromEnvironment('HARMONY_TEST');用编译期常量比运行时检测更可靠,因为鸿蒙的运行时环境可以掩盖很多平台标识,而编译期常量在产物生成前就已确定,调试时也能直观区分。
使用构建命令时,我会在脚本里加上编译期变量注入,比如:
flutter test --platform=flutter --dart-define=HARMONY_TEST=true这个命令的标准输出虽然依旧是终端报告,但内部如果已经替换了 Reporter,输出信息就会被同时转发到鸿蒙原生侧,从而为 HAP 内的自动化测试打下基础。
4.4 集成到鸿蒙原生工程
适配层准备就绪后,把 HAP 工程中的测试页面对接到 Flutter 测试入口。具体做法是在鸿蒙原生侧写一个TestRunnerAbility,在onCreate时加载 Flutter 容器,并调用上面生成的测试 bootstrap。
鸿蒙侧的代码不必复杂,核心是创建一个承载 Flutter 引擎的页面,把 Flutter 测试主入口作为入口模块加载。这里要注意的是,引擎初始化参数需要把测试目录的产物路径传递进去,并指定main.dart的路径指向我们的bootstrap.dart。
经验:不要试图把所有测试塞到一个 HAP 里。我第一版方案图省事,把 300 多个用例都放在了同一个产物包中,结果启动时间长达几十秒,而且一旦某个用例挂起,整个测试包直接卡死。拆分测试包是鸿蒙场景下的硬性需求,毕竟设备上的资源限制和桌面端完全不同。
4.5 与 CI 流程打通
前面的内容解决了“在鸿蒙设备上能跑”,CI 要解决的是“每次提交自动跑”。我在流水线里把测试拆成两个阶段:先在标准 Flutter 环境跑一遍全量单元测试,再把通过率较高的核心用例切到鸿蒙环境跑冒烟。
这样做的原因是鸿蒙设备资源有限,全量用例都放上去不现实。“标准环境全量 + 鸿蒙环境冒烟”的组合可以在稳定性和成本之间取得平衡。流水线日志里,我会用统一的 JSON 上报格式,这样测试报告平台在解析时不需要区分设备类型,只需要看字段中的platform: "harmony"。
5. 常见问题与排查技巧实录
以下问题全部来自我的实际调试记录,整理成表格方便快速查阅。
| 问题现象 | 可能根因 | 解决方式 |
|---|---|---|
| 测试执行后卡在 5 分钟无输出 | Runner 中的 Invocation 生命周期钩子未触发 | 检查是否替换了 Runner 默认的后端;确认事件循环未被阻塞 |
MissingPluginException频繁出现 | 未对 MethodChannel 做 mock | 用上文提到的HarmonyChannelMock注册假 handler |
| 测试报告生成失败,日志显示协议端口占用 | test_core默认尝试连接 VM service | 替换Runner,禁用服务监听 |
在expect中比较浮点数时结果不稳定 | 鸿蒙引擎浮点精度或 Zone 调度差异 | 使用moreOrLessEquals或closeTomatcher |
| 单测与 Widget 测试无法同时跑 | 多个测试文件共享同一绑定 | 按目录拆分测试包,分别执行 |
这里挑几个最有代表性的详细展开。
5.1 用了自定义 Runner 之后测试仍然挂起
这个问题我排查了很久,最后发现根因不是 Runner,而是test_api的StreamChannel在鸿蒙端没有收到 EOF 信号。标准 VM 中,测试跑完会关闭事件通道,runner 收到关闭信号后自然退出;鸿蒙端的定制 engine 对 Stream 关闭的时机处理不一致,导致 runner 一直在等待数据。
解决方式是在HarmonyReporter里增加一个onDone的显式回调,在最后一个用例完成后主动触发 runner 的结束流程,不要依赖底层流的 EOF。这个补丁看起来不大,但对于持续集成场景来说,一个挂起的任务可能会阻塞整个 pipeline,所以完善退出机制非常必要。
5.2 异步测试在鸿蒙设备上频繁超时
这个问题更隐蔽。用testWidgets包裹的异步用例在 PC 上跑得很快,到了鸿蒙设备上偶发超时。打印日志后发现pump()和pumpAndSettle()在鸿蒙上的帧回调时机不同,有时不是超时,而是虚拟时钟没有推进。
我采取的方案是降低对帧回调的依赖,在测试中尽量使用tester.binding.scheduleFrame()手动调度帧,必要时用真实的延迟来妥协。第一版我把所有用例都改成虚拟时钟,反而因为引擎的 tick 机制不同而产生更多偏差。后来经验是:组件测试尽量虚拟化,涉及真实渲染的用例尽量集成化。
5.3 版本冲突背锅侠
test_api是官方基础库,很多其他依赖包都会间接依赖它。鸿蒙自定义通道包如果使用了本地的test_api目录,那么其他依赖包如果显式声明了不同版本,就可能出现“通过路径 A 拿到 A 版本、通过路径 B 拿到 B 版本”的冲突。
好在dependency_overrides可以将所有地的test_api引用统一收拢到一个路径。如果某个第三方库仍然报版本冲突,我需要把这个库dependency_overrides到一个 keep 版本,或者在pubspec.lock中做一次版本锁定。这个问题的处理原则是:让所有依赖尽量引用同一个版本,不要搞多个本地副本。
6. 后续扩展与测试底座完善建议
test_api鸿蒙化适配做完之后,只是把最基础的地基打稳了。按我当前项目的经验,后续还可以从三个方向继续扩展。
第一是智能用例筛选机制。鸿蒙设备专项测试不应该每次都跑全量,而是通过“变更影响分析”自动筛选出受本次提交影响的用例。这个能力可以基于代码覆盖率或依赖图实现,我目前在 CI 中已经尝试着依赖flutter test --coverage的产物,再加一层用例路径与源码文件的映射,效果还不错。
第二是自定义测试报告协议。上文提到的 JSON 上报格式是当前最简方案,如果团队有原生测试平台,可以把这些 JSON 事件进一步封装成标准格式,比如上报到自建平台或转换为可读 HTML 报告。关键是通道层的事件类型要设计得足够细,比如增加execution_time字段、内存峰值字段等,这些对于在鸿蒙设备上评估性能非常重要,甚至能提前发现异常的内存占用问题。
第三是 golden 测试的鸿蒙化。flutter_test 的 golden 测试在鸿蒙上比较尴尬,因为渲染结果的截图在不同系统上差异较大。我的做法是额外使用自定义matchesGoldenFile的容差策略,在鸿蒙上对特定区域设置模糊匹配,专门跑一套harmony_goldens目录,避免与标准 gesture golden 冲突。
提示:不要奢望一套测试底座同时完美适配 Android、iOS、鸿蒙三端。不同系统对渲染、调度、异步语义的封装本质上不同,自适配的目标是“同一套测试语义,各端保留合理的容差”。
我做鸿蒙适配最大的体悟是:技术难点不只是写几个适配类,而是要在保持官方test_api语义一致性的前提下,摸清鸿蒙引擎和标准 Dart VM 之间的行为差异,找对替换点。种策略回头再看,其实并没有增加多少代码行数,但每一步都需要理解 test_api 的底层设计意图。希望这份实战记录能帮后来者少走点弯路。