☰
Flutter测试报告鸿蒙化:端侧生成JUnit XML与Jenkins集成实践
2026/10/22 5:19:05 网站建设 项目流程

做移动端测试自动化的同行应该都有这个体会:测试跑完了只算完成一半,报告能不能被 CI 系统正确解析、沉淀下来,才是真正决定这套投入有没有价值的关键。我在把一套 Flutter 测试框架往鸿蒙生态迁移时,遇到了一个绕不开的环节——怎么在鸿蒙设备端直接产出标准的 JUnit XML 测试报告,让 Jenkins 这类 CI 工具能自动收集、统计、出图表。查了一圈发现,Flutter 社区里最常用的 junitreport_maintained 库还停留在纯 Android/iOS 的用法,本地脚本和端侧设备是割裂的。这篇文章就是我从零做鸿蒙化适配的完整记录,包括库的改造思路、端侧生成报告的实现细节,以及 Jenkins 集成那一段的实际配置,希望对你后续接鸿蒙设备测试有用。

1. 先搞清楚:这个库到底在做什么

1.1 junitreport_maintained 的核心原理

junitreport_maintained 是 Flutter 生态里一个专门把测试结果转换成 JUnit XML 格式的库。它的前身是 junitreport,原作者停更之后,社区拉了一个维护分支,主要是修掉了一些 Dart 版本兼容性问题和 XML 转义 bug。这个库的工作方式非常特别,它不是直接监听测试框架内部事件,而是吃flutter test --machine输出的 JSON 行。

--machine模式是 Flutter 测试框架提供给开发者做二次集成的接口。在这个模式下,测试框架不会打印正常人看的彩色日志,而是把每个测试的生命周期事件——开始、通过、失败、跳过、错误——序列化成一行 JSON 输出到 stdout。junitreport_maintained 做的事情就是把这一行行 JSON 重新组织成一个层级结构,内部叫 Report 对象,然后把它渲染成符合 JUnit XML schema 的文本。

整个链路可以理解为:Flutter 测试框架是数据源,JSON 流是中间传输格式,JUnit XML 是最终交付物。它不关心测试具体跑在什么平台上,只关心测试结果的结构化表达。这个设计给它做鸿蒙化带来了一个天然优势——纯 Dart 实现的逻辑可以直接复用,不需要重写核心算法。

1.2 鸿蒙化适配到底改了什么

所谓鸿蒙化适配,不是把这套库在鸿蒙上重新写一遍,而是解决三个层面的问题。

第一层是依赖兼容。鸿蒙上跑 Flutter 用的是一套自研的 Flutter 引擎分支,底层实现跟 OpenHarmony 生态深度耦合。如果库的 pubspec.yaml 里声明了某些依赖冲突,或者用到了鸿蒙运行时没有实现的 dart:io 能力,就会在编译期或运行期炸掉。junitreport_maintained 本身依赖很少,这层问题相对轻微,但它依赖的 path_provider、file 等常用包有没有鸿蒙适配版本,直接决定你在端侧能不能顺利拿到可写目录。

第二层是运行环境差异。在 Android/iOS 上,我们一般是在宿主机跑flutter test --machine,然后本地脚本解析输出。但鸿蒙的测试场景很多时候是设备端离线的:你要在鸿蒙设备上跑完一整套自动化用例,测试结果得当场生成 XML 文件,等设备回连之后 CI 再把文件拉走。这就意味着原本"跑在 PC 上的脚本逻辑"要变成"跑在设备上的函数调用"。

第三层是平台通道。鸿蒙的 Flutter 引擎支持 MethodChannel / BasicMessageChannel,但它原生侧是用 ArkTS 或者 C++ 实现的,跟 Java/Kotlin 的 API 完全不同。如果库要读写设备文件、访问应用沙箱目录,就绕不开鸿蒙的上下文对象(Context)和沙箱文件路径规则。

所以我做适配的时候,给自己定的原则是:能用纯 Dart 解决的就用纯 Dart,必须碰原生的部分,就用鸿蒙官方推荐的通道方式对接,不做任何过度设计。

2. 适配前的准备工作

2.1 环境清单与版本匹配

动手改代码之前,先把环境对齐。鸿蒙 Flutter 开发目前依赖 DevEco Studio 配套的 Flutter SDK,这个 SDK 跟官方 Flutter SDK 在 API 上大体兼容,但仍有一些细节差异。我这次用的是某版本的 HarmonyOS SDK 加配套 Flutter SDK 3.x 分支,Dart 版本也对应到了 3.x。

具体环境清单如下:

组件版本/说明
DevEco Studio5.x 及以上,安装 HarmonyOS SDK
Flutter SDKHarmonyOS 定制版,支持 ArkTS 插件工程
Dart SDK随 Flutter SDK 分发,3.x
junitreport_maintained最新维护版,作为本地依赖引入
path_provider鸿蒙适配版(或改用系统临时目录)

这里有一个非常容易踩的坑:很多人在 pubspec.yaml 里直接用junitreport_maintained: ^x.y.z从 pub 仓库拉包,但在鸿蒙的开发环境里,pub 仓库的解析可能正常,也可能因为 SDK 版本断言失败。为了稳妥,我建议直接把源码 clone 到工程的 third_party 目录,用 path 依赖本地引用。这样既绕开了版本解析的网络问题,也方便改源码。

2.2 库的源码结构拆解

我拉下来之后,先花了一个晚上把库的源码读了一遍,说实话代码量不大,核心文件就几个,但结构组织得很清晰,这种小库做适配简直就是练习鸿蒙化的好样本。

主要文件是lib/src/models.dart,里面定义了 Report、Suite、Case 三个核心模型;lib/src/report.dart定义了报告容器的聚合逻辑;lib/src/xml.dart负责把模型转换成 XML 字符串;入口文件lib/junitreport_maintained.dart暴露了parseJsonReport和toXml接口。

parseJsonReport(List<String> lines)是整个过程的总入口,它接收从flutter test --machine捕获的原始 JSON 行,内部维护一个状态机:看到testStart就开启一个 case,看到testDone就收束这个 case 的结果,看到done就结束整个 suite 解析。这个状态机是库的核心资产,鸿蒙化完全不用动它。

toXml()是最终输出方法,它遍历 Report 里的嵌套结构,拼接 XML 字符串。这里有个细节我要单独提一下:库对 failure 和 error 是区分输出的,failure 对应断言失败,error 对应测试过程中的异常崩溃。Jenkins 解析这两种结果会给出不同的统计口径,这个区分在适配种必须保留。

理解了这个结构,适配路线就清晰了:Dart 核心逻辑直接复用,我只需要在里面补一个"鸿蒙端侧运行环境适配层",负责拿到可写目录、落地 XML 文件、管理历史报告文件。

3. 端侧生成 JUnit XML 的实战改造

3.1 Dart 层的采集与序列化改造

鸿蒙设备端跑测试,跟 PC 上跑的最大区别是:你不能依赖命令行管道把 JSON 流导到文件里。所以第一步是把"JSON 行收集"这个动作搬到应用内部。

我的做法是在测试入口文件(一般是test/main_test.dart或者鸿蒙化的 runner 入口)里,用 Flutter 的dart:io直接捕获测试输出。但这里有个技术限制:正常跑 Flutter test 时,--machine模式的 JSON 是打到进程 stdout 的,在设备端,Flutter 引擎会把这个 stdout 跟应用日志混在一起。更干净的方式是让测试框架自己把事件回调出来。

好在 Flutter 的测试绑定(test package)提供了onReport之类的回调机制,不过它给的不是完整机器事件,而是组装好的结果对象。如果你想把结果喂给 junitreport_maintained,需要先把这些结果对象再包装成机器可读的 JSON 行。

我最后选择的实现方式是:自己写一个轻量的事件采集器,监听测试运行时的每个用例回调,手动构造跟设备无关的 JSON 行,喂给parseJsonReport。这样避免了 stdout 捕获的不可靠问题,也不影响库内部的状态机逻辑。

// test_result_harness.dart import 'package:test_api/test_api.dart' show TestCallback; import 'package:junitreport_maintained/junitreport_maintained.dart'; class JunitHarness { final List<String> _jsonLines = []; void onEvent(Map<String, dynamic> event) { // 这里按机器模式可视化的字段结构重建一行 JSON _jsonLines.add(const JsonEncoder().convert(event)); } String buildXml() { final report = parseJsonReport(_jsonLines); return report.toXml(); } }

用测试框架自身的回调而不是捕获 stdout,还有一个额外的好处:在鸿蒙设备上跑测试时,应用进程和测试进程的内存是共享的,你可以直接在测试结束时拿到 Report 对象,不需要再做一次反序列化。不过这个优化后面会讲到,得小心内存引用泄漏问题。

3.2 沙箱路径问题与方案选择

拿到 XML 字符串之后,最容易被忽视、也最容易翻车的就是文件写入路径。Android 上你还能拿到绝对路径凭直觉写/sdcard/xxx,鸿蒙不行。

鸿蒙的应用沙箱机制跟 iOS 类似但又有区别。HarmonyOS NEXT 上,每个应用默认只能访问自己的沙箱目录,获取路径必须依赖 UIAbilityContext。如果直接用Directory('/data/app/...')这种硬编码路径,编译可能不报错,但运行期绝对会抛一个FileSystemException。

我测试过的三条路,实际对比如下:

方案稳定性适用场景
path_provider 鸿蒙适配版高有社区维护,推荐优先尝试
HarmonyOS Context API 获取 filesDir极高原生插件封装,最稳妥
Directory.systemTemp 直写不稳定部分系统版本可写,不推荐生产用

我的建议是不要让纯 Dart 层直接猜路径,而是封装一层目录提供者。如果项目已经把 path_provider 做过鸿蒙适配,直接拿getApplicationCacheDirectory()就行。如果没有现成适配,用鸿蒙原生侧拿filesDir再通过 MethodChannel 返回给 Dart 层,一份成本一劳永逸。

下面是 MethodChannel 原生侧的最小实现思路,ArkTS 部分大概长这样:

// TestReportPlugin.ets import { abilityAccessCtrl, common } from '@kit.AbilityKit'; @Entry @Component struct TestReportPlugin { private context: common.UIAbilityContext | null = null; onPageShow(): void { const context = getContext(this) as common.UIAbilityContext; // 获取沙箱 files 目录 const filesDir = context.filesDir; // 通过通道回传给 Dart 层 this.reportChannel.invokeMethod('onFilesDir', filesDir); } }

Dart 侧对应维护一个 Future,拿到目录后拼接junit_report子目录,再拼接文件名。

3.3 最小可用代码验证

适配最忌讳一上来就全量改造。我建议先用最小的闭环验证逻辑:创建一个单元测试,故意让它失败,看 XML 能不能成功落到沙箱目录里,然后手动把它拉出来用 IntelliJ 打开,看测试报告是否正常。

这一步我写了一个两百行的验证 demo,核心就三段:跑测试、拿目录、写文件。

// write_report_example.dart import 'dart:convert'; import 'dart:io'; import 'package:junitreport_maintained/junitreport_maintained.dart'; Future<String> writeReportFile(String dirPath, String xmlContent) async { final dir = Directory('$dirPath/junit_report'); if (!dir.existsSync()) { dir.createSync(recursive: true); } final file = File('${dir.path}/TEST-report.xml'); await file.writeAsString(xmlContent, encoding: utf8, flush: true); return file.path; }

这个 demo 跑通之后,我的信心就来了。因为核心链路已经通了,后续接 Jenkins 只是配置层面的问题。这里有个经验值得分享:验证端侧写入是否真正成功,别只看返回的路径,一定要调用File.existsSync()再确认一次,同时把文件大小打印出来。如果文件大小是 0 字节,大概率是沙箱权限没给够或者写入时机不对,早发现早处理。

4. 接入 Jenkins/CI

4.1 测试产物的自动收集

端侧生成报告之后,CI 能干活的弹药就齐了。Jenkins 的 JUnit 插件本身不关心报告是从哪台机器来的,它只认 XML 文件路径,所以关键环节是"把设备上的报告拉到 CI 能访问的目录"。

鸿蒙设备回连一般是走 hdc 工具,跟 Android 的 adb 类似。我在 Jenkins 流水线里加了一个阶段,专门负责文件回拉。大致逻辑是:先看设备是否在线,然后递归拉取测试产物目录下的所有 XML 文件,放到 workspace 下的test-results目录。

# fetch_harmony_results.sh HOST_OUTPUT_DIR="${WORKSPACE}/test-results" mkdir -p "${HOST_OUTPUT_DIR}" # 通过 hdc 拉取鸿蒙设备沙箱缓存目录 hdc file recv "/data/app/el2/100/base/xxx/cache/junit_report" "${HOST_OUTPUT_DIR}/device"

这里要注意的是路径里的 uid 部分(el2/100/base 这一串),不同设备、不同应用签名对应的路径不一样。建议在 CI 流水线里先跑一个 hdc shell 命令定位find / -name "*junit_report*",把真实路径缓存成环境变量,再用于拉取。硬编码路径早晚会摔跟头。

4.2 报告解析与统计

报告全部拉回 workspace 之后,Jenkins 这边只需要在流水线脚本里加上 JUnit 插件的调用:

stage('JUnit Report') { steps { junit testResults: 'test-results/device/**/TEST-*.xml' } }

这行配置看似简单,背后其实有几个细节值得琢磨。第一是 ant glob 模式要匹配到真实的文件名,TEST-*.xml是我这里自定义的命名规范,如果你沿用库默认的文件名,得相应调整。第二是如果拉取回来的 XML 里包含多个测试模块,Jenkins 会自动把它们合并成趋势图,前提是testsuite的 name 属性区分度要够,否则历史趋势会被搅成一锅粥。

我在接完这部分之后看 Jenkins 的构建页,发现趋势图里每个模块都能独立看失败率,这才算是真正把鸿蒙设备的测试结果纳入到了整体质量门禁里。后面我又把报告里的 failure 数量接入到了流水线里做质量阈值判断,一旦失败用例超过某个值就打断发布,这条链路就成了发布前的一个硬关卡。

做 CI 集成的心得是:不要只满足于"能出图",要把报告里的结构化数据当成代码去对待,失败了多少、错误了多少、跳过了多少、耗时多少,都应该成为流水线决策的输入参数。Jenkins 的 JUnit 插件本质上是把这些数据暴露给了下游,值得好好用起来。

5. 踩坑实录与排查清单

5.1 高频问题排障

任何一次适配都不可能一次顺利,我在这个项目上踩的坑绝对够写一篇专题。最典型的几个问题,我按暴露频率排了个序,整理成一张速查表。

问题现象根因排查思路解决方案
XML 文件为空写入时机早于测试结束事件检查 flush 策略,确认报告在 done 事件后再生成在测试兜底回调处统一生成
中文用例名乱码XML 声明缺 encoding="UTF-8"用十六进制查看工具看文件头手动在 XML 首行补全声明
Jenkins 统计为 0报告文件不在匹配路径下查看 Jenkins 控制台的具体解析日志修正 glob 匹配路径
设备端找不到目录测试进程和主进程沙箱上下文不一致分别打印两处路径统一用插件上下文获取目录
时间戳显示 UTC库默认输出 UTC 时间检查 XML 的 timestamp 属性在序列化层做时区转换

5.2 稳定性与性能优化建议

端侧生成报告这件事,除了功能正确,还要考虑性能。测试用例数量多了以后,xml.startTag 拼接字符串的过程会有一定的内存压力,尤其是一个超大 testsuite 包含几千个 testcase 时。

我在做压测时,三千条用例的 XML 文本量大概在 1MB 左右,纯 Dart 字符串拼接耗时不到 200ms,这在端侧是完全可以接受的。但如果你在跑集成测试的同时又启动了很多原生线程,建议还是把 XML 生成放到一个独立的 isolate 里,避免卡住 UI 或者让主 isolate 的 GC 压力变大。

// 使用 isolate 生成 XML final xmlString = await compute((report) { return report.toXml(); }, report);

另外一个稳定性隐患是测试中途崩溃,这种情况事件流是不完整的,状态机可能永远等不到done,导致 XML 生成时抛出空异常。我加了一个兜底逻辑:在测试入口增加一个超时保护,超过指定时间强制终结解析,把已解析的 case 先输出出去,避免整个报告丢失。这样做虽然会损失最后几条用例的数据,但至少保住了大部分结果,对 CI 集成来说,有结果总比没结果好。

6. 后续可以怎么扩展

鸿蒙化适配做完之后,这套方案不止能解决 junitreport_maintained 这一个库的问题。我后来顺手把我们团队的另一个 Flutter 插件也按同样的思路做了移植,发现整个流程可以沉淀一套方法论:先分析纯 Dart 层和原生依赖的边界,再统一封装目录获取接口,最后在 CI 侧做产物收集,三条线并行推进。

对于你的项目,我建议也按这个节奏推进。如果鸿蒙设备测试量很大,可以考虑把端侧生成的 XML 文件通过上传组件直接推到对象存储,而不经过 Jenkins 所在的宿主机中转。这样 CI 的并发能力不会成为瓶颈,多个设备可以同时回传报告。

最后再分享一个小技巧:端侧报告生成之后,最好把原始 JSON 事件流一并落盘。调试的时候,有 JSON 原始流和最终 XML 两个版本对照,排查问题效率会高非常多。junitreport_maintained 的 parse 逻辑虽然有维护,但遇到极端事件组合时仍然需要人工审计,原始数据在手,心里就不慌。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询