☰
HarmonyOS 7 fast-check:桌面卡片生命周期随机序列收缩
2026/10/8 7:19:33 网站建设 项目流程

WidgetFuzzLab 不在手机里跑随机测试。它把 Form Kit 的新增、刷新、路由、删除抽成纯模型,交给 fast-check 在构建侧生成序列;手机端只展示同一份报告。这样既不把测试库带进 HAP,也能把偶现问题压缩成五步可复现样本。

一、四十二步之后的错误,人工很难复盘

桌面卡片的单个回调通常不难。麻烦来自顺序:用户刚添加卡片,系统触发更新;点击卡片拉起应用;应用写入新数据;桌面又发来刷新;紧接着用户删除卡片。每一步单独执行都没错,组合起来却可能让已删除的formId再次落盘,或者让旧版本数据覆盖新版本。

原来的回归脚本只有几条固定流程。线上偶发一次“删除后复活”,日志足足 42 步,手工删掉任何一段都可能让问题消失。团队花了半天重放,最后还不能确定最小触发条件。于是我把目标从“多写几个示例”改成三个可验证的不变量:已删除卡片不可更新;同一formId的revision只能单调递增;同一个待消费路由最多处理一次。

本次任务编号为fuzz_20261001_22,项目叫 WidgetFuzzLab,报告页叫PropertyReportPage。测试使用固定种子203104717,总共生成 5,000 条序列、42,680 个命令。fast-check 只用于 Node 侧测试任务,不进入 HarmonyOS 运行包;ArkTS 运行时仍只包含 Form Kit 业务适配器和一份 JSON 报告读取逻辑。

二、先做一个比真实卡片更严格的模型

属性测试不是把随机数塞进现有接口。没有“什么结果才算对”,随机只会制造噪声。我先写纯内存模型:每个卡片记录revision、删除标记和待消费路由;模型不会访问 Form Kit,也不会写文件。真实适配器和模型接收同一条命令,执行后比较可观察状态。

下面这段代码解决的是判定标准缺失。WidgetLifecycleModel把三条不变量放在同一处,命令执行后都调用assertInvariant()。

exportinterfaceWidgetState{revision:numberremoved:booleanpendingRoute?:stringconsumedRoutes:Set<string>}exportclassWidgetLifecycleModel{privatestates:Map<string,WidgetState>=newMap()add(formId:string):void{if(!this.states.has(formId)){this.states.set(formId,{revision:0,removed:false,consumedRoutes:newSet<string>()})}}update(formId:string,revision:number):void{conststate=this.states.get(formId)if(!state||state.removed||revision<=state.revision)returnstate.revision=revision}remove(formId:string):void{conststate=this.states.get(formId)if(state)state.removed=true}route(formId:string,routeId:string):void{conststate=this.states.get(formId)if(!state||state.removed||state.consumedRoutes.has(routeId))returnstate.pendingRoute=routeId state.consumedRoutes.add(routeId)}assertInvariant():void{for(conststateofthis.states.values()){if(state.removed&&state.pendingRoute)thrownewError('REMOVED_WITH_ROUTE')if(state.revision<0)thrownewError('REVISION_ROLLBACK')}}}

模型故意比业务层更保守:一旦删除就不允许复活。update()对相同或更小的版本直接忽略,这使重复回调天然幂等。数据只在命令被接受时变化,拒绝分支不修改状态。测试结束后模型整体丢弃,没有资源释放问题;但它不能替代真机验证,因为系统回调的并发、序列化和进程退出仍属于真实适配器的责任。

三、命令生成、收缩与重放是一条链

我为AddCommand、UpdateCommand、RemoveCommand和RouteCommand分别实现fc.AsyncCommand。check()控制某条命令当前是否有效,run()同时推动模型和真实适配器。生成器偏向重复更新、删除后更新和相同路由重放,因为这些正是事故集中区,而不是平均分配所有操作。

下面的测试入口解决“失败只能在当次随机运行中看到”的问题。固定种子保证序列可追溯,fast-check 收缩后输出replayPath,下一次可以只重放最小失败样本。

importfcfrom'fast-check'import{WidgetLifecycleModel}from'../model/WidgetLifecycleModel'import{FormRuntimeAdapter}from'../adapter/FormRuntimeAdapter'import{widgetCommandArbitrary}from'./WidgetCommands'constseed:number=203104717constreplayPath:string|undefined=process.env.FC_PATHawaitfc.assert(fc.asyncProperty(fc.commands(widgetCommandArbitrary(),{maxCommands:64}),async(commands)=>{constsetup=()=>({model:newWidgetLifecycleModel(),real:newFormRuntimeAdapter()})awaitfc.asyncModelRun(setup,commands)}),{seed,path:replayPath,numRuns:5000,endOnFailure:true,verbose:2})

这里的commands会记录整条执行轨迹,失败时尝试删除命令、缩小参数,直到找不到更短的仍失败序列。第一次定位到的问题从 42 步收缩成 5 步,路径是AAAE:B。FC_PATH=AAAE:B只适合与同一 seed、同一生成器版本组合使用;改了命令集合后路径可能失效,因此报告还会保存可读命令文本。测试进程完成后适配器要显式dispose(),关闭临时数据库和定时器,否则下一轮属性测试可能被上一轮资源污染。

四、真实适配器的修复点是幂等提交

最小序列是:添加 A、更新到 7、删除 A、收到延迟更新 8、消费旧路由。错误不在 Form Kit,而在我们的onUpdateForm()先写数据库、后检查删除墓碑。进程调度改变时,延迟更新就可能穿过删除操作。

下面这段适配器代码解决“检查与写入分离”的竞态。它把墓碑、当前版本和新数据放进同一个存储事务,只有版本更新且未删除才提交。

exportclassFormRuntimeAdapter{constructor(privatestore:FormStateStore=FormStateStore.getInstance()){}asyncapplyUpdate(formId:string,revision:number,payload:Record<string,Object>):Promise<boolean>{returnthis.store.transaction(async(tx:FormTransaction)=>{constrecord=awaittx.get(formId)if(!record||record.removed)returnfalseif(revision<=record.revision)returnfalseawaittx.put(formId,{...record,revision,payload,updatedAt:Date.now()})returntrue})}asyncremove(formId:string):Promise<void>{awaitthis.store.transaction(async(tx:FormTransaction)=>{constrecord=awaittx.get(formId)if(record)awaittx.put(formId,{...record,removed:true,pendingRoute:undefined})})}dispose():void{this.store.closeIdleHandles()}}

applyUpdate()返回布尔值,让EntryFormAbility决定是否继续调用卡片更新,而不是把拒绝当异常。删除时保留墓碑而不是立即删除记录,是为了识别晚到回调;墓碑可在超过系统可能重试的时间窗后批量清理。正式项目需要用真实的关系型或键值存储事务替换 Demo 抽象,不能把先get后put的两个普通 Promise 当成原子操作。

FormExtensionAbility 的生命周期也必须纳入判断。系统回调结束后扩展进程并不适合承载长任务,耗时计算应交给应用或后台任务,准备好数据后再更新卡片。属性测试验证的是业务状态顺序,不代表可以绕过系统对扩展存活时间的约束。

五、报告页只展示证据,不执行测试

DevEco Studio 图里,左侧目录把test/property/WidgetCommands.test.ts、模型、真实适配器和EntryFormAbility.ets分开;中间是固定种子的测试入口;右侧模拟器打开PropertyReportPage。底部 HiLog 与报告使用同一份 JSON:5,000 条序列、42,680 个命令,历史失败 37 个已全部修复,最小反例 42 → 5 步,11 个曾经不稳定的样本现在都能稳定重放,收缩耗时 P95 为 84 ms。

状态流转是GENERATING → SHRINKING → REPLAYING → PROPERTY_GREEN。PROPERTY_GREEN只表示当前生成器与不变量下没有失败,不表示卡片没有任何缺陷。生成器没有覆盖的权限变化、网络中断和跨设备恢复,仍要用专项测试补齐。

手机截图顶部显示时间 22:56、电量 86%,页面中保留任务编号、种子和AAAE:B,是为了让值班同学能从报告直接拼出重放命令。它没有手机外壳,也没有把 IDE 截图再嵌进去;红色批注只指向最小反例与最终状态。

报告生成也做了两层隔离。属性测试只输出结构化结果到build/reports/widget-fuzz/result.json,一个轻量构建步骤再挑选摘要写入应用的 rawfile。这样本地调试可以看到最新结果,发布构建则可以完全排除测试报告。报告 schema 带版本号,页面遇到未知版本只显示“报告不兼容”,不会把缺失字段误判成零失败。这个细节避免测试工具反过来成为应用运行时的脆弱依赖。

我还给每个命令增加了稳定的文本表示,例如Update(A,8)和Remove(A)。fast-check 的 path 很适合机器重放,但代码重构后未必长期可读;命令文本则能直接贴进缺陷单。最小反例最终被保存为五行,而不是一整段序列化对象。值班同学不需要理解生成器内部结构,就能看出“删除之后仍接受更新”才是关键转折。

性能数据同样需要正确解读。84 ms 是失败样本进入收缩阶段后的 P95,不是 5,000 条序列的总耗时;42,680 是实际执行命令数,不等于生成器尝试次数,因为被check()拒绝的命令不会执行。把这些口径写进报告,是为了防止后来的人看到数值变化就误判性能回退。工具页展示摘要,完整耗时分布仍留在 CI 附件中。

六、让随机测试能长期留在工程里

属性测试最容易出现两个反效果。一个是序列太随意,跑得多却碰不到危险边界;另一个是每次失败都不同,团队逐渐把它当成“不稳定测试”跳过。我的做法是把线上事故转换成生成权重和不变量,并把 seed、path、命令文本、代码版本一起写入报告。任何历史失败都进入固定回归集合,随机生成负责继续探路,固定集合负责防止倒退。

CI 中分成两档:提交阶段运行 300 条序列,控制反馈时间;夜间任务运行 5,000 条并允许完整收缩。发现失败后先把seed + path固化,确认可重放,再修业务代码。不要为了让流水线变绿而只调整生成器,也不要无限增加numRuns掩盖一个不可复现的共享状态问题。

还有几个工程边界值得保留。命令的check()不能过度过滤,否则永远生成不了删除后更新这种非法输入;真实适配器必须为每次 property 创建独立实例;时间相关逻辑要注入时钟,避免Date.now()让重放漂移;formId在模型里使用字符串,不能隐式转成 Number;测试报告落盘要做大小限制,不能把 5,000 条完整轨迹全部塞进资源包。

模型与真实实现的比较也不能只看最终快照。如果先错误更新、随后又被另一条命令覆盖,最终数据可能恰好一致,过程中的违规却已经对桌面发出一次错误刷新。因此适配器还记录精简事件流,断言每次提交之前都满足墓碑和版本条件。事件流在单条 property 结束后立即清空,失败时才写报告,既能捕捉瞬时错误,也不会无限占用内存。

针对并发我没有让随机命令直接开大量线程,那会让失败变得难以收缩。当前版本先把逻辑顺序验证干净,再用两个受控调度点模拟“删除检查后暂停”和“更新提交前恢复”。如果后续要覆盖更多调度组合,应该引入可控 scheduler,并把调度选择一并纳入重放数据。盲目增加并发只会得到偶发红灯,不会得到可行动的反例。

最后,第三方库版本必须锁定并接受许可证与依赖审计。fast-check 位于测试依赖,构建产物要通过依赖清单确认它没有进入 HAP。升级版本时先用历史 seed 和固定回归集合跑一遍,再更新 path;如果收缩策略变化导致旧 path 失效,仍可用保存的命令文本重建用例。工具可替换,失败证据不能随工具升级一起消失。

这次还暴露出一个命名问题。过去的refresh()同时承担读取、比较、写入和通知,模型无法判断哪一步违反规则。拆成loadRecord()、acceptRevision()、commitPayload()和publishUpdate()后,属性测试能够在提交边界检查不变量,生产代码的错误语义也清楚了。拒绝旧版本属于正常分支,存储事务失败才进入错误日志,二者不再混成同一个异常码。

当 Form Kit 回调携带未知formId时,适配器不会自动创建记录,而是记一次受控拒绝。自动补建看似提高容错,实际会绕过添加流程需要写入的初始配置。生成器专门提高未知 ID 的比例,确认它既不污染存储,也不会触发桌面更新。这个边界同样来自最小反例:随机工具最有价值的地方,往往是逼代码回答过去从未明确过的业务问题。

这套工具最终没有改变卡片的视觉效果,却改变了团队处理偶现故障的方式:从一段长日志里猜测,变成一个五步反例;从“可能是系统回调乱序”,变成可执行的不变量;从修完一次就算结束,变成把失败样本永久收入回归。fast-check 的价值不在随机本身,而在它能把复杂生命周期压缩到人能读懂、机器能重复的最小证据。

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

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

立即咨询