HarmonyOS 应用实战 38业务测试别依赖页面点击把题库、导入和抽取服务做成可验证闭环“新建题库、导入几条答案、点一次抽取”在真机上都正常并不代表题库业务可靠。在《答案之书》这类本地优先应用里最容易漏掉的恰好是用户不愿意反复手点的场景导入文本里混了空行和重复项、题库只有一条答案、当前题库刚被删除、连续抽取却总是得到同一条。它们最后会显示为某个页面没刷新、某次动画后没有结果但故障的归属通常不在 ArkUI 组件而在导入、保存、抽取和当前题库回退这条业务链没有被独立验证。本文给出一套可以接入 HarmonyOS 工程的服务级测试写法用内存仓储隔离 Preferences用可控随机源消除偶然性用小型运行期信号替身检查刷新语义。重点不是把测试页面做得多漂亮而是让同一份规则能脱离页面点击得到确定结论。本文要解决的不是“怎么点自动化”页面测试与服务测试回答的问题不同。前者适合确认“导入弹层能否打开”“按钮是否可点击”“结果是否展示”后者应该确认“重复答案是否真的不会落库”“失败时是否没有写入”“连续抽取是否遵守排除规则”。把两者混在一起排障时只能看到按钮点过了却不知道规则在哪一层失效。这篇文章会完成下面四件事为题库读写定义一个足够小的DeckRepository契约让导入、保存和抽取依赖契约而不是依赖页面和真实 Preferences用确定的随机源覆盖“连续不重复”这一类容易偶发的规则用用例验证成功、拒绝、回退和刷新四种结果。先划清四个 owner测试才不会测偏一个常见反模式是在Button.onClick里完成文本切分、数组去重、Preferences 写入、State更新和随机抽取。这样第一次看起来很快后续从剪贴板导入、外部 Want 拉起或恢复入口接入同一能力时就会出现多条规则链。更稳的边界如下层级负责什么测试要观察什么ArkUI 页面收集输入、调用服务、展示成功或错误不直接断言持久化细节DeckWorkflow导入清洗、最小数量校验、保存、抽取、删除后的回退输入和业务结果DeckRepository保存、读取、删除题库是否产生正确的数据变化刷新信号告诉订阅页面重新读取成功时一次通知、失败时零通知页面仍然需要少量交互测试但不应成为业务规则的唯一证明。尤其是“保存失败后列表仍显示成功”“抽取页正好抽到相同答案”这类问题靠反复手点不仅慢而且很难稳定复现。第一层把真实存储藏在一个小接口后面不要为了测试再造一套题库模型。生产仓储和测试仓储应共享领域模型只替换 I/O。下面的模型刻意只保留本文需要的字段项目已有Deck、Answer或保存载荷时应直接复用已有模型而不是并行维护两份定义。exportinterfaceAnswerItem{id:string;text:string;}exportinterfaceDeck{id:string;name:string;answers:AnswerItem[];updatedAt:number;}exportinterfaceDeckRepository{findById(deckId:string):PromiseDeck|undefined;save(deck:Deck):Promisevoid;remove(deckId:string):Promisevoid;list():PromiseDeck[];}这个接口的边界很重要它只描述稳定数据的读写没有State、弹层开关、Toast 文案或动画阶段。生产实现可以在内部使用 Preferences 或已有 Repository测试实现只需要满足同一个契约。这样用例失败时先能确定是业务规则退化而不是设备存储、页面生命周期或动画时序造成的噪声。第二层内存仓储必须复制数据不能只存引用很多“内存替身”写成map.set(deck.id, deck)就结束了。它会带来一个隐蔽问题服务保存后如果又修改了原数组测试仓储中的对象也会跟着变测试误以为仓储正确保存了新状态。真实持久化通常不会共享这个引用。下面的实现通过cloneDeck模拟读写边界并额外记录写入次数方便验证一次业务提交没有产生多次保存。functioncloneDeck(deck:Deck):Deck{return{id:deck.id,name:deck.name,updatedAt:deck.updatedAt,answers:deck.answers.map((item:AnswerItem)({id:item.id,text:item.text}))};}exportclassMemoryDeckRepositoryimplementsDeckRepository{privatereadonlydecks:Mapstring,DecknewMap();saveCount:number0;asyncfindById(deckId:string):PromiseDeck|undefined{constdeck:Deck|undefinedthis.decks.get(deckId);returndeck?cloneDeck(deck):undefined;}asyncsave(deck:Deck):Promisevoid{this.decks.set(deck.id,cloneDeck(deck));this.saveCount1;}asyncremove(deckId:string):Promisevoid{this.decks.delete(deckId);}asynclist():PromiseDeck[]{returnArray.from(this.decks.values()).map((deck:Deck)cloneDeck(deck));}}这里没有模拟 Preferences 的全部行为也不需要模拟。测试的目标是验证题库规则如果要验证 Preferences 的迁移、加密或损坏恢复再为真实存储实现补一组集成测试。把这两类测试分开失败原因才清楚。导入规则要返回结果而不是让页面猜发生了什么导入文本最忌讳“解析失败就抛一个模糊异常页面自己重新算一遍原因”。服务返回可解释的结果页面才能展示“忽略了 2 条空行、合并了 1 条重复项”测试也能直接断言规则。exportinterfaceImportPreview{accepted:string[];blankCount:number;duplicateCount:number;}exportfunctionparseAnswers(rawText:string):ImportPreview{constunique:SetstringnewSet();letblankCount:number0;letduplicateCount:number0;rawText.split(/\r?\n/).forEach((line:string){constvalue:stringline.trim();if(!value){blankCount1;return;}if(unique.has(value)){duplicateCount1;return;}unique.add(value);});return{accepted:Array.from(unique),blankCount,duplicateCount};}如果工程已有DeckImport或类似解析工具应该让测试直接调用它而不是用这段示例替代生产规则。上面的代码说明的是断言形状既要检查最终答案也要检查被忽略数据的原因否则将来规则改坏时测试只能告诉你“数量不对”。保存失败必须保证“没有半次提交”以“至少两条答案才允许保存”为例关键并非抛错本身而是失败后仓储没有写入、刷新信号没有发出。否则页面虽然提示失败其他页面却可能已经根据错误信号重载。exportinterfaceRefreshPort{publishDeckChanged(deckId:string):void;}exportclassMemoryRefreshPortimplementsRefreshPort{readonlychangedDeckIds:string[][];publishDeckChanged(deckId:string):void{this.changedDeckIds.push(deckId);}}exportclassDeckWorkflow{constructor(privatereadonlyrepository:DeckRepository,privatereadonlyrefreshPort:RefreshPort){}asynccreateFromText(deckId:string,name:string,rawText:string):PromiseDeck{constpreview:ImportPreviewparseAnswers(rawText);if(preview.accepted.length2){thrownewError(题库至少需要两条有效答案);}constdeck:Deck{id:deckId,name:name.trim(),updatedAt:Date.now(),answers:preview.accepted.map((text:string,index:number)({id:${deckId}-${index},text}))};awaitthis.repository.save(deck);this.refreshPort.publishDeckChanged(deck.id);returndeck;}}在生产代码中RefreshPort可以映射为项目已有的AppStorage键或事件分发器它不应把整份Deck放进全局状态。订阅页面收到“题库变更”后重新从仓储读取冷启动与跨入口也会回到同一份事实。随机规则要注入别让测试和运气赌一把“连续两次不抽到同一条”的测试如果直接调用Math.random()就可能今天通过、明天偶发失败。解决办法不是重跑而是把随机源作为依赖传入服务。生产使用系统随机测试使用按顺序返回固定索引的实现。exportinterfaceIndexSource{next(maxExclusive:number):number;}exportclassSequenceIndexSourceimplementsIndexSource{privatecursor:number0;constructor(privatereadonlyvalues:number[]){}next(maxExclusive:number):number{constvalue:numberthis.values[this.cursor]??0;this.cursor1;returnvalue%maxExclusive;}}exportfunctionpickNextIndex(count:number,previousIndex:number|undefined,source:IndexSource):number{if(count1){thrownewError(题库没有可抽取的答案);}if(count1){return0;}if(previousIndexundefined){returnsource.next(count);}constcandidate:numbersource.next(count-1);returncandidatepreviousIndex?candidate1:candidate;}这段逻辑的前提是source.next(count - 1)返回[0, count - 2]范围内的索引再跳过上次位置。只有一条答案时直接返回0不把“不能避免重复”误报为错误。实际项目若已经有随机工具应优先在既有工具外增加可注入入口避免在页面和服务里各实现一套抽取算法。删除当前题库时回退规则也应在服务内删除题库后最容易被遗漏的是“当前题库 id”。若页面先删数据、再把本地State清空其他页面或下次启动仍可能拿着已删除 id 继续读。服务应该在删除后计算一个明确回退值并将它交给专门管理当前题库状态的端口。exportinterfaceCurrentDeckPort{getCurrentDeckId():string|undefined;setCurrentDeckId(deckId:string|undefined):void;}exportasyncfunctionremoveDeckAndFallback(deckId:string,repository:DeckRepository,currentDeck:CurrentDeckPort,refreshPort:RefreshPort):Promisevoid{awaitrepository.remove(deckId);if(currentDeck.getCurrentDeckId()deckId){constremaining:Deck[]awaitrepository.list();currentDeck.setCurrentDeckId(remaining[0]?.id);}refreshPort.publishDeckChanged(deckId);}回退到第一本题库、最近使用题库还是空状态是产品决策但无论采用哪一种都应该写成服务层可断言的规则。页面只根据结果切换展示不能自己决定另一份“默认题库”。用例要覆盖四种结果而不是只跑成功路径以下示例不绑定某个测试框架。接入 HarmonyOS 工程时可以放进现有 LocalUnit/ohosTest 的断言结构中关键是每个用例建立自己的内存仓储绝不读取真实用户题库。asyncfunctionexpectReject(task:()Promisevoid,message:string):Promisevoid{try{awaittask();thrownewError(预期失败${message});}catch(error){if((errorasError).message.startsWith(预期失败)){throwerror;}}}exportasyncfunctionverifyCreateRules():Promisevoid{constrepository:MemoryDeckRepositorynewMemoryDeckRepository();constrefresh:MemoryRefreshPortnewMemoryRefreshPort();constworkflow:DeckWorkflownewDeckWorkflow(repository,refresh);awaitexpectReject(async(){awaitworkflow.createFromText(deck-a,晨间问题,只有一条);},少于两条答案不能保存);if(repository.saveCount!0||refresh.changedDeckIds.length!0){thrownewError(拒绝输入不应产生写入或刷新);}constdeck:Deckawaitworkflow.createFromText(deck-a,晨间问题,喝水\n\n散步\n喝水\n);if(deck.answers.length!2||repository.saveCount!1){thrownewError(导入去重或单次保存规则失效);}if(refresh.changedDeckIds.join(,)!deck-a){thrownewError(成功保存应只发布一次题库刷新);}}接着为抽取补一条确定断言。这里的SequenceIndexSource([0, 0])使两次候选都从固定位置开始若跳过上一次位置的逻辑丢失第二次就会暴露问题。exportfunctionverifyNoImmediateRepeat():void{constrandom:SequenceIndexSourcenewSequenceIndexSource([0,0]);constfirst:numberpickNextIndex(3,undefined,random);constsecond:numberpickNextIndex(3,first,random);if(firstsecond){thrownewError(多条答案的连续抽取不应返回相同索引);}}最后还应覆盖删除当前题库准备两本题库把当前 id 设为第一本删除后断言当前 id 已变为第二本再删除最后一本断言当前 id 是undefined。这条用例能防止“页面退回首页了但持久化的当前 id 仍指向不存在题库”的恢复故障。验证顺序先服务后页面最后真机恢复推荐按下面顺序收口前一步失败就不要急着调 UI在 LocalUnit 或 ohosTest 中执行导入去重、最小答案数、连续抽取和删除回退用例确认每个用例使用MemoryDeckRepository不会写入真机用户数据在页面侧只验证输入、错误展示和刷新后的列表不重复实现业务判断安装到真机或模拟器后执行“导入 → 抽取 → 删除当前题库 → 退出应用 → 冷启动”的恢复路径最后检查日志仅记录题库 id、数量、阶段和错误码不记录用户问题与答案全文。第 4 步属于运行时验证不能被单元测试替代反过来真机手点也不能替代前两步的规则验证。两者一起做才能把服务正确性和页面接线正确性分别证实。常见失败现象与定位方式现象优先检查典型修复页面可保存但测试说答案数不对页面是否自行过滤、服务是否复用了同一解析函数将清洗规则收回导入服务页面只展示预览用例偶发失败是否直接使用了Math.random()或系统时间注入固定索引源只断言时间的相对关系测试后真机题库被污染用例是否拿到了生产 Repository测试组装时只传入内存仓储禁止访问真实 Preferences保存失败后列表仍刷新刷新信号是否写在校验之前所有校验通过、仓储写入成功后再发布信号删除后冷启动白屏当前题库 id 是否仍指向已删除数据在删除服务中计算回退值并持久化当前选择连续抽取仍重复排除索引逻辑没有覆盖两条以上答案为 1 条、2 条、3 条答案分别写固定随机源断言不需要把轻量项目变成大框架这套方案新增的不是一层“测试专用架构”而是三个已经值得存在的边界仓储契约、随机源、刷新端口。它们让服务依赖可替换能力而不是依赖某个页面是否已创建。对本地题库应用而言这比引入复杂的容器或为了测试重写所有页面更合适。当规则只涉及纯文本清洗时直接测试纯函数即可当规则涉及保存、当前题库与刷新时再使用内存仓储组合测试只有需要确认 Preferences、生命周期、路由或设备权限时才上升为真机集成测试。测试层级越贴近问题失败信息越有价值。小结业务测试的目标不是证明某个按钮出现过而是证明同样输入经过导入、保存、抽取、删除和恢复后得到同样的业务结论。把题库 I/O 隔离在DeckRepository后用内存仓储验证写入把随机源注入后用固定序列验证抽取把刷新留在成功提交之后就能稳定覆盖最容易被页面点击掩盖的边界条件。页面因此更薄服务规则更集中下一次从新入口接入题库能力时也不必重新猜一遍该走哪条链路。
网站建设
高端定制
企业官网