Flutter测试报告鸿蒙化适配:JUnit XML生成与Jenkins集成实战
做 Flutter 相关开发的团队基本都绕不开测试报告展示这门功课。我用 junitreport_maintained 在常规 Flutter 工程里做测试报告生成已经很久了它能把 flutter test 跑出来的结果落成标准 JUnit XMLJenkins 直接拿它渲染出历史趋势、失败率这些面板。可一旦要把这套东西搬到鸿蒙设备上跑自动化事情就完全不是一回事了。鸿蒙的测试执行环境、文件系统、工程结构都和 Android/iOS 有差异原库拿过来直接跑基本会挂在“报告写不进去”“路径找不到”这类问题上。本文把我做 junitreport_maintained 鸿蒙化适配的完整过程记录下来包括接口抽象改造、端侧 XML 生成、多设备报告合并以及和 Jenkins 流水线的对接适合正在做鸿蒙端 Flutter 自动化测试的同学参考。1. 先说清楚为什么 JUnit XML 在鸿蒙上不能直接跑1.1 junitreport_maintained 原本的工作机制junitreport_maintained 这个库本身做的事情并不复杂核心就是充当一个“事件流转文件”的转换器。flutter test 在运行时如果带上--machine参数会向标准输出吐出一串 JSON 事件流每个事件代表一次测试的开始、结束、通过、失败、跳过。junitreport_maintained 做的事情就是监听这个事件流在内存里构建测试模型哪个测试套件里有哪些用例、每个用例跑了多久、如果失败了具体在文件的哪一行哪一列挂了然后等测试进程全部跑完之后把这个模型序列化成一个 XML 文档写到指定路径。这个机制里暗含了三个前置假设也是后面鸿蒙化时要逐一击破的点它假定标准输出可以被稳定读取并且事件流是完整的、不会被吞掉。它假定目标文件系统是常规的可写目录不区分设备沙箱和宿主环境。它假定报告文件最终能被 CI 节点拿到所以生成路径必须可被外部访问。在 Android 和 iOS 的传统 Flutter 工程里这三个假设基本都能直接满足因为测试大多在宿主机侧完成或者通过 adb/xcrun 同步产物。但鸿蒙的工程模型不一样后面细说。1.2 鸿蒙环境差在哪三个绕不开的坎我把适配过程中碰到的差异归纳成三类理解这三类后面看代码就不会懵。第一执行形态不同。鸿蒙上跑 Flutter 测试通常不是直接在宿主机执行 flutter test而是要在设备或模拟器上安装测试专用 HAP再通过 hvigor 或 DevEco 的测试面板触发。就算你走命令行命令的参数体系和结果输出位置也有自己的规矩。这就导致原来“一个管道符把 flutter test 的输出喂给报告生成器”的做法在鸿蒙环境下并不总是成立的报告生成器必须能独立启动并且能从指定输入源读取事件流。第二文件系统不同。鸿蒙应用跑在设备上时默认情况下应用只能读写自己的沙箱目录直接写/sdcard/或者相对路径都有权限问题。junitreport_maintained 默认写的路径如果不改在鸿蒙设备上大概率拿到 permission denied或者更阴间的“写下去但拉不出来”。这不是库有 bug是运行时对文件访问的约束本来就不一样。第三插件模型不同。原库在传统 Flutter 里可能依赖了一些基于 Android/iOS 原生实现的能力鸿蒙环境下这些原生实现不存在需要走鸿蒙侧的 API。更麻烦的是很多 Flutter 库的鸿蒙适配实际上是通过兼容层完成的部分 dart:io 接口在兼容层上行为不一致不是报错而是静默返回错误结果排查起来比较难受。理解完这三个差异适配的总体思路就很清晰了不重写解析核心而是把“文件写入”“输入源获取”这些平台相关能力从库里抽离出来让库的核心逻辑在鸿蒙环境下保持原样只替换平台适配层。2. 适配前准备环境核查与依赖盘点2.1 工具链怎么搭适配 junitreport_maintained 之前我先把工具链整理了一遍。需要一个能正常编译鸿蒙 Flutter 工程的开发环境我这边用的是 DevEco Studio 配合官方鸿蒙 SDK同时安装了针对鸿蒙的 Flutter 工具链分支。需要注意当前社区里 Flutter 在鸿蒙平台的适配有多个来源不同分支的 API 覆盖度有差异建议先用官方渠道确认版本兼容关系再动手改代码。验证工具链是否可用的最快方式是跑一个空的鸿蒙 Flutter 工程执行一次最简单的 flutter test哪怕只有一个恒通过的用例。如果这一步能在设备或模拟器上稳定跑完再往下走。我见过不少同学上来就改库结果连基础测试都跑不起来浪费了大量时间。2.2 依赖核查适配前必须过一遍的清单在动手改库之前我列了一张核查清单每项都对着原库源码重新确认过。这张表建议你也过一遍至少能少走一半弯路核查项原库中的依赖鸿蒙环境下的表现处理方式dart:io 文件写入File、Directory沙箱路径受限部分路径写不了抽象出 ReportSink 接口鸿蒙端用专用 API 实现标准输入流stdin命令形态不同直接管道不可行支持从文件和 stdin 双输入源读取进程调用Process.run调用系统命令可能受限制尽量用纯 Dart 实现核心逻辑避免依赖外部命令路径分隔符/ 与 \鸿蒙设备上有自己的路径规范路径统一走参数注入不硬编码时间戳格式本地时区设备时区可能不同影响 XML 时间字段统一用 UTC 生成避免时区偏差XML 转义内置实现基本无差异保持原逻辑但补充特殊字符测试用例这张表的价值在于把“不确定因素”提前锁定成“确定的适配点”。比如时间戳我最初根本没意识到会出问题结果在设备上生成的 XML 里时间比本地时间慢了整整 8 小时Jenkins 的趋势图全部对不上后来统一改成 UTC 才解决。这种坑在排查阶段特别耗时间提前在清单里标出来能让后面的改造过程顺畅很多。3. 核心改造从接口抽象到端侧报告生成3.1 先把平台相关逻辑抽出来适配的第一步是把原库里和平台强相关的逻辑抽成接口。原库的报告写出逻辑集中在几个文件里我做的第一件事是定义一个抽象接口把“把字符串内容写到某个目标”这件事和具体实现解耦。abstract class ReportSink { /// 将报告内容写入目标位置。 /// /// [target] 由调用方传入可以是文件路径、URI 或设备专用标识。 /// 具体怎么解释这个 target由实现类决定。 Futurevoid writeReport(String content, String target); }这个接口简单到有点“简陋”但它把最重要的自由度留给了实现方。在传统 Flutter 环境下实现类可以继续用File(target).writeAsString(content)完成原来的行为在鸿蒙环境下实现类则可以换成鸿蒙侧的文件 API或者把内容写到沙箱目录再由测试框架统一拉取。核心解析逻辑完全不需要动。这里有一个设计取舍值得展开说为什么不用“在接口里加一个writeToDevice方法”这种方案因为加方法意味着所有实现类都要改而且很容易把平台语义泄漏到核心逻辑里。我选择的方式是反过来的核心逻辑只依赖ReportSink抽象具体平台在组装时注入实现。这样一来原库的测试用例和逻辑分支全部保留回归风险最小。3.2 鸿蒙端文件写入实现鸿蒙端的ReportSink实现我参考了鸿蒙应用沙箱的访问规则。在鸿蒙环境里应用可以访问自己的沙箱目录通常通过特定的文件路径接口获取。下面这段代码是我在适配工程里使用的一个参考实现你根据自己的 SDK 版本做微调即可class HarmonyReportSink implements ReportSink { override Futurevoid writeReport(String content, String target) async { // 这里不是直接 new File(target)而是先解析目标路径。 // 如果 target 以特定前缀开头视为鸿蒙沙箱内路径 // 否则使用当前应用可访问的缓存目录。 final String resolvedPath resolveTargetPath(target); // 实际写入逻辑使用鸿蒙侧提供的文件写入能力。 // 需要注意写入后最好确认文件确实存在且大小不为 0 // 否则后续 CI 拉取时会拿到一个空文件很难排查。 final bool success await writeDeviceFile(resolvedPath, content); if (!success) { throw Exception(failed to write report at $resolvedPath); } } }你不要纠结writeDeviceFile这个函数的具体实现因为不同鸿蒙 SDK 版本提供文件写入的方式有差异有的是同步接口有的是异步接口有的还需要先申请目录权限。关键是把握两点第一路径必须解析到应用可访问的范围内第二写完之后要做一次“回读校验”确认报告文件真实可用。我在真机上踩过“写的时候明明成功文件大小始终是 0”的坑后来就是靠这段回读校验暴露出来的。3.3 JUnit XML 生成核心逻辑报告生成这部分是原库擅长的领域我基本没有重写只是把入口和配置方式调整了一下让它能在鸿蒙环境下被单独调用。核心逻辑维持“模型构建 - XML 序列化”两步走。下面这段代码是生成 JUnit XML 的骨架示例展示了最关键的部分class JUnitXmlDocument { final ListTestSuite suites; String toXmlString() { final buffer StringBuffer(); buffer.writeln(?xml version1.0 encodingUTF-8 ?); buffer.writeln(testsuites disabled0 errors$totalErrors failures$totalFailures tests$totalTests time$totalTime); for (final suite in suites) { buffer.writeln( testsuite name${escapeXml(suite.name)} tests${suite.cases.length} failures${suite.failureCount} errors${suite.errorCount} time${suite.time}, ); for (final testCase in suite.cases) { buffer.writeln( testcase classname${escapeXml(testCase.className)} name${escapeXml(testCase.name)} time${testCase.time}, ); if (testCase.failure ! null) { buffer.writeln(failure message${escapeXml(testCase.failure.message)}${escapeXml(testCase.failure.detail)}/failure); } buffer.writeln(/testcase); } buffer.writeln(/testsuite); } buffer.writeln(/testsuites); return buffer.toString(); } }这段代码里的escapeXml函数只是把、、、、这些特殊字符替换成对应的 XML 实体标准的实现方式但很容易被忽略。测试用例的失败信息里出现或是家常便饭如果不转义Jenkins 解析 XML 时会直接报格式错误整个报告都加载不出来。原库大概率已经有转义逻辑但我在适配时仍然专门补了测试用例覆盖这个场景。有个细节值得说一下JUnit XML 对time属性有格式要求Jenkins 会把它解析成浮点数单位是秒。我在适配时发现有些用例执行时间极短会输出类似0.000000的值Jenkins 面板上就会显示成 0看起来像“没跑”。后来我在生成时做了个规范化如果时间小于 0.001至少显示0.001这样趋势图才有意义。3.4 CLI 入口与命令行集成原库原本的 CLI 用法主要是通过管道接收 flutter test 的事件流。鸿蒙环境下管道不一定方便我给适配版加了一个“同时支持文件输入和 stdin 输入”的入口。这样在设备上可以先跑测试并把事件流写到临时目录再调用报告生成器读取该文件生成 XML在 CI 上也可以直接用管道方式运行。入参示例如下# 方式一文件作为输入源适合鸿蒙设备端 dart run junitreport_maintained_harmony:main \ --input events.json \ --output reports/junit.xml # 方式二标准输入流适合 CI 宿主机 flutter test --machine | \ dart run junitreport_maintained_harmony:main \ --output reports/junit.xml这里没有用原库的默认命令而是新建了独立入口原因很实际在适配期如果能和原库入口并存方便对照验证行为出了问题可以快速回到原库跑一遍确认是不是适配引入的问题。等你验证充分了再考虑要不要直接替换原入口。4. 多设备报告汇总端侧产物怎么合并成一份标准 XML4.1 汇总策略与目录约定自动化测试只要涉及多台设备或者多个模块报告就只有“汇总后”才有完整的价值。单台设备的报告只能说明这一台设备上用例过没过管理者想看的往往是整体通过率、失败用例分布、哪台设备最不稳定。我采用的策略是“设备内分散生成CI 侧统一合并”。也就是说每台设备在端侧生成自己的 JUnit XML 文件CI 构建节点通过设备管理平台把文件拉取到固定目录再跑一个合并脚本把所有 XML 合成一份总报告。目录约定如下reports/ device_serial_A/ junit.xml device_serial_B/ junit.xml merged/ junit-all.xml这个约定虽然简单但解决了一个很现实的问题如果所有设备把报告都写到同一个文件后拉取的那台设备会覆盖前一台设备的结果最终报告里永远只有一台设备的测试数据。分目录存放从根上规避了并发覆盖问题也让排查单台设备问题时能直接定位到对应文件。4.2 合并脚本示例合并脚本我用 Python 写的因为 CI 节点上 Python 基本是标配不需要额外安装依赖。核心思路是用标准库xml.etree.ElementTree解析所有 XML把测试套件的节点取出来挂到新的testsuites根节点下再重新写一个文件。import glob import xml.etree.ElementTree as ET def merge_junit(input_pattern, output_file): root ET.Element(testsuites) total_tests 0 total_failures 0 total_errors 0 total_time 0.0 for path in glob.glob(input_pattern): tree ET.parse(path) suite_root tree.getroot() # 兼容什么根节点都有的情况统一取 testsuite 或直接取根节点 suites suite_root.findall(testsuite) if suite_root.tag testsuites else [suite_root] for suite in suites: root.append(suite) total_tests int(suite.get(tests, 0)) total_failures int(suite.get(failures, 0)) total_errors int(suite.get(errors, 0)) total_time float(suite.get(time, 0)) root.set(tests, str(total_tests)) root.set(failures, str(total_failures)) root.set(errors, str(total_errors)) root.set(time, f{total_time:.6f}) tree ET.ElementTree(root) tree.write(output_file, encodingUTF-8, xml_declarationTrue) if __name__ __main__: merge_junit(reports/device_*/junit.xml, reports/merged/junit-all.xml)这个脚本在合并时要注意两个问题。第一不同设备生成的 XML 里testsuite的name属性最好带上设备标识否则两个设备跑同一个测试模块合并后 Jenkins 上会显示两套同名套件虽然也能看但鼠标点进去分不清谁是谁。我建议端侧生成时就把设备名拼进 suite name从源头解决。第二时间戳和耗时属性在合并脚本里用浮点数重新累加不要直接用字符串拼接否则 Jenkins 解析时精度对不上。4.3 成功率与失败用例统计合并完成之后Jenkins 里展示的通过率、失败增量、耗时趋势全部以合并后的总报告为准。这里有一个统计口径的问题合并脚本里total_tests是所有套件的用例数之和而 Jenkins 最终显示的成功率是(total_tests - failures - errors) / total_tests。如果你的测试里有跳过skipped用例JUnit XML 里对应的属性也要正确汇总否则统计结果会偏。我在实际项目里遇到过一种情况某台设备因为长时间待机自动断开导致该设备上执行的测试全部超时生成出来的 XML 里有大量failure节点。从总报告看通过率一下子掉了很多看起来很吓人但本质是设备连接问题而非用例质量退化。后来我在汇总逻辑里增加了一个“设备心跳检测”如果某台设备拉取报告时发现异常超时直接标记为“设备故障”对应的报告文件不参与合并以免污染整体统计。5. Jenkins/CI 集成实战5.1 流水线阶段设计接入 Jenkins 时我把整个流程分成了四个阶段构建、测试、报告收集、汇总发布。构建阶段负责编译 HAP 并安装到测试设备测试阶段执行 flutter test 并让鸿蒙端侧生成 XML报告收集阶段从设备上拉取报告文件汇总发布阶段跑合并脚本并交给 Jenkins 的 JUnit 插件解析展示。这个分阶段设计的核心考量是让“测试执行”和“报告处理”完全解耦。这样 CI 机器不需要预装鸿蒙工具链也能处理报告需要调设备时只需要在测试阶段配置设备农场信息不会影响其他阶段。5.2 Jenkinsfile 参考下面是一份精简但完整的 Jenkinsfile 参考你可以根据自己集群的标签和设备管理方式调整pipeline { agent any environment { REPORT_BASE reports DEVICE_POOL harmony-device-pool } stages { stage(Build HAP) { steps { // 在这里执行鸿蒙工程构建 sh hvigorw assembleHap } } stage(Run Tests on Devices) { steps { // 在测试设备池上并行执行测试 // 每台设备执行结束后把 reports/junit.xml 拉到 // ${REPORT_BASE}/device_${DEVICE_SERIAL}/junit.xml sh ./run_tests_on_devices.sh --pool ${DEVICE_POOL} --report-base ${REPORT_BASE} } } stage(Collect Reports) { steps { // 把设备端生成的 XML 从远端设备管理服务拉回本地 sh ./pull_reports.sh --device-pool ${DEVICE_POOL} --report-base ${REPORT_BASE} } } stage(Merge Reports) { steps { sh python3 merge_junit.py \ ${REPORT_BASE}/device_*/junit.xml \ ${REPORT_BASE}/merged/junit-all.xml } } } post { always { // JUnit 插件解析合并后的报告 junit reports/merged/junit-all.xml } } }这份流水线文件里的每个脚本都假定已经放在源码仓库里并且能在 CI 节点上直接执行。post.always里的junit步骤是 Jenkins JUnit 插件提供的它会解析 XML 并生成趋势图、失败分类列表。5.3 报告解析与展示Jenkins 的 JUnit 插件解析完 XML 之后你会在构建详情页看到几个非常直观的板块测试结果趋势图、失败用例列表、耗时统计。趋势图尤其有用它能一眼看出某个测试类是不是从某个构建开始突然频繁失败。失败用例列表里会直接展示 failure 节点的 message 信息这就是为什么前面强调生成阶段必须完整保留失败信息否则这里只能看到干巴巴的“failed”字样。CI 集成过程中有一个容易被忽略的细节如果合并后的 XML 里某个测试套件没有time属性Jenkins 会把整个套件的耗时当成 0趋势图里会平白多出一堆零值干扰判断。所以合并脚本里我专门做了归一化即使原 XML 里没有 time 属性也会填一个默认值。类似的边界情况最好在合并脚本里统一兜底。6. 常见问题与排查实录6.1 高频问题速查表适配和接入过程中我把团队和自己遇到的典型问题整理成了一张速查表希望对你排查时有帮助现象常见原因解决方法报告文件大小为 0写入后没有做 fsync 或回读校验在 ReportSink 中增加写入后校验XML 解析失败特殊字符未转义用标准 XML 转义函数补测试用例Jenkins 上看不到报告合并脚本输出路径和 junit 步骤不一致统一用相对路径确认阶段间目录约定多设备报告互相覆盖文件没有按设备分目录强制按 device_serial 分目录存储时间全部显示为 8 小时前时区未统一生成时统一用 UTC 时间戳设备上有报告但拉不下来报告生成在沙箱外或权限不足确认写入路径在应用可访问的沙箱范围内测试事件流不完整命令管道被截断或进程提前退出改用文件作为输入源测试结束后再生成这张表里的每一条都是我或者身边同事实际撞到过的不是拍脑袋总结的。最值得留意的是“报告文件大小为 0”这一条它特别隐蔽因为设备端看起来一切都是正常的但 CI 拉回来的文件是空的Jenkins 解析时报错或静默跳过最终表现为“报告莫名消失”。6.2 两个让我折腾最久的坑第一个坑是“沙箱路径之外写入静默失败”。我在最初适配版本里直接沿用了原库的默认输出路径想着鸿蒙再怎么样也不至于连个目录都写不了结果真机上一跑报告文件确实生成了但怎么拉都只有 0 字节。排查了大半天最后发现是应用在设备上根本不具备目标目录的写权限写入请求被系统静默拦截了。从那以后我所有设备端的路径都改成显式传入并且写入后立刻读一遍文件字节数做校验才彻底消除这个隐患。第二个坑是“事件流从管道切到文件时丢尾巴”。端侧执行测试时如果直接用管道把 flutter test 输出交给报告生成器遇到设备资源紧张或连接不稳末尾的 JSON 事件很容易丢失导致报告里用例数少于预期。我的解决方式是在测试执行时把原始事件流先完整落盘测试结束之后再由报告生成器读取文件、生成 XML。这样即使事件流在管道阶段丢了一部分文件里仍然保留着原始日志可以回放排查。后来验证下来这个方式让报告用例数几乎和实际执行数完全一致。结尾说点实操后的真心话这次把 junitreport_maintained 鸿蒙化的过程中我最有价值的一个体会是跨平台适配最耗时间的往往不是核心逻辑而是环境差异带来的隐性行为变化。很多问题在 Android 上不存在到了鸿蒙上就会以各种奇怪的方式冒出来比如静默失败的写入、时区错位、路径权限陷阱。如果你也在做类似的事情建议一开始就把“平台差异清单”拉出来每个点都主动验证一遍而不是等出了问题再回头猜。最后再分享一个小技巧适配库的时候尽量给整个流程加一个“端到端冒烟测试”就是用两个必通过的用例和两个必失败的用例跑通全链路检查生成的 XML 在 Jenkins 上是否解析正确。这个小动作每次集成新设备类型都帮我提前暴露掉至少两三个隐藏问题。工具链这种东西只有真正在流水线上跑稳了才算适配完成。