【口算王|19】HarmonyOS ArkTS 回归测试实战:覆盖启动、空数据、异常输入和重复点击
证据边界:本文依据 D:/huawei/one16-11 当前可读取源码与测试目录整理;本轮未执行 Hypium、构建、模拟器或真机回归。工程存在默认测试模板不等于业务用例已覆盖,文中矩阵属于建议实现。
回归测试不是把页面依次点一遍,也不是看到工程里存在src/test就认为质量已经有保障。真正有价值的回归测试必须固定前置数据、执行动作、观察结果和持久化副作用,并且能在版本变化后重复执行。对于口算王这类本地题库应用,最容易被忽略的故障恰好不在正常答题路径,而在首次启动、零记录、错误路由参数、连续点击和页面重新进入。
本文基于口算王项目D:\huawei\one16-11的真实源码,复核Index.ets、BankDetailPage.ets、CategoryPage.ets、SearchPage.ets、PracticePage.ets、ExamResultPage.ets、LearningStatsPage.ets、QuestionUtils.ets与StatService.ets。包名com.jiaweikang.one16是本文草稿核验使用的唯一标记。项目目标和兼容 SDK 均为 HarmonyOS 6.0 系列,本文方法适用于 HarmonyOS 5.0 及以上 Stage 模型应用。
先说明测试现状:工程包含 Hypium 测试目录,但entry/src/test/LocalUnit.test.ets仍是默认的字符串断言模板,没有覆盖题目评分、统计汇总、路由参数、页面空态或持久化。本文不会把这些测试写成“已经通过”,而是从现有代码提炼一套可以真正落地的回归矩阵,同时指出当前实现已经具备的保护和仍需补齐的风险。
一、先定义回归测试的可交付结果
一次回归不能只留下“正常”两个字。每条用例至少应记录五个字段:
- 前置数据,例如首次安装、已有错题或已有挑战历史;
- 入口与参数,例如从分类页进入,或直接传入非法
bankId; - 操作序列,例如连续点击两次答案或交卷;
- 可见结果,例如空态、按钮状态和页面栈;
- 数据结果,例如收藏数、答题记录和挑战历史只变化一次。
这样测试失败时才能定位是渲染、路由、业务计算还是 Preferences 写入出了问题。
二、现有测试目录不等于已有回归覆盖
当前LocalUnit.test.ets的核心断言是:
it('assertContain',0,()=>{leta='abc'let b = 'b'
expect(a).assertContain(b)expect(a).assertEqual(a)})
它只能证明 Hypium 模板能够运行,不能证明口算王的任何业务行为。测试报告中应把它标记为“框架冒烟用例”,而不是“核心功能测试”。第一轮建设应优先替换为纯逻辑边界测试,因为这类测试执行快、结果稳定,也最适合作为每次提交的门禁。
三、回归矩阵从四层建立
建议把用例分成四层:
- 纯逻辑层:评分、计时、统计和题目选择;
- 页面状态层:首次启动、空数据、无结果和错误参数;
- 交互层:重复点击、快速切页、弹窗开关和返回;
- 持久化层:写入、清空、重启后回读和重复进入。
纯逻辑层可以使用 Hypium 单元测试;页面与交互需要组件测试或真实设备操作;持久化必须跨页面甚至跨进程复核。不要用一类测试替代所有层。
四、启动回归要从干净状态开始
Index.ets用@StorageLink('currentTabIndex')恢复当前标签,并根据currentBreakpoint选择底部导航或侧边导航。启动测试至少覆盖:
- 全新安装后进入首页;
- 本地数据为空时五个标签均可切换;
- 上次停留在非首页标签时重新启动;
- phone、tablet、2in1 对应宽度下导航可见;
- 状态栏和底部安全区不会遮挡内容。
单纯执行hvigorw assembleHap只能证明编译成功,不能证明首帧、路由和数据初始化正常。发布候选包还应执行安装、启动、核心路径和卸载冒烟。
五、标签重复点击应保持状态稳定
当前标签点击只执行赋值:
.onClick(()=>{this.currentIndex=index})
重复点击同一个标签不会叠加路由,这是当前实现的优势,因为PageContent由状态条件渲染,而不是每次点击都pushUrl。回归用例仍应连续点击当前标签十次,观察页面是否重复初始化、滚动位置是否异常跳变,以及 StorageLink 是否产生无意义写入。
测试通过的判定不是“没有崩溃”,而是标签索引保持一个确定值,页面没有多份实例,返回键也不会出现多余层级。
六、零数据统计必须返回确定的零
StatService.summarize对空数组已有明确处理:
accuracyPercent:totalAnswered>0?Math.round(totalCorrect/totalAnswered*100): 0
recentAvgScore在历史为空时也返回 0。这里应补充最小单元测试:
it('emptyStatsReturnsZero',0,()=>{conststats=StatService.summarize([],[],[],[])expect(stats.totalAnswered).assertEqual(0)
expect(stats.accuracyPercent).assertEqual(0)expect(StatService.recentAvgScore([])).assertEqual(0)})
这条用例能防止后续重构时引入NaN、除零或空数组 reduce 异常。
七、统计页没有传统空白页,但有零值态
LearningStatsPage即使没有任何记录,也会展示概览卡片、三个数据卡片和所有题库进度。各项值为 0,每个题库显示“未开始”。这是一种合法的零值态,不必强行替换成整页插画。
回归时应检查:
0%不显示为NaN%;- 最近五次均分为空时显示 0;
- 已学习题库数为
0/题库总数; - 每个进度条比例为 0;
- 窄屏和宽屏布局都能完整显示。
零值态的价值在于用户仍能理解页面结构和下一步目标。
八、搜索页要区分“未搜索”和“无结果”
SearchPage用searched区分两个状态。空关键词且没有分类时会清空结果并保持searched = false;执行一个没有匹配项的关键词后,才进入“未找到相关内容”。
if(this.keyword.trim().length===0&&this.categoryType.length===0) {this.bankResults = []
this.questionResults=[]this.searched=falsereturn
}因此回归用例必须分别覆盖:初次进入、只输入空格、输入不存在关键词、清除分类、从分类页带参数进入。若把前两者都断言为“无结果”,就会误判现有产品设计。
九、异常搜索输入先验证边界而不是猜攻击面
搜索完全基于本地字符串匹配,没有 SQL、HTTP 或 Web 组件,所以不应虚构注入风险。真实需要验证的是:
- 超长文本是否挤压搜索栏;
- 中英文混合和特殊符号是否稳定;
- 前后空格是否被
trim; - 连续提交是否重复构造巨大数组;
- 分类名称被用户编辑后,分类过滤是否正确清除。
当前代码在输入内容偏离categoryName时会重置分类条件,方向正确。由于普通搜索限制最多 50 道题,长关键词也不会无限追加结果。
十、非法题库 ID 已有可见空态
BankDetailPage读取路由参数后调用getBankById。查不到题库时,bank保持undefined,页面展示“未找到题库”:
if(this.bank===undefined) {Image($r('app.media.img_empty_default'))Text('未找到题库')
}应测试缺少参数、空字符串、未知 ID 和合法 ID 四种情况。合法 ID 必须展示内容和底部动作;非法 ID 必须保持空态,且不能执行依赖this.bank!的随机练习或限时挑战。
当前空态分支不会渲染BottomActions,避免了空对象强制解包,这是可以保留的保护。
十一、空题目集合需要单独测试
PracticePage根据 mode、chapterId、questionType 和 startQuestionId 组装题目。普通模式在题目为空且没有questionType时会回退到整个题库;指定题型为空时则保留空集合。
页面只有在currentQ() !== undefined时渲染题目,但底部工具栏仍存在。回归时要确认:
- 空题集不会崩溃;
- 进度条比例为 0;
- 收藏和笔记操作不会写入数据;
- “下一题/完成”不会产生除零或错误路由;
- 用户有明确的退出路径。
当前实现具备空值判断,但没有专门的“暂无可练习题目”提示。这是测试应暴露的体验缺口,不能写成已经完善。
十二、评分函数已经防住总题数为零
QuestionUtils.calcScore的首行是:
if(total===0)return0应覆盖四组数据:0 题、全错、部分正确和全对。还要加入“记录数大于总题数”的异常输入,决定服务是按总题数计算还是先截断记录。当前实现会按全部正确记录除以 total,理论上可能得到超过 100 的分数。
正常 UI 不应产生这种数据,但回归测试的职责正是固定异常边界。更稳妥的服务契约是把结果限制到 0 到 100,并校验记录是否属于当前题集。
十三、时间格式也需要异常输入契约
formatTime把秒数直接拆成分钟和秒:
constm=Math.floor(sec/60)consts=sec%60return${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}
正常值 0、59、60、3599 都容易断言。负数、小数和极大值目前没有归一化,可能出现负号或小数秒。调用方通常提供递增整数,但公共工具方法仍应通过测试明确输入约束,避免未来被其他页面复用时产生模糊结果。
十四、答题选项已有重复记分保护
selectOption开头有两道门:
if(this.mode==='wrongAnalysis')returnif(this.showAnalysis)return第一次点击后立即设置showAnalysis = true,后续点击不会再次 push 答题记录,也不会重复添加错题。回归时可连续点击同一选项和不同选项,最终断言:
records只增加一条;wrongRecords最多增加一条;- 选中态和答案解析保持第一次结果;
- 自动下一题只调度一次。
这是“重复点击测试”最典型的成功判据:副作用只发生一次。
十五、自动下一题要防止延迟回调串题
开启自动下一题后,代码使用 800 毫秒延迟:
setTimeout(()=>{if(this.showAnalysis) {this.goNext()
}},800)如果用户在延迟期间手动点击“下一题”,goNext会先切换题目并把showAnalysis设为 false,因此回调到达后不会再次前进。这一保护值得加入回归用例:答题后立即手动下一题,等待一秒,索引只能增加一次。
还应在页面退出后观察延迟回调是否触发。当前代码没有保存并清理 timeout ID,后续可以把它纳入生命周期治理,但本文不把该改进描述为已实现。
十六、导航按钮目前缺少通用防抖
分类卡片、章节“开始”、随机练习、限时挑战和结果页按钮都直接调用router.pushUrl或replaceUrl,没有isNavigating状态。高频连续点击可能发起多次导航。
建议先写失败用例,再补一个最小门闩:
if(this.isNavigating)returnthis.isNavigating=truerouter.pushUrl(target).catch(() => {
this.isNavigating=false})页面返回或导航失败时必须恢复状态。测试要检查页面栈只增加一层,不能只看最终页面是否打开。
十七、交卷动作需要幂等语义
挑战倒计时到零会调用autoSubmitExam,最后一题按钮也可能调用goNext并进入结果页。两条路径都负责更新进度和替换路由。
当前autoSubmitExam会把timerId设为 -1,但没有独立的submitted标志。边界时刻若用户操作和计时回调非常接近,应验证是否可能执行两次进度更新或两次 replace。可靠判定应是:
- 一场挑战只生成一个结果页面;
- 进度只累计一次;
- 挑战历史只增加一次;
- 倒计时停止且不会继续回调。
这比给按钮加一个视觉 disabled 更重要,因为幂等必须落在业务动作上。
十八、结果页的异常 JSON 已有降级
ExamResultPage和错题解析都用 try/catch 解析 records。解析失败时数组保持为空,页面仍可展示结果。这条路径应传入空串、损坏 JSON、对象而非数组和超长记录。
不过JSON.parse成功不等于结构正确。若传入{},类型断言不会做运行时校验,后续读取records.length的行为可能偏离预期。建议增加显式数组判断:
constparsed:Object=JSON.parse(raw)this.records=Array.isArray(parsed)? parsed as AnswerRecord[]
:[]测试应验证错误数据被降级为空记录,而不是让类型断言掩盖运行时形状。
十九、结果页重复进入会重复追加历史
ExamResultPage.aboutToAppear每次执行都会调用:
this.examHistory=UserDataManager.addExamHistory(this.examHistory,this.bankId,
this.score,this.total,this.correct,
this.durationSec)这意味着页面重建、重复导航或某些生命周期重入可能追加相同挑战历史。回归时应记录进入前后的数组长度,重复进入同一结果参数后仍只能增加一条。
工程上可以给每次挑战生成唯一 sessionId,并在写入时去重;也可以把历史写入放在挑战提交服务中,结果页只负责展示。当前源码尚未实现这一点,属于明确的回归风险。
二十、结果页排名是静态展示,不应作为联网测试
结果页显示${236}/${1258},它不是服务端返回的真实排名。回归测试只能验证文字布局稳定,不能记录“在线排行榜请求成功”或“排名数据同步成功”。
同理,测试报告不能虚构 PV、点赞、收藏或云端账号数据。收藏是本机题目记录,挑战历史也是本地数据。测试对象必须与真实数据源一致。
若后续要接入真实榜单,应先定义请求失败、超时、无网络、匿名标识和隐私说明,再把静态占位替换为明确的加载、成功、空态和失败状态。在此之前,验收表应将这一格标为“静态展示”,避免测试人员把固定数字误判成已经具备联网能力。
二十一、持久化回归必须跨重启
收藏、错题、笔记、进度和挑战历史最终通过本地数据管理器保存。只在当前页面观察 StorageLink 变化,只能证明内存状态更新。
一条完整持久化用例应执行:
- 清空目标数据并重启;
- 完成一次明确操作;
- 记录界面和内存结果;
- 退出页面或结束应用;
- 重新启动并回到对应页面;
- 验证记录仍存在且数量准确;
- 执行删除,再次重启验证已消失。
这样才能发现写入失败被吞掉、键名不一致或清理不彻底等问题。
二十二、测试数据要可重复
随机练习使用洗牌,挑战题则按题库 ID 和当前 UTC 小时生成种子。同一小时内挑战题相对稳定,跨小时会变化。自动化测试如果直接断言某道固定题,可能在整点后失败。
可选策略包括:
- 单元测试直接传入固定 Question 数组;
- 把时间源封装为可注入依赖;
- 只断言数量、唯一性和题目属于输入集合;
- 记录测试开始时的小时边界,避免跨整点执行。
测试稳定不等于业务取消随机性,而是把随机和时间依赖控制在可验证边界内。
二十三、多设备回归不能只改窗口宽度
源码对宽度和currentBreakpoint都有判断。例如题库详情只有在currentBp === 'lg' && pageWidth >= 700时使用双列布局。统计页也会在宽屏切换网格。
回归矩阵至少覆盖 phone 竖屏、phone 横屏或小窗、tablet 宽屏和 2in1 调整窗口。检查重点是:
- 导航模式是否符合断点;
- 列表、卡片和底部动作是否可达;
- 文本是否截断或重叠;
- 弹窗和软键盘是否遮挡保存操作;
- 系统底部手势区是否保留安全距离。
只修改pageWidth而不更新断点,不能代表真实设备切换。
二十四、回归证据要能定位版本
每轮发布测试建议保存:
- 构建版本名和版本号;
- 包名与签名环境;
- 设备类型、系统版本和窗口尺寸;
- 用例编号、前置数据和执行时间;
- 实际结果、截图或日志;
- 失败是否可复现及对应源码提交。
日志不应包含用户笔记、账号凭据或私密信息。本文也不会输出或保存密码、Cookie、Token、短信码和证书密码。
二十五、第一批 Hypium 用例应从纯函数开始
最适合先落地的目标是QuestionUtils和StatService:
calcScore([], 0) === 0;- 全对、全错和部分正确得分;
formatTime(0/59/60/3599);- 空统计返回全零;
- 有进度时正确汇总;
- 空挑战历史平均分为 0;
- 最近五次平均只取约定范围。
这些用例不依赖 UIAbility、router 或 Preferences,失败定位最直接。待纯函数门禁稳定后,再增加组件和真实设备用例。
二十六、页面测试应围绕状态转换断言
页面测试不要依赖“截图看起来差不多”。更可靠的是断言状态转换:
| 场景 | 初始状态 | 动作 | 期望状态 |
|---|---|---|---|
| 空搜索 | searched=false | 提交空格 | 仍为引导态 |
| 无匹配 | searched=false | 搜索不存在词 | 无结果态 |
| 非法题库 | bank=undefined | 传未知 ID | 未找到题库 |
| 重复答题 | records=0 | 连点两次 | records=1 |
| 手动抢自动下一题 | index=0 | 答题后立即下一题 | 最终 index=1 |
| 重复交卷 | submitted=false | 连点或超时竞争 | 只写一次 |
状态断言与可见文案结合,才能同时覆盖业务和体验。
二十七、发布前的最小回归清单
在每个候选包上至少执行:
- 干净安装并正常启动;
- 五个主标签切换与系统返回;
- 搜索初始态、无结果和分类结果;
- 非法题库 ID 空态;
- 正常答题、答错、重复点击和自动下一题;
- 空错题、空收藏、空历史和零统计;
- 挑战正常交卷、倒计时交卷和结果回看;
- 收藏、笔记、错题、进度在重启后仍一致;
- phone、tablet、2in1 关键布局;
- 卸载后不依赖第三方工具清理。
没有执行的检查必须标记为“未运行”,不能写成通过。
二十八、当前源码的通过项与风险项
从代码静态复核可以确认:
- 统计和评分对零分母有保护;
- 搜索区分初始态与无结果态;
- 非法题库 ID 有可见空态;
- 选项重复点击不会重复记分;
- 自动下一题前检查当前解析状态;
- JSON 解析错误有基础降级。
仍需通过实现或运行测试验证:
- Hypium 业务用例尚未建立;
- 空题集缺少专门提示;
- 通用导航入口没有防抖;
- 交卷竞争缺少独立幂等标志;
- 结果页重复出现可能重复写历史;
- JSON 成功解析后缺少结构校验;
- 持久化写入需要跨重启证据。
二十九、结语
回归测试的核心不是用例数量,而是把一次用户动作和一次状态变化对应起来。对口算王而言,最值得优先锁定的契约是:空数据必须可解释,坏参数必须可退出,重复答题只能记录一次,交卷只能落库一次,写入后重启仍能回读。
从纯函数 Hypium 用例开始,再补页面状态、导航防抖和跨重启持久化测试,能够以较小成本建立真正可重复的发布门禁。测试报告只记录实际运行证据,静态代码复核、模拟器验证、真机验证和 AppGallery 检查应分别标注,不能相互冒充。
本文部分内容由 AI 辅助整理,所有源码事实、测试边界和结论均以口算王项目实际代码复核为准。
三十、把失败证据也纳入交付
回归报告不能只记录通过项。失败用例应保存用例 ID、前置数据、实际结果、预期结果、首个错误位置和是否阻塞发布。环境、签名或设备不可用导致的未执行,需要单独标为not run,不能算通过或失败。只有修复后重新执行同一操作序列,并得到一致结果,才能关闭问题。
自动化测试适合纯逻辑、存储适配和稳定页面状态;真机冒烟仍需覆盖安装、首次启动、核心答题、退出重启和卸载。两类证据不能相互替代。一个单元测试通过不证明页面布局可用,一次人工点击正常也不证明边界算法长期稳定。
三十一、测试数据必须可恢复
每条用例开始前重置到明确数据快照,结束后核对并恢复。空数据用例不能依赖上一轮是否刚好清理过;重复点击用例要在动作前后读取记录数量和具体身份键;重启恢复用例必须真正重新初始化存储服务,而不是只刷新组件状态。测试若修改共享开发数据,应使用隔离命名空间或专用配置,避免污染真实草稿。
本文本轮没有执行 Hypium、构建、模拟器或真机回归。文章中的矩阵是根据源码风险设计的验收方案,当前只能确认默认模板未覆盖业务逻辑,不能声称任何新增用例已经通过。
AI 辅助声明
本文在人工复核口算王 ArkTS 源码与测试目录后,使用 AI 辅助整理结构、润色表达并生成配图;未执行的 Hypium、构建、模拟器和真机测试均未写成已通过。