去年年底我在做 Flutter 测试套件向鸿蒙 HarmonyOS 迁移的时候,遇到了一件很诡异的事:一套在 Android 上已经稳定跑了半年的用例,搬到鸿蒙上之后,一夜之间“绿”了 17 个用例。不是通过率变高了,而是这些用例根本没有执行到断言那一步,就悄悄返回了成功。这是典型的“假绿”——测试套件在撒谎,而你完全不知道。
后来我把异常断言相关的逻辑全部重写,收敛成一个独立的组件,内部代号就叫 expect_error。这次适配鸿蒙的实战让我把“怎么写异常断言”这件事彻底想明白了:异常断言不应该是散落在测试文件里的零散 expect,而应该是一套有入口、有链路、有结果归档的治理体系。这篇文章就把整个过程中的设计思路、踩坑记录和最终落地架构原原本本讲一遍,适合正在做 Flutter 鸿蒙迁移、或者想系统化构建异常断言能力的团队参考。
1. 从一次“假绿”测试说起:异常断言为什么必须体系化
1.1 鸿蒙迁移后,我的测试套件学会了撒谎
问题出现得很隐蔽。迁移测试套件的第一周,一切看起来都很顺利:Android 上 200 多个用例全绿,鸿蒙上跑出来也是全绿,而且耗时更短。我当时还以为是鸿蒙的运行时更快,直到随手把一个用例的断言故意改错,重新跑——它还是绿的。
那一刻我才意识到问题严重性。逐个定位后发现,真正被完整执行的用例只有不到三分之一,其余的都是“起跑即终点”:测试方法进入后,异步逻辑还没完成,测试框架就认为该方法执行完毕,直接标记为通过。尤其集中在涉及 MethodChannel、平台视图和异步回调的场景里。说白了,异常是在测试结束之后才抛出来的,框架根本接不住。
这个现象在 Android 上没有出现,是因为 Android 端 Flutter 引擎的事件循环和线程调度,恰好能把异常压回测试 Zone 内;而鸿蒙的异步消息队列调度时机不同,异常被延后或直接吞掉。这提醒我,如果只把迁移当作“跑一遍试试”,测试可靠性根本无从谈起。
1.2 原生断言写法在异常场景下的四个致命伤
把当时测试代码里的异常断言全翻出来看,问题非常集中,也和绝大多数 Flutter 项目里常见写法一致:
| 痛点 | 典型表现 | 后果 |
|---|---|---|
| 类型依赖过强 | 直接throwsA(isA<FooException>()) | 鸿蒙平台层包装后异常类型变化,断言直接失效 |
| 异步捕获盲区 | expect(() async {...}, throwsException) | async 函数闭包返回 Future,断言作用在闭包执行瞬间而非 Future 完成之后 |
| 缺少上下文 | 断言只关心异常类型,不知道从哪个业务模块、哪条链路抛出来的 | 出问题后只能靠日志猜位置 |
| 失败信息碎片化 | 多个异常断言各自为政,没有统一报告 | 一次测试失败,无法快速判断是新增缺陷还是已有问题 |
这些不是写法水平问题,而是缺少一整套围绕“异常”的断言抽象。Flutter 官方 test 包给了很好的底层能力,但把底层能力直接铺在业务测试里,就必然产生上面这些碎片和盲区。所以我的决定是:写一个组件,把异常断言从“行为”升级为“治理”。
1.3 expect_error 的目标定位
expect_error 不是要替代package:test或者flutter_test,它要解决的是中间这一层问题:在测试代码和异常发生点之间,建立一个稳定、可追溯、跨平台一致的断言通道。
它承担三件事:第一,统一同步、异步、事件驱动三种异常断言入口;第二,兼容鸿蒙平台对异常类型的包装与改写;第三,为每次断言生成错误链路快照,让异常断言天然带上可观测性。整个组件的核心思路可以概括成一句话:把“写断言”变成“登记异常”——你不需要在测试里猜异常怎么冒出来,只需要告诉 expect_error 你关心什么,剩下的捕获、校验、归档都交给它。
2. expect_error 的核心设计:把“写断言”变成“登记异常”
2.1 统一入口:同步、异步、事件驱动三种模式
最开始我也犹豫过,要不要保留原生expect(() {}, throwsA(...))风格,做一层薄封装就行。后来发现不行。因为底层throwsA对异步场景的支持天然别扭——它接收的是一个函数,这个函数如果是异步的,返回的是 Future,throwsA无法感知 Future 内部的异常。这也正是鸿蒙上大量“假绿”的根源之一。
所以我设计了统一入口,不再区分断言宏,而是区分异常产生的方式:
// 同步场景:直接传入会产生异常的闭包 expectError( () => divide(10, 0), onError: (cause) { expect(cause.type, 'ArithmeticException'); expect(cause.detail, contains('division by zero')); }, ); // 异步场景:传入返回 Future/Future<void> 的函数 await expectError( () => fetchUserProfile('token_expired'), asynchronously: true, timeout: const Duration(seconds: 5), onError: (cause) { expect(cause.linkId, startsWith('network.user.profile')); }, );同步和异步之所以必须分开设计,是因为捕获机制完全不同。同步异常由函数调用栈直接抛出,闭包内 try-catch 就能接住;异步异常则必须等待 Future 完成,并在 completer 层面接管错误。而事件驱动场景,比如某个 Widget 在 build 期间抛错,既不是同步也不是单纯异步,它落在 FlutterError 的全局捕获通道里,需要额外的桥接钩子。
// 事件驱动场景:监听 FlutterError.onError / PlatformDispatcher.instance.onError final subscription = FlutterError.onError.listen((details) { expectErrorFromEvent(details, onError: (cause) { expect(cause.origin, 'widget.build'); }); });三种模式共用同一个ErrorCause数据模型,后续的链路归档和报告生成全部复用,不用各写一套。
2.2 错误谓词:从“匹配类型”升级为“匹配特征”
Flutter 里最常见也最脆弱的断言方式,就是isA<SomeException>()。在纯 Dart 环境下,这个写法问题不大;但一旦进入鸿蒙平台层,异常从平台通道传回来时,类型信息会经过一次“翻译”,结果是:你在测试里看到的异常类型,往往不是业务代码里真正抛出的类型。
所以 expect_error 放弃了单点类型匹配,改为一个更宽松但更可靠的“特征谓词”体系。你可以从四个维度描述一个异常:类型名、消息片段、错误码、链路 ID。命中条件默认是“任意特征匹配即可”,也可以通过match: MatchMode.strict要求全命中。
await expectError( () => channel.invokeMethod('storage/read', {'key': 'config'}), asynchronously: true, match: MatchMode.strict, onError: (cause) { expect(cause.type, contains('PlatformException')); expect(cause.code, 'STORAGE_READ_FAILED'); expect(cause.detail, contains('NoSuchFile')); }, );为什么要这么设计?因为跨平台场景里,异常类型是最不稳定的一层。鸿蒙对 Flutter 平台通道异常的包装方式,我后面会详细讲;这里先记住结论——按特征断言,不按类型断言,能把测试对底层实现细节的耦合降到最低。业务代码里改了异常类名、换了包装方式,只要关键特征还在,断言就还能守住底线。
2.3 超时、快照与“异常未发生”的判定
异常断言里最容易被忽略的场景是:测试关心的是“某段代码必须抛异常”,结果它没抛。原生写法下,throwsA遇到“没抛”会直接报 expectation 失败,信息量几乎为零。你只知道它没抛,不知道为什么没抛。
expect_error 做了两件事补上这个缺口。第一是超时控制,异步断言默认 3 秒内没有异常上报,就触发ExpectTimeoutException,同时把已收集到的上下文信息原样保留。第二是快照生成,无论断言成功还是失败,都会把当前请求参数、错误码、堆栈帧摘要、耗时写入一条结构化记录。
class ErrorCause { final String type; final String? code; final String? detail; final String? linkId; final StackTrace? stackTrace; final DateTime occurredAt; final Duration elapsed; }有了快照,测试失败就不再是一个孤零零的红叉,而是一条“错误现场的录像”。尤其在鸿蒙这种平台行为差异较多的环境里,快照几乎成了定位问题的第一手材料。
3. 鸿蒙适配绕不开的三道坎:桥接差异、异步时序与错误码漂移
3.1 第一道坎:MethodChannel 异常在鸿蒙上会被包一层“马甲”
鸿蒙适配过程中,第一个让我真正动手改代码的问题,就是平台通道异常的包装差异。在 Android 上,Flutter 调用 MethodChannel 后,如果原生侧抛出异常,最终落到 Dart 侧的通常是一个PlatformException,类型清晰、message 可读、details 里还能带附加信息。
但鸿蒙侧并非总是如此。实际跑下来,一部分异常确实能映射成PlatformException,另一部分却会被包裹成通用的PlatformException(code: '-1', message: 'unknown error'),真正的错误码和原因被塞进了details字段的深层嵌套里。如果断言直接盯 code 或 type,必然翻车。
我的解包方案是加一层_unwrapPlatformError,对平台通道返回的异常做递归解析,把深层 details 里的业务错误码和原始消息提取到统一模型里:
ErrorCause _unwrapPlatformError(Object error) { if (error is PlatformException) { final code = error.code; final detail = _extractNestedDetails(error.details); if (code == '-1' || code == 'unknown_error') { return ErrorCause( type: _resolveRealType(detail) ?? error.runtimeType.toString(), code: _resolveRealCode(detail) ?? code, detail: detail, ); } return ErrorCause(type: error.runtimeType.toString(), code: code, detail: detail); } return ErrorCause.fromUnknown(error); }_extractNestedDetails会遍历details里的 Map/List 结构,优先取code、errCode、description、msg这些常见字段。这一层解包做完,测试代码里对异常特征的描述才和平台实现完成了解耦。鸿蒙版本后续如果调整了包装结构,也只需要更新解包规则,断言语义不用动。
3.2 第二道坎:异步时序差异导致异常“迟到”甚至“缺席”
回到文章开头那个“假绿”问题。我定位它的过程,完整复现一下,方便你对照排查。
第一步,我在可疑用例里插入日志,发现测试方法结束时异常确实还没发生;第二步,在异常真正抛出的地方单独打日志,确认异常存在、且堆栈完整;第三步,确认 FlutterError 和 Zone 的全局回调里都收到了这个异常,但它就是没有被测试断言捕获。
关键点就在第三步。测试框架是靠 Zone 来捕获异步异常的,而鸿蒙的异步任务调度在某些场景下会把任务投递到引擎内部的另一个 Zone,导致测试 Zone 里的runZonedGuarded收不到异常。说白了,异常发生了,但它发生在你监控范围之外。
解法是给 expect_error 的异步模式加一个“强制归约”机制:不依赖调用方 Zone 的自动捕获,而是用 Completer 显式接管 Future,并在超时定时器触发时主动判定失败:
Future<T> _runWithTimeout<T>(Future<T> Function() task, Duration timeout) { final completer = Completer<T>(); final timer = Timer(timeout, () { if (!completer.isCompleted) { completer.completeError(ExpectTimeoutException(timeout)); } }); task().then((value) { if (!completer.isCompleted) { timer.cancel(); completer.complete(value); } }).catchError((Object e, StackTrace st) { if (!completer.isCompleted) { timer.cancel(); completer.completeError(e, st); } }); return completer.future; }这里有个细节值得注意:catchError必须显式声明参数类型Object e,否则 Dart 的强类型分析会把catchError当成可能吞掉异常的处理,导致异常被静默处理。这个写法保证异常无论何时到达,都会被 Completer 接住,再通过我们自己的错误链路传递,不再依赖 Zone 的隐式行为。
3.3 第三道坎:不同版本鸿蒙错误码漂移,断言怎么稳住
鸿蒙适配里最隐蔽的问题,是错误码在不同系统版本间并不稳定。同一个“文件不存在”场景,在某个 API 版本上是errorCode: 101,到了另一个版本变成了errorCode: 300003,但语义其实一样。如果断言写死错误码,升级系统后测试会突然大量失败,而且每次失败都是误报。
应对思路是建一张“错误码归义表”,把不同版本的错误码映射到统一的业务语义上。expect_error 在鸿蒙模式下会先做一次归义解析,再交给断言语义层:
class OhosErrorCodeNormalizer { static const Map<String, List<String>> _semanticGroups = { 'resource_not_found': ['101', '300003', 'FILE_NOT_FOUND', 'NO_SUCH_FILE'], 'permission_denied': ['201', '100401', 'PERMISSION_DENIED'], 'network_unreachable': ['103', '200001', 'NETWORK_UNREACHABLE'], }; static String normalize(String rawCode) { for (final entry in _semanticGroups.entries) { if (entry.value.contains(rawCode)) return entry.key; } return rawCode; } }断言的正确姿势变成了:
expect(cause.code, 'resource_not_found');这么做的好处是,测试关注的是业务语义,不是某个版本的具体实现。错误码表需要持续维护,但收益非常明显——系统版本升级时,测试套件不会再被一堆“假失败”淹没。
4. 从单点断言到全链路:错误链路捕捉架构的落地方式
4.1 四层异常收集体系
单个断言写得再稳,如果没有链路视角,异常治理仍然是零散的。我在鸿蒙适配的后半段,把 expect_error 从断言组件升级成了完整的错误链路捕捉架构,分四层:
| 层级 | 异常来源 | 捕获点 | 典型场景 |
|---|---|---|---|
| 单元层 | 纯 Dart 函数/类 | 同步闭包入口 | 工具类、状态管理逻辑 |
| 组件层 | Widget build/layout | FlutterError.onError | 组件渲染异常 |
| 平台层 | MethodChannel/EventChannel | 通道消息回调 | 鸿蒙原生能力调用失败 |
| 全局层 | 未捕获异步异常 | PlatformDispatcher.instance.onError | 后台任务、Timer、Stream 回调 |
四层捕获到的异常,统一汇入一个链路节点队列。每层之间用节点上下级关系串联,最终形成一棵错误链路树。这个设计的动机是:线上问题很少是单点失败,往往是“组件加载失败 -> 平台能力调用失败 -> 权限不足”这样的级联链条,传统断言只能告诉你最后一步,链路树能告诉你完整的因果。
4.2 链路 ID 与上下文透传
要让链路可追踪,必须先有链路 ID。expect_error 在测试初始化时生成一个全局linkId,通过 Zone 变量向异步子任务透传:
const _linkIdKey = #expectErrorLinkId; String get currentLinkId => Zone.current[_linkIdKey] as String? ?? 'unknown'; R runWithLinkId<R>(String linkId, R Function() body) { return runZoned(() => body(), zoneValues: {_linkIdKey: linkId}); }这样,只要业务代码或测试代码在同一个 Zone 体系里发起异步调用,错误捕获时就能从Zone.current里读出链路 ID,让每一条异常都自动挂到正确的链路上。不需要侵入业务代码传参,这是 Zone 机制在错误可观测性上最大的价值。
4.3 断言结果聚合与报告输出
所有层级的错误链路最终被聚合成一份结构化报告。报告包含链路 ID、节点数、每层断言的命中情况、超时统计,以及未捕获异常清单。CI 阶段可以把报告序列化成 JSON,归档到测试管理平台;本地调试时则输出到控制台,用缩进表现层级:
class ErrorLinkReport { final String linkId; final List<ErrorLinkNode> nodes; final Duration totalElapsed; final bool allAssertionsMatched; } class ErrorLinkNode { final String nodeName; final int depth; final DateTime occurredAt; final ErrorCause cause; final ErrorLinkNode? parent; }这套报告机制带来的一个直接变化是:测试失败从“某个用例挂了”变成了“某条链路上哪个节点出了什么问题”。排查效率提升得非常明显,尤其是在鸿蒙平台行为还不完全稳定的时候,一份带链路结构的报告,基本就是给定位问题开了天眼。
5. 实战收益与维护禁忌
5.1 迁移后三个立竿见影的变化
expect_error 在鸿蒙测试套件上跑稳之后,我观察到了三个非常具体的变化。
第一个,假绿率归零。所有异步用例都必须显式等待异常或超时结论,测试框架再也没出现过“没执行就通过”的情况。第二个,异常定位时间从小时级降到分钟级。错误码漂移和平台包装问题在解包层统一处理后,测试失败直接指向业务语义,不用再翻平台日志。第三个,用例的可读性变好了。写断言的同事不再需要关心异常捕获机制,只需要描述“这段代码应该报什么特征错误”,理念上从底层 API 使用变成了领域规则描述。
5.2 过度断言是比漏断言更隐蔽的坑
在我把 expect_error 推广给团队后,很快遇到了新问题:有人把特征谓词写得太死。比如对detail字段精确匹配整段错误描述,结果业务侧调整了一个措辞,测试就红了。还有人在一个同步用例里叠加十多个断言,把实现细节全部固化,重构时处处掣肘。
我的建议是,异常断言遵循“三层原则”:第一层断言业务语义(错误码归义后的结果),第二层断言关键消息片段(用contains而不是精确匹配),第三层才断诊断言堆栈或耗时等附加信息。前两层是必须的,第三层默认不写。这样既守住了核心质量底线,又给业务代码留了演进空间。
5.3 这个架构还能怎么扩展
目前这套错误链路捕捉架构已经稳定跑在鸿蒙和 Android 双端。后续我计划把链路报告接入实时监控大盘,把测试环境的异常数据同步用于灰度发布前的风险预判。还打算把错误码归义表做成配置热更新的形式,鸿蒙每次发版后,由 CI 自动比对错误码差异,把漂移检测前置到版本发布之前,而不是等测试跑了才发现。
最后分享一个我一直强调的细节:异常断言治理的成败,不取决于断言怎么写,而取决于错误链路能不能打通。只要链路是通的,断言早晚能补全;链路断着,断言写得再漂亮也是自欺欺人。做鸿蒙适配尤其要记住这一点——先解决假绿,再谈覆盖率。