【口算王|19】HarmonyOS ArkTS 回归测试实战:覆盖启动、空数据、异常输入和重复点击

【口算王|19】HarmonyOS ArkTS 回归测试实战:覆盖启动、空数据、异常输入和重复点击

证据边界:本文依据 D:/huawei/one16-11 当前可读取源码与测试目录整理;本轮未执行 Hypium、构建、模拟器或真机回归。工程存在默认测试模板不等于业务用例已覆盖,文中矩阵属于建议实现。

回归测试不是把页面依次点一遍,也不是看到工程里存在 src/test 就认为质量已经有保障。真正有价值的回归测试必须固定前置数据、执行动作、观察结果和持久化副作用,并且能在版本变化后重复执行。对于口算王这类本地题库应用,最容易被忽略的故障恰好不在正常答题路径,而在首次启动、零记录、错误路由参数、连续点击和页面重新进入。

本文基于口算王项目 D:\huawei\one16-11 的真实源码,复核 Index.etsBankDetailPage.etsCategoryPage.etsSearchPage.etsPracticePage.etsExamResultPage.etsLearningStatsPage.etsQuestionUtils.etsStatService.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, () => {
  let a = 'abc'it('assertContain', 0, () => {
#it('assertContain', 0, () => {
  let a = 'abc'  let a = 'abc'

let b = 'b'

复制代码
#expect(a).assertContain(b)
  expect(a).assertEqual(a)  expect(a).assertContain(b)
#expect(a).assertContain(b)
  expect(a).assertEqual(a)  expect(a).assertEqual(a)

})

它只能证明 Hypium 模板能够运行,不能证明口算王的任何业务行为。测试报告中应把它标记为"框架冒烟用例",而不是"核心功能测试"。第一轮建设应优先替换为纯逻辑边界测试,因为这类测试执行快、结果稳定,也最适合作为每次提交的门禁。

三、回归矩阵从四层建立

建议把用例分成四层:

  1. 纯逻辑层:评分、计时、统计和题目选择;
  2. 页面状态层:首次启动、空数据、无结果和错误参数;
  3. 交互层:重复点击、快速切页、弹窗开关和返回;
  4. 持久化层:写入、清空、重启后回读和重复进入。

纯逻辑层可以使用 Hypium 单元测试;页面与交互需要组件测试或真实设备操作;持久化必须跨页面甚至跨进程复核。不要用一类测试替代所有层。

四、启动回归要从干净状态开始

Index.ets@StorageLink('currentTabIndex') 恢复当前标签,并根据 currentBreakpoint 选择底部导航或侧边导航。启动测试至少覆盖:

  • 全新安装后进入首页;
  • 本地数据为空时五个标签均可切换;
  • 上次停留在非首页标签时重新启动;
  • phone、tablet、2in1 对应宽度下导航可见;
  • 状态栏和底部安全区不会遮挡内容。

单纯执行 hvigorw assembleHap 只能证明编译成功,不能证明首帧、路由和数据初始化正常。发布候选包还应执行安装、启动、核心路径和卸载冒烟。

五、标签重复点击应保持状态稳定

当前标签点击只执行赋值:

复制代码
#.onClick(() => {
  this.currentIndex = index.onClick(() => {
#.onClick(() => {
  this.currentIndex = index  this.currentIndex = index

})

重复点击同一个标签不会叠加路由,这是当前实现的优势,因为 PageContent 由状态条件渲染,而不是每次点击都 pushUrl。回归用例仍应连续点击当前标签十次,观察页面是否重复初始化、滚动位置是否异常跳变,以及 StorageLink 是否产生无意义写入。

测试通过的判定不是"没有崩溃",而是标签索引保持一个确定值,页面没有多份实例,返回键也不会出现多余层级。

六、零数据统计必须返回确定的零

StatService.summarize 对空数组已有明确处理:

复制代码
#accuracyPercent: totalAnswered > 0
  ? Math.round(totalCorrect / totalAnswered * 100)accuracyPercent: totalAnswered > 0
#accuracyPercent: totalAnswered > 0
  ? Math.round(totalCorrect / totalAnswered * 100)  ? Math.round(totalCorrect / totalAnswered * 100)

: 0

recentAvgScore 在历史为空时也返回 0。这里应补充最小单元测试:

复制代码
#it('emptyStatsReturnsZero', 0, () => {
  const stats = StatService.summarize([], [], [], [])it('emptyStatsReturnsZero', 0, () => {
#it('emptyStatsReturnsZero', 0, () => {
  const stats = StatService.summarize([], [], [], [])  const stats = StatService.summarize([], [], [], [])

expect(stats.totalAnswered).assertEqual(0)

复制代码
#expect(stats.accuracyPercent).assertEqual(0)
  expect(StatService.recentAvgScore([])).assertEqual(0)  expect(stats.accuracyPercent).assertEqual(0)
#expect(stats.accuracyPercent).assertEqual(0)
  expect(StatService.recentAvgScore([])).assertEqual(0)  expect(StatService.recentAvgScore([])).assertEqual(0)

})

这条用例能防止后续重构时引入 NaN、除零或空数组 reduce 异常。

七、统计页没有传统空白页,但有零值态

LearningStatsPage 即使没有任何记录,也会展示概览卡片、三个数据卡片和所有题库进度。各项值为 0,每个题库显示"未开始"。这是一种合法的零值态,不必强行替换成整页插画。

回归时应检查:

  • 0% 不显示为 NaN%
  • 最近五次均分为空时显示 0;
  • 已学习题库数为 0/题库总数
  • 每个进度条比例为 0;
  • 窄屏和宽屏布局都能完整显示。

零值态的价值在于用户仍能理解页面结构和下一步目标。

八、搜索页要区分"未搜索"和"无结果"

SearchPagesearched 区分两个状态。空关键词且没有分类时会清空结果并保持 searched = false;执行一个没有匹配项的关键词后,才进入"未找到相关内容"。

复制代码
#if (this.keyword.trim().length === 0 &&
    this.categoryType.length === 0) {if (this.keyword.trim().length === 0 &&
#if (this.keyword.trim().length === 0 &&
    this.categoryType.length === 0) {    this.categoryType.length === 0) {

this.bankResults = \[\]

复制代码
#this.questionResults = []
  this.searched = false  this.questionResults = []
#this.questionResults = []
  this.searched = false  this.searched = false

return

复制代码
#}}

因此回归用例必须分别覆盖:初次进入、只输入空格、输入不存在关键词、清除分类、从分类页带参数进入。若把前两者都断言为"无结果",就会误判现有产品设计。

九、异常搜索输入先验证边界而不是猜攻击面

搜索完全基于本地字符串匹配,没有 SQL、HTTP 或 Web 组件,所以不应虚构注入风险。真实需要验证的是:

  • 超长文本是否挤压搜索栏;
  • 中英文混合和特殊符号是否稳定;
  • 前后空格是否被 trim
  • 连续提交是否重复构造巨大数组;
  • 分类名称被用户编辑后,分类过滤是否正确清除。

当前代码在输入内容偏离 categoryName 时会重置分类条件,方向正确。由于普通搜索限制最多 50 道题,长关键词也不会无限追加结果。

十、非法题库 ID 已有可见空态

BankDetailPage 读取路由参数后调用 getBankById。查不到题库时,bank 保持 undefined,页面展示"未找到题库":

复制代码
#if (this.bank === undefined) {
  Image($r('app.media.img_empty_default'))if (this.bank === undefined) {
#if (this.bank === undefined) {
  Image($r('app.media.img_empty_default'))  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) return 0if (total === 0) return 0

应覆盖四组数据:0 题、全错、部分正确和全对。还要加入"记录数大于总题数"的异常输入,决定服务是按总题数计算还是先截断记录。当前实现会按全部正确记录除以 total,理论上可能得到超过 100 的分数。

正常 UI 不应产生这种数据,但回归测试的职责正是固定异常边界。更稳妥的服务契约是把结果限制到 0 到 100,并校验记录是否属于当前题集。

十三、时间格式也需要异常输入契约

formatTime 把秒数直接拆成分钟和秒:

复制代码
#const m = Math.floor(sec / 60)
const s = sec % 60const m = Math.floor(sec / 60)
#const m = Math.floor(sec / 60)
const s = sec % 60const s = sec % 60

return ${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}

正常值 0、59、60、3599 都容易断言。负数、小数和极大值目前没有归一化,可能出现负号或小数秒。调用方通常提供递增整数,但公共工具方法仍应通过测试明确输入约束,避免未来被其他页面复用时产生模糊结果。

十四、答题选项已有重复记分保护

selectOption 开头有两道门:

复制代码
#if (this.mode === 'wrongAnalysis') return
if (this.showAnalysis) returnif (this.mode === 'wrongAnalysis') return
#if (this.mode === 'wrongAnalysis') return
if (this.showAnalysis) returnif (this.showAnalysis) return

第一次点击后立即设置 showAnalysis = true,后续点击不会再次 push 答题记录,也不会重复添加错题。回归时可连续点击同一选项和不同选项,最终断言:

  • records 只增加一条;
  • wrongRecords 最多增加一条;
  • 选中态和答案解析保持第一次结果;
  • 自动下一题只调度一次。

这是"重复点击测试"最典型的成功判据:副作用只发生一次。

十五、自动下一题要防止延迟回调串题

开启自动下一题后,代码使用 800 毫秒延迟:

复制代码
#setTimeout(() => {
  if (this.showAnalysis) {setTimeout(() => {
#setTimeout(() => {
  if (this.showAnalysis) {  if (this.showAnalysis) {

this.goNext()

复制代码
#}
}, 800)  }
#}
}, 800)}, 800)

如果用户在延迟期间手动点击"下一题",goNext 会先切换题目并把 showAnalysis 设为 false,因此回调到达后不会再次前进。这一保护值得加入回归用例:答题后立即手动下一题,等待一秒,索引只能增加一次。

还应在页面退出后观察延迟回调是否触发。当前代码没有保存并清理 timeout ID,后续可以把它纳入生命周期治理,但本文不把该改进描述为已实现。

十六、导航按钮目前缺少通用防抖

分类卡片、章节"开始"、随机练习、限时挑战和结果页按钮都直接调用 router.pushUrlreplaceUrl,没有 isNavigating 状态。高频连续点击可能发起多次导航。

建议先写失败用例,再补一个最小门闩:

复制代码
#if (this.isNavigating) return
this.isNavigating = trueif (this.isNavigating) return
#if (this.isNavigating) return
this.isNavigating = truethis.isNavigating = true

router.pushUrl(target).catch(() => {

复制代码
#this.isNavigating = false
})  this.isNavigating = false
#this.isNavigating = false
})})

页面返回或导航失败时必须恢复状态。测试要检查页面栈只增加一层,不能只看最终页面是否打开。

十七、交卷动作需要幂等语义

挑战倒计时到零会调用 autoSubmitExam,最后一题按钮也可能调用 goNext 并进入结果页。两条路径都负责更新进度和替换路由。

当前 autoSubmitExam 会把 timerId 设为 -1,但没有独立的 submitted 标志。边界时刻若用户操作和计时回调非常接近,应验证是否可能执行两次进度更新或两次 replace。可靠判定应是:

  • 一场挑战只生成一个结果页面;
  • 进度只累计一次;
  • 挑战历史只增加一次;
  • 倒计时停止且不会继续回调。

这比给按钮加一个视觉 disabled 更重要,因为幂等必须落在业务动作上。

十八、结果页的异常 JSON 已有降级

ExamResultPage 和错题解析都用 try/catch 解析 records。解析失败时数组保持为空,页面仍可展示结果。这条路径应传入空串、损坏 JSON、对象而非数组和超长记录。

不过 JSON.parse 成功不等于结构正确。若传入 {},类型断言不会做运行时校验,后续读取 records.length 的行为可能偏离预期。建议增加显式数组判断:

复制代码
#const parsed: Object = JSON.parse(raw)
this.records = Array.isArray(parsed)const parsed: Object = JSON.parse(raw)
#const parsed: Object = JSON.parse(raw)
this.records = Array.isArray(parsed)this.records = Array.isArray(parsed)

? parsed as AnswerRecord\[\]

复制代码
#: []  : []

测试应验证错误数据被降级为空记录,而不是让类型断言掩盖运行时形状。

十九、结果页重复进入会重复追加历史

ExamResultPage.aboutToAppear 每次执行都会调用:

复制代码
#this.examHistory = UserDataManager.addExamHistory(
  this.examHistory,this.examHistory = UserDataManager.addExamHistory(
#this.examHistory = UserDataManager.addExamHistory(
  this.examHistory,  this.examHistory,

this.bankId,

复制代码
#this.score,
  this.total,  this.score,
#this.score,
  this.total,  this.total,

this.correct,

复制代码
#this.durationSec
)  this.durationSec
#this.durationSec
))

这意味着页面重建、重复导航或某些生命周期重入可能追加相同挑战历史。回归时应记录进入前后的数组长度,重复进入同一结果参数后仍只能增加一条。

工程上可以给每次挑战生成唯一 sessionId,并在写入时去重;也可以把历史写入放在挑战提交服务中,结果页只负责展示。当前源码尚未实现这一点,属于明确的回归风险。

二十、结果页排名是静态展示,不应作为联网测试

结果页显示 ${236}/${1258},它不是服务端返回的真实排名。回归测试只能验证文字布局稳定,不能记录"在线排行榜请求成功"或"排名数据同步成功"。

同理,测试报告不能虚构 PV、点赞、收藏或云端账号数据。收藏是本机题目记录,挑战历史也是本地数据。测试对象必须与真实数据源一致。

若后续要接入真实榜单,应先定义请求失败、超时、无网络、匿名标识和隐私说明,再把静态占位替换为明确的加载、成功、空态和失败状态。在此之前,验收表应将这一格标为"静态展示",避免测试人员把固定数字误判成已经具备联网能力。

二十一、持久化回归必须跨重启

收藏、错题、笔记、进度和挑战历史最终通过本地数据管理器保存。只在当前页面观察 StorageLink 变化,只能证明内存状态更新。

一条完整持久化用例应执行:

  1. 清空目标数据并重启;
  2. 完成一次明确操作;
  3. 记录界面和内存结果;
  4. 退出页面或结束应用;
  5. 重新启动并回到对应页面;
  6. 验证记录仍存在且数量准确;
  7. 执行删除,再次重启验证已消失。

这样才能发现写入失败被吞掉、键名不一致或清理不彻底等问题。

二十二、测试数据要可重复

随机练习使用洗牌,挑战题则按题库 ID 和当前 UTC 小时生成种子。同一小时内挑战题相对稳定,跨小时会变化。自动化测试如果直接断言某道固定题,可能在整点后失败。

可选策略包括:

  • 单元测试直接传入固定 Question 数组;
  • 把时间源封装为可注入依赖;
  • 只断言数量、唯一性和题目属于输入集合;
  • 记录测试开始时的小时边界,避免跨整点执行。

测试稳定不等于业务取消随机性,而是把随机和时间依赖控制在可验证边界内。

二十三、多设备回归不能只改窗口宽度

源码对宽度和 currentBreakpoint 都有判断。例如题库详情只有在 currentBp === 'lg' && pageWidth >= 700 时使用双列布局。统计页也会在宽屏切换网格。

回归矩阵至少覆盖 phone 竖屏、phone 横屏或小窗、tablet 宽屏和 2in1 调整窗口。检查重点是:

  • 导航模式是否符合断点;
  • 列表、卡片和底部动作是否可达;
  • 文本是否截断或重叠;
  • 弹窗和软键盘是否遮挡保存操作;
  • 系统底部手势区是否保留安全距离。

只修改 pageWidth 而不更新断点,不能代表真实设备切换。

二十四、回归证据要能定位版本

每轮发布测试建议保存:

  • 构建版本名和版本号;
  • 包名与签名环境;
  • 设备类型、系统版本和窗口尺寸;
  • 用例编号、前置数据和执行时间;
  • 实际结果、截图或日志;
  • 失败是否可复现及对应源码提交。

日志不应包含用户笔记、账号凭据或私密信息。本文也不会输出或保存密码、Cookie、Token、短信码和证书密码。

二十五、第一批 Hypium 用例应从纯函数开始

最适合先落地的目标是 QuestionUtilsStatService

  • 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、构建、模拟器和真机测试均未写成已通过。

相关推荐
贾伟康1 小时前
【句匠|09】HarmonyOS ArkTS 学习统计实战:汇总正确率、连续学习和薄弱类型
harmonyos·arkts·appstorage·学习统计·本地统计
贾伟康2 小时前
【口算王|17】HarmonyOS ArkTS 亮暗色与视觉令牌实战:集中颜色、间距和交互状态避免页面割裂
harmonyos·arkts·arkui·深色模式·ui设计
搬砖的kk2 小时前
从 0 到 1:react-native-screenshot-aware 鸿蒙适配实战(RNOH 0.84)
react native·华为·harmonyos
贾伟康2 小时前
【口算王|15】HarmonyOS ArkTS 本地状态持久化实战:让保存、删除和页面返回后的数据即时一致
harmonyos·arkts·数据持久化·状态管理·preferences
贾伟康2 小时前
【口算王|18】HarmonyOS ArkTS 权限与隐私实战:让 module.json5、功能说明和拒绝路径一致
harmonyos·arkts·权限管理·隐私合规·module.json5
ChinaDragonDreamer2 小时前
HarmonyOS:6.0 新增和增强特性
harmonyos·鸿蒙
Georgewu2 小时前
【HarmonyOS AI】 通用文字识别详解
harmonyos
哈__3 小时前
Flutter 3.44.9 + OpenHarmony7:home_widget 三方库桌面服务卡片(FormKit)的应用
flutter·华为·harmonyos
贾伟康3 小时前
【句匠|08】HarmonyOS ArkTS 句库搜索实战:支持关键词、分类和无结果反馈
harmonyos·arkts·分类筛选·学习应用·搜索功能