
HarmonyOS 应用的回归测试不能只验证"首页能打开"。真实故障往往出现在启动数据损坏、路由参数缺失、空题库、考试记录 JSON 非法、重复点击交卷、计时器离开页面后仍执行、窗口切换导致布局分支失效等边界。尤其是本地题库应用,主流程不依赖网络,稳定性重点更集中在状态初始化、页面路由、持久化、交互幂等和多设备布局上。
中国方言题库工程已经包含 Hypium 的 src/test 与 src/ohosTest 目录,但现有测试仍是模板示例:只断言字符串 abc 包含 b,没有覆盖题库、练习、收藏、进度、考试和页面路由。业务源码本身则提供了不少可测试边界,例如题库不存在时显示"未找到题库"、错题为空时显示空状态、JSON 解析失败后回退空数组、选项重复点击被 showAnalysis 拦截、启动页离开时清理定时器。文章会严格区分"源码已有防护"和"测试已经验证":没有真实测试用例的地方不会写成已通过。
本文面向 HarmonyOS 5.0 及以上版本,基于启动页、题库详情、分类页、练习页、考试结果页、主框架和现有 Hypium 脚手架,设计一套可落地的回归测试矩阵。本文唯一核验标记:先验证状态边界,再验证页面结果,最后验证重复操作不会产生第二次副作用。
一、先审计现有测试,而不是假设测试存在
工程包含:
text
entry/src/test/LocalUnit.test.ets
entry/src/test/List.test.ets
entry/src/ohosTest/ets/test/Ability.test.ets
entry/src/ohosTest/ets/test/List.test.ets
librarya/src/test/...
librarya/src/ohosTest/...
libraryb/src/test/...
libraryb/src/ohosTest/...
oh-package.json5 也声明了:
json
"devDependencies": {
"@ohos/hypium": "1.0.25",
"@ohos/hamock": "1.0.0"
}
说明工程具备编写本地单元测试与设备测试的基础。但 entry 当前唯一示例是:
ts
it('assertContain', 0, () => {
let a = 'abc'
let b = 'b'
expect(a).assertContain(b)
expect(a).assertEqual(a)
})
ohosTest 的 Ability 示例也是相同断言,只多了一条 hilog。它只能证明测试模板可以被组织,不能证明任何业务回归已经覆盖。
二、回归测试应该按风险链路组织
对当前应用,最有价值的链路是:
text
Ability 启动
-> 初始化 Preferences 与 AppStorage
-> 启动页计时跳转
-> Index 选择 Tab
-> 题库详情加载
-> PracticePage 组题和答题
-> 结果页保存考试历史
-> 设置页清除本地数据
测试不应按"每个文件写一个用例"机械分配,而应围绕用户可感知结果和数据副作用展开。一次回归至少要覆盖正常路径、空状态、异常输入和重复操作。

三、启动链路需要验证什么
EntryAbility.onCreate() 会执行三类初始化:
text
设置浅色模式
UserDataManager.init(context)
初始化 AppStorage 的 Tab 和安全区状态
注册 BreakpointSystem
onWindowStageCreate() 获取主窗口、监听避让区变化,并加载:
ts
windowStage.loadContent('pages/SplashPage', ...)
启动回归至少要检查:
text
Preferences 为空时能启动
Preferences 包含合法历史数据时能恢复
某个 JSON 键损坏时不会白屏
无法读取避让区时使用 0 回退
SplashPage 能在约 2 秒后 replace 到 Index
离开 SplashPage 后计时器被清理
重复创建/销毁 Ability 不残留媒体查询监听
当前 UserDataManager.init() 用一个大 try/catch 包住全部键读取。任意一个 JSON 解析失败,catch 会把所有共享数据恢复为默认值。这保证启动不崩溃,但可能让本次运行丢失其他仍然完好的数据。测试应明确验证这个现状,并推动后续按键隔离解析。
四、SplashPage 的定时器回归
启动页在 aboutToAppear() 中调用:
ts
this.timerId = setTimeout(() => {
router.replaceUrl({ url: 'pages/Index' })
}, 2000)
在 aboutToDisappear() 中:
ts
if (this.timerId !== -1) clearTimeout(this.timerId)
需要覆盖两个场景。
第一,正常停留:
text
进入 SplashPage
等待计时完成
断言只发生一次 replaceUrl
目标为 pages/Index
第二,提前离开:
text
进入 SplashPage
在 2 秒前触发 disappear
推进虚拟时间
断言不再发生 replaceUrl
如果测试框架难以替换全局定时器,可以把"延时跳转"封装到可注入的导航/调度服务,再做纯单元测试;设备测试只保留一条端到端验证。
五、题库详情页的空输入
BankDetailContent 从固定 ID 或路由参数加载题库:
ts
const params = router.getParams()
if (params && params.bankId) {
this.bank = getBankById(params.bankId)
}
当 bank 为 undefined 时,页面明确渲染:
text
空状态图片
"未找到题库"
应至少覆盖:
text
params 为 undefined
params 不含 bankId
bankId 为空字符串
bankId 为不存在的值
fixedBankId 合法
fixedBankId 不合法
测试结果不是"代码没有抛异常"就结束,还要断言 TopBar 仍存在、空状态文本可见、底部随机练习与模拟考试按钮不应在无题库时出现。
六、HakkaBankPage 的复用回归
HakkaBankPage 只有一层包装:
ts
BankDetailContent({ fixedBankId: 'b_hakka' })
它的风险不在复杂逻辑,而在"固定入口是否真的映射到正确题库"。测试应断言:
text
进入 HakkaBankPage
显示客家话题库名称
章节来自 b_hakka
点击章节传递 bankId=b_hakka
随机练习和模拟考试也携带 b_hakka
这类地区专属入口适合做参数化测试,避免每个页面复制一套测试代码。
七、分类页需要做边界宽度回归
CategoryPage.itemWidth() 的规则是:
ts
return this.pageWidth >= 720 ? '24%' : '48%'
测试应覆盖精确边界:
text
719vp -> 48%
720vp -> 24%
721vp -> 24%
同时模拟 onAreaChange 的 newArea.width:
text
0 或负值不更新 pageWidth
合法数字更新
无法转成数字时不应破坏已有宽度
现有代码只判断 width > 0。Number('abc') 会得到 NaN,条件为 false,可以保留旧值。测试应固定这一回退行为,防止后续重构直接赋值导致卡片布局异常。
八、Index 的布局分支必须纳入回归
当前 Index 的条件是:
ts
if (currentBp === 'sm' ||
currentBp === 'md' ||
currentBp === 'lg') {
// 底部导航
} else {
// 侧边导航
}
由于合法断点只有 sm/md/lg,侧边导航不可达。回归测试应把产品预期写成断言,例如:
text
sm 显示底部导航
md 显示底部或侧边导航(按产品策略)
lg 显示侧边导航
当前代码执行"lg 显示侧边导航"会失败,这是一条有价值的红灯测试。回归测试不是为了全部变绿而回避问题,而是先把预期固定,再修正实现。
九、空收藏、空笔记和空错题
FavoritePage 对三类数据分别判断:
ts
favRecords.length === 0
noteRecords.length === 0
wrongRecords.length === 0
错题空状态文案是:
text
暂无错题记录
答错的题会自动收录到这里
测试应分别清空 AppStorage 三个数组,然后切换对应 Tab,断言:
text
空状态图片存在
标题与说明匹配当前 Tab
列表项为 0
没有访问 records[0] 导致异常
再写一组从空到有、从有到空的状态转换:收藏一道题后列表出现,取消收藏后恢复空状态;保存一条笔记后显示,保存空白笔记后删除;错题答对后记录移除。
十、PracticePage 的无题目回退
练习页根据 mode 和路由参数组题:
text
wrongAnalysis -> 从 records 找错题
wrong -> 从 wrongRecords 找题
chapter -> 按章节取题
exam -> 取最多 20 题
random -> 洗牌
如果非错题解析模式下组题结果为空,会回退:
ts
if (this.questions.length === 0 &&
this.mode !== 'wrongAnalysis') {
this.questions = getQuestions(params.bankId)
}
需要验证:
text
不存在章节但题库有效 -> 回退整库
错题记录引用已删除题目 -> 过滤无效记录
bankId 不存在 -> questions 仍为空且页面不崩溃
wrongAnalysis 无错题 -> 显示 0/0 并可返回
exam 题库不足 20 题 -> 不越界
空题库下不能触发 currentQ()! 的强制非空路径。测试应点击底部工具栏、答题卡和下一题,确认所有动作都有空值保护。
十一、异常 JSON 的回归
练习页错题解析:
ts
try {
examRecords = JSON.parse(params.records)
} catch (_) {
examRecords = []
}
考试结果页也在解析 records 时使用 try/catch,失败后保留空数组。
应使用一组异常输入:
text
undefined
空字符串
普通文本
不完整 JSON
{}
null
[1,2,3]
缺少 questionId 的对象数组
selected 类型错误
当前实现只防止语法解析异常,没有运行时结构校验。[1,2,3] 是合法 JSON,但后续读取 record.correct、record.questionId 时语义不成立。测试会暴露这个边界,推动增加类型守卫或 normalizer。
十二、选项重复点击已有一层幂等保护
selectOption() 开头是:
ts
if (this.mode === 'wrongAnalysis') return
if (this.showAnalysis) return
第一次选择后立即:
ts
this.selectedKey = key
this.showAnalysis = true
this.records.push(...)
因此连续点击同一选项或不同选项,第二次调用会因 showAnalysis 返回,不会重复写入答题记录。这条保护值得用单元测试固定:
text
初始 records 长度 0
调用 selectOption('A')
再调用 selectOption('B')
records 长度仍为 1
selectedKey 保持 A
错题记录最多新增一次
文章不能说它已经通过,因为现有 Hypium 测试没有该用例;只能说源码存在可验证的幂等条件。
十三、自动下一题与手动点击的竞态
当设置 autoNextQuestion=true 且不是考试模式,选项提交后会启动延时:
ts
setTimeout(() => {
if (this.showAnalysis) {
this.goNext()
}
}, ...)
在延时到达前,用户仍可能手动点击"下一题"。手动 goNext() 会把 showAnalysis 设为 false,因此延时回调检查后通常不会再前进一次。
测试应模拟:
text
答题触发自动下一题
延时到达前手动下一题
推进时间
currentIdx 只增加 1
还要测试用户切到答题卡中的另一题后,旧延时是否会错误前进。仅检查 showAnalysis 可能不足以绑定"这是哪一道题的定时任务",更稳的方式是记录题目 ID 或取消旧 timer。
十四、最后一题重复点击是高风险路径
底部"完成/交卷"按钮满足条件时直接调用 goNext()。最后一题中,goNext() 会:
text
计算 correctCount
更新题库进度
更新章节进度
replace 到结果页或 router.back
当前没有 isSubmitting 或 isNavigating 标志。如果用户在路由切换完成前快速点击两次,第二次可能再次更新进度。是否能在真实设备上稳定复现取决于 UI 事件和路由时序,但代码没有显式幂等保证。
回归测试应构造:
text
currentIdx 位于最后一题
showAnalysis=true
快速触发两次完成
UserDataManager.updateProgress 只调用一次
replaceUrl 或 back 只调用一次
如果测试失败,最小修复是:
ts
if (this.isSubmitting) return
this.isSubmitting = true
然后在失败回退路径恢复状态,按钮同时显示禁用视觉。
十五、考试超时与手动交卷竞态
计时器每秒执行:
ts
if (mode === 'exam' && examRemainSec() <= 0) {
autoSubmitExam()
}
autoSubmitExam() 首先清除 interval,并把 timerId 设为 -1,这能避免下一秒再次自动提交。但超时瞬间用户也可能手动点击交卷,两条路径都可能更新进度并跳转。
测试应让剩余时间为 0,同时触发:
text
计时器回调
手动交卷点击
然后断言进度、考试历史和导航各发生一次。自动提交与手动提交最好最终汇聚到同一个带幂等锁的 submitExam()。
十六、ExamResultPage 的重复出现
结果页 aboutToAppear() 每次都会执行:
ts
this.examHistory = UserDataManager.addExamHistory(...)
正常流程通过 replaceUrl 进入一次,通常只追加一条。但如果页面因为生命周期重入、导航恢复或测试重复调用 aboutToAppear(),可能重复保存同一场考试。
测试可以直接调用两次初始化逻辑,检查历史记录是否增加两条。若产品预期一场考试只保存一次,需要给考试结果携带唯一 attemptId,或在提交考试时保存历史,结果页只负责展示。
十七、答题卡索引需要防越界
SheetDot(idx) 点击时直接:
ts
this.currentIdx = idx
const record = this.records.find(
r => r.questionId === this.questions[idx].id
)
正常 UI 的 idx 来自 questions 遍历,因此范围合法。但单元测试和后续重构可以验证:
text
idx=0
idx=questions.length-1
idx=-1
idx=questions.length
questions 在弹层打开后被替换为空
当前负数或越界会访问 undefined.id。这不一定能由正常 UI 触发,却是组件方法缺乏边界保护的证据。可在方法开头增加:
ts
if (idx < 0 || idx >= this.questions.length) return
十八、收藏按钮为什么要测偶数次点击
收藏使用 toggleFavorite(),每次点击在存在与不存在之间切换。快速双击的结果会回到初始状态:
text
未收藏 -> 收藏 -> 未收藏
从纯逻辑看是正确的,但用户可能认为自己只点击了一次。测试重点包括:
text
单击后收藏记录增加 1
再单击后删除
连续奇数次和偶数次的最终状态
每次持久化结果与 AppStorage 相同
同一题不会出现重复记录
如果产品需要防误双击,可增加短时间防抖;如果允许快速切换,则测试应固定最终幂等结果。
十九、设置页删除操作的重复点击
清除全部学习数据使用两次点击确认:
text
第一次 -> pendingClear=true,只提示
第二次 -> 执行清除
测试应覆盖:
text
第一次不删除
点击其他设置后 pendingClear 被取消
第二次删除所有目标数组
第三次点击重新进入确认,而不是再次清除
持久化失败时不能错误显示成功
当前持久化异常被静默吞掉,最后一项需要先改造结果返回才能可靠验证。
二十、纯函数优先做本地单元测试
适合 src/test 的逻辑包括:
text
QuestionUtils.formatTime
QuestionUtils.calcScore
QuestionUtils.correctCount
题库筛选与排序
进度计算
分类宽度阈值
答题记录 normalizer
重复提交状态机
这些测试不依赖 UIAbility、窗口或真实 Preferences,运行快,适合每次提交执行。
UserDataManager 当前把 Preferences 静态对象和业务更新放在同一个类中,测试时不容易替换存储。可以把"计算新数组"拆成纯函数,把落盘封装成 repository,再分别测试。
二十一、页面与设备行为放进 ohosTest
适合 ohosTest 的路径包括:
text
应用启动到 Index
Tab 切换
题库详情空状态
练习选项点击与解析显示
收藏、笔记和错题跨页面同步
考试完成到结果页
设置页清除数据
安全区和窗口尺寸变化
返回导航
设备测试数量不需要覆盖所有数据组合。把复杂数据排列留给单元测试,ohosTest 重点验证 ArkUI 渲染、路由、生命周期和系统交互。

二十二、测试数据必须可重复
回归测试最怕依赖上一次执行残留。每个用例前应建立明确状态:
text
清空或使用独立 Preferences 测试库
写入固定收藏、错题和进度
固定当前时间或注入 Clock
固定随机题序或注入 Random
重置 AppStorage
清理计时器、TTS 引擎和路由桩
考试题目按小时轮换,pickHourlyExamQuestions() 可能依赖时间。若不固定时钟,同一测试在整点前后会得到不同题序。应把当前时间作为参数或服务注入。
二十三、建立四层回归矩阵
启动层
text
首次安装
有历史数据
损坏数据
Ability 重建
窗口避让区异常
数据层
text
空数组
单条记录
重复记录
非法 JSON
结构合法但字段错误
持久化失败
页面层
text
无路由参数
无效 bankId
不存在章节
空错题
结果记录为空
长文本与小窗口
交互层
text
选项连点
自动与手动下一题竞态
最后一题重复完成
超时与手动交卷竞态
收藏双击
删除确认重复点击
二十四、发布前最小烟测
自动化用例之外,release 包至少完成一次:
text
安装
冷启动
进入首页
切换五个 Tab
进入题库详情
完成一组练习
完成一次模拟考试
查看收藏、错题和统计
退出并重启验证恢复
清除学习数据
横竖屏或调整窗口
卸载
要记录真实结果,不应因为代码里有空状态和 try/catch 就推断运行稳定。本文没有执行设备安装与 UI 自动化,因此只提供基于源码的测试设计,不报告任何未运行的通过结果。
二十五、测试命名要描述行为
不要继续使用 assertContain 这类模板名。建议:
ts
it('loadsDefaultStateWhenPreferencesIsEmpty', 0, ...)
it('showsEmptyStateForUnknownBankId', 0, ...)
it('ignoresSecondOptionTapAfterAnswer', 0, ...)
it('submitsFinalQuestionOnlyOnce', 0, ...)
it('fallsBackToEmptyRecordsForInvalidJson', 0, ...)
看到失败名称就能知道哪条产品契约被破坏。
二十六、回归测试的最小落地顺序
第一批先覆盖最危险且易测试的纯逻辑:
text
分数计算
进度更新
收藏切换
笔记空白删除
错题去重
非法记录归一化
第二批覆盖启动和路由输入:
text
Preferences 损坏
无效 bankId
空错题解析
无效 records JSON
第三批覆盖竞态:
text
选项连点
最后一题重复完成
超时与手动交卷
自动下一题与手动下一题
第四批完成设备烟测和多窗口适配。这样能用较少用例先守住最容易造成数据重复和崩溃的路径。
二十七、结语
中国方言题库已经具备空状态、JSON 解析回退、选项重复点击拦截、计时器销毁和本地数据清除等防护,但现有 Hypium 文件仍是默认模板,不能据此宣称业务测试已经完成。真正的回归体系要把源码里的边界变成可执行断言,并优先验证最后一题、超时交卷、页面重入和持久化失败这些会产生重复副作用的路径。
整套方法可以概括为:先验证状态边界,再验证页面结果,最后验证重复操作不会产生第二次副作用。当启动、空数据、异常输入和重复点击都有稳定断言,HarmonyOS 应用的兼容性与运行稳定性才从"人工感觉没问题"变成可复核的工程证据。
AI 辅助声明:本文在结构整理和语言润色环节使用了 AI 辅助;现有测试覆盖、空状态、JSON 回退、计时器、重复点击与导航行为均依据中国方言题库真实 ArkTS 与 Hypium 文件复核。