
上周回归MorphReplay时单步用例都过了窗口从 412vp 拉到 840vp页面进入双栏切回 412vp又能回到单栏。真正的问题出现在一串看起来并不特殊的操作里——展开、退到后台、后台期间收窄、再回前台。页面右上角已经显示COMPACT列表却仍保留双栏宽度。这不是“再补一个测试数据”能收住的缺陷。窗口、前后台和异步布局提交可以互相穿插四类事件稍微换个顺序人工枚举就会爆炸。我最后没有继续加表格用例而是把适配逻辑拆成可计算的状态机再让 fast-check 自动生成事件序列、压缩失败路径并把种子带回真机页面重放。一、单个断点正确不代表事件序列正确项目使用的规则并不复杂宽度小于 600vp 是COMPACT大于等于 600vp 是EXPANDED。最初实现直接在windowSizeChange回调里计算模式并用一个异步任务更新详情栏。正常拖动时几乎看不出异常因为后一次提交通常晚于前一次。事故序列是固定的RESIZE(840) → BACKGROUND → RESIZE(412) → FOREGROUND。进入后台前840vp 对应的详情栏计算已经排队后台期间收到 412vp状态被正确改成COMPACT恢复前台后旧任务才落地把 840vp 的布局结果重新写回。也就是说模式字段是新的布局快照却来自旧生命周期。传统测试只断言“输入 412 得到 COMPACT”当然不会失败。我们真正需要的不变量是任意合法事件序列执行结束后renderMode必须与当前宽度一致后台期间不能提交可见布局生命周期轮次变化后旧回调不能覆盖新快照。这三个条件比某一组输入更稳定也是属性测试适合接手的边界。二、先把 UI 逻辑压成一个纯状态机属性测试不能直接拖模拟器窗口。若把窗口对象、页面组件、异步队列都塞进测试失败后无法可靠缩减序列。我先做了一层很薄的模型输入只有RESIZE、BACKGROUND、FOREGROUND、COMMIT输出只有宽度、可见性、模式、生命周期轮次和最后提交轮次。下面这段 reducer 解决的是“同一事件在任何上下文里都只能产生一种状态变化”。它不访问 ArkUI也不创建窗口因此既能在 Node 测试进程中跑也能被页面的重放工具复用。exporttypeWindowModeCOMPACT|EXPANDEDexporttypeEvent|{kind:RESIZE,widthVp:number}|{kind:BACKGROUND}|{kind:FOREGROUND}|{kind:COMMIT,epoch:number}exportinterfaceLayoutState{widthVp:numbermode:WindowMode visible:booleanepoch:numbercommittedEpoch:numberstaleCommits:number}exportfunctionreduceWindow(state:LayoutState,event:Event):LayoutState{if(event.kindRESIZE){constmode:WindowModeevent.widthVp600?COMPACT:EXPANDEDreturn{...state,widthVp:event.widthVp,mode}}if(event.kindBACKGROUND){return{...state,visible:false,epoch:state.epoch1}}if(event.kindFOREGROUND){return{...state,visible:true,epoch:state.epoch1}}if(!state.visible||event.epoch!state.epoch){return{...state,staleCommits:state.staleCommits1}}return{...state,committedEpoch:event.epoch}}这里特意没有保存“上一种模式”。模式是宽度的派生值如果同时保存两份事实测试模型也会复制业务缺陷。epoch每次切换前后台都递增代表当前页面生命周期的版本提交事件携带创建时的版本版本不一致只能计为陈旧提交不能改变可见布局。这个模型还有一个重要边界它不尝试模拟系统何时派发窗口事件。系统调度是外部事实我们只检查无论事件怎样到达业务层都不能进入矛盾状态。把模型写得和生产代码一样复杂会变成“拿同一段错误证明自己正确”。三、生产代码只接受当前轮次的结果找到不变量后修复反而很小。WindowSequenceLabPage在收到尺寸变化时捕获当前epoch完成测量后再比较页面退后台与恢复前台都让轮次前进。旧任务可以自然结束但不能再写 UI。这段代码解决的是旧测量回调跨生命周期提交的问题。applyLayout()只在页面可见、轮次一致时执行ignoredCommitCount只是诊断指标不参与布局判断。EntryComponentstruct WindowSequenceLabPage{StatewidthVp:number412Statemode:WindowModeCOMPACTStateignoredCommitCount:number0privatevisible:booleantrueprivateepoch:number1onPageHide():void{this.visiblefalsethis.epoch}onPageShow():void{this.visibletruethis.epochthis.scheduleLayout(this.widthVp)}privatescheduleLayout(widthVp:number):void{constrequestEpochthis.epochthis.widthVpwidthVpPromise.resolve().then((){if(!this.visible||requestEpoch!this.epoch){this.ignoredCommitCountreturn}this.modewidthVp600?COMPACT:EXPANDEDthis.applyLayout(this.mode)})}}容易写错的地方是只在onPageHide()增加版本。那样后台期间创建的任务与恢复后的任务可能仍共享同一轮次。这里在隐藏和显示时都递增等于给每个可见阶段划出独立提交域。另一个风险是重复注册窗口回调页面销毁时仍要取消监听否则新旧页面实例会各自生成任务epoch只能隔离单实例无法替代资源释放。在真实工程中scheduleLayout()由窗口尺寸监听触发测试页则可以直接传入宽度。这个小切口让生产逻辑和测试模型共享判定规则又没有把系统对象硬塞进测试进程。四、让 fast-check 生成“人不会写”的顺序我把 fast-check 放在工程根目录的测试工具中由 Hvigor 的验证任务调用。它不是应用运行时依赖不会打进 HAP。生成器偏向 320、412、599、600、840、1000 这些边界宽度同时允许普通范围内的随机值生命周期事件和提交事件混合生成每条序列最多 18 步。下面的属性解决的是“无论事件顺序多怪最终状态都满足三个不变量”。测试批次固定为MR-1002-0410正常验证使用种子260410共运行 1200 条序列。importfcfromfast-checkimport{strictasassert}fromnode:assertconstresizeArbfc.oneof(fc.constantFrom(320,412,599,600,840,1000),fc.integer({min:300,max:1200})).map((widthVp)({kind:RESIZE,widthVp}asEvent))consteventArbfc.oneof(resizeArb,fc.constant({kind:BACKGROUND}asEvent),fc.constant({kind:FOREGROUND}asEvent),fc.integer({min:1,max:30}).map((epoch)({kind:COMMIT,epoch}asEvent)))fc.assert(fc.property(fc.array(eventArb,{minLength:1,maxLength:18}),(events){constfinalStateevents.reduce(reduceWindow,initialState())assert.equal(finalState.mode,finalState.widthVp600?COMPACT:EXPANDED)assert.ok(finalState.committedEpochfinalState.epoch)if(!finalState.visible){assert.notEqual(finalState.committedEpoch,finalState.epoch)}}),{seed:260410,numRuns:1200,endOnFailure:true})第一次运行在第 85 条失败。原始反例有 13 个事件人工看日志很难判断哪个动作有用。fast-check 继续 shrinking最终只留下四步RESIZE(840) → BACKGROUND → RESIZE(412) → FOREGROUND并给出seed260410、path84:3:1、replayPathAAEABQ:CF。这串信息比一张失败截图有价值因为任何开发机都能回到完全相同的输入。注意path不是业务 ID也不应手工拼。升级 fast-check 后缩减策略可能变化所以项目同时保存最小事件 JSON。种子用于重放生成过程事件文件用于长期回归两者各自解决不同的稳定性问题。五、把最小反例带回模拟器而不是停在测试报告里纯模型通过并不等于 ArkUI 页面一定正确。为此我加了一个很小的ReplayBridgeHvigor 任务把最小事件写入rawfile/replay-case.json测试页按 180ms 间隔执行并在每一步记录页面快照。这样可以核对系统监听、页面生命周期和 reducer 是否仍然一致。这段重放入口解决的是“CI 能复现设备上却只能靠手点”的断层。它只接受固定批次与固定路径避免调试参数意外进入正式功能。exportinterfaceReplayCase{runId:stringseed:numberpath:stringreplayPath:stringevents:Event[]}exportasyncfunctionreplayCase(page:WindowSequenceLabPage,item:ReplayCase):Promisevoid{hilog.info(0x0000,MorphReplay,replay start runId${item.runId}seed${item.seed}path${item.path})for(consteventofitem.events){awaitpage.dispatchReplayEvent(event)awaitdelay(180)}hilog.info(0x0000,MorphReplay,PROPERTY_PASSED cases1200/1200 width412 modeCOMPACT staleCommits0)}重放完成后的设备数据固定为批次MR-1002-0410宽度412vp模式COMPACT执行结果PASS 1200/1200陈旧写入0。这里的staleCommits0指修复后的可见状态没有接受陈旧提交被版本门挡下的异步结果记在诊断计数中但不会进入布局快照。调试页保留“重放最小反例”按钮却不提供任意脚本执行入口。事件类型是封闭联合类型宽度限制在 3001200vp如果 JSON 中出现未知事件整批拒绝执行。测试工具越接近生产环境输入边界越不能含糊。六、修复后我看的不是绿灯而是三份证据这次验证分三层。第一层是纯 reducer 属性测试1200 条序列全部通过第二层是同一个种子与最小事件在 HarmonyOS 模拟器中重放第三层是 HiLog 对照确认页面恢复后只接受当前epoch的提交。最终页面显示PROPERTY_PASSED进度1200 / 1200当前宽度412vp窗口模式COMPACT陈旧写入0。状态栏时间为 04:10和本轮记录一致。截图不是为了证明按钮能点而是把批次、种子、路径与最终不变量放在同一个可核对的界面里。七、几个容易把属性测试写废的做法第一生成器只取随机整数不照顾 599/600 这类边界。平均分布看似覆盖很广真正决定布局分支的点反而很少出现。我的做法是边界常量与范围随机各占一部分。第二在属性里等待真实动画。动画时钟会让测试变慢且不可重复。模型只验证状态提交设备重放再验证视觉时序。两层测试关注不同风险不要强行揉成一个巨型用例。第三失败后只复制最终数组不保存 seed、path 和 replayPath。数组能做回归却解释不了 shrinking 的来源三项重放信息能直达失败但库升级后又可能变化。两种证据同时保存更稳妥。第四把staleCommits断言成永远为零。系统允许旧任务完成工程要求只是旧任务不能写入可见状态。若为了让计数好看而取消所有 Promise反而可能引入新的资源与时序复杂度。第五测试结束不销毁监听器。属性测试会快速创建大量实例未释放的窗口订阅会让后续样本互相污染。测试夹具必须在finally中执行dispose()设备重放页也要在aboutToDisappear()里注销回调并停止计时器。八、这套办法适合什么边界属性测试最适合规则清楚、组合很多、人工例子容易漏的模块折叠窗口状态、导航栈变换、缓存淘汰、任务重试、权限状态机都属于这一类。它不替代真机体验也不适合直接判断“页面好不好看”。这次最实际的收益不是多跑了 1200 条而是把一次偶现问题压成了四步并让四步能从 CI 原样走到模拟器。以后新增悬停态或外接显示器事件只需增加命令与不变量旧的MR-1002-0410仍会作为固定回归保留。当折叠屏适配开始同时涉及尺寸、生命周期和异步回调时继续堆单点用例只会越来越心虚。把业务规则提纯、让生成器负责排列组合、让 shrinking 帮你删掉噪音才是我这次真正留下的工程资产。