ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

鸿蒙上Flutter单元测试的test_api适配实践与踩坑记录

鸿蒙上Flutter单元测试的test_api适配实践与踩坑记录 鸿蒙上跑 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_testWidget 测试入口 ↓ 依赖 package:test / test_corerunner、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; Futurevoid harmonyTestMain(ListString args) async { await runTest(args); }看起来稀松平常但实际坑点在于test_core默认会创建Runner时加载平台配置而鸿蒙端无法使用默认的VMService监听端口。解决办法是提供一个自定义的Runner子类重写配置加载逻辑。我这里的简化假设是使用test_api/harness.dart提供的入口但在真实鸿蒙场景中通常还要把平台检测结果注入进去告诉 runner 当前环境是harmony。class HarmonyRunner extends Runner { HarmonyRunner(super.config); override Futurevoid 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(MapString, 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, FutureObject? 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; Futurevoid 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 --platformflutter --dart-defineHARMONY_TESTtrue这个命令的标准输出虽然依旧是终端报告但内部如果已经替换了 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 的底层设计意图。希望这份实战记录能帮后来者少走点弯路。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进