【天体运行模拟|19】HarmonyOS ArkTS 回归测试实战:覆盖启动、空数据、异常输入和重复点击
回归测试最容易退化成一张“点过首页、能打开详情”的清单。真正会在上架后暴露的问题,往往发生在第二次动作:快速点两次“查看结果”写出重复记录;启动、暂停、再启动后出现两个计时器;清除数据后页面仍显示旧收藏;Preferences 尚未初始化时,空数据被误判为读取失败;窗口缩放后 Canvas 仍按旧尺寸绘制。
“天体运行模拟”的真实代码包含四个一级 Tab、首页入口、关卡收藏、知识网格、个人中心、N 体模拟计时器、结果记录和本地数据清理。源码已经有一些防护:启动模拟前调用stopTimer(),页面消失时停止计时器;滑块由min/max/step约束;引力计算用最小距离平方避免除零;清除数据前有不可恢复确认。但项目目录中未检索到现成测试用例,因此不能把这些代码结构等同于“已经通过自动化回归”。
本文面向 HarmonyOS 5.0 及以上版本,基于真实页面和数据链,建立从纯逻辑、页面状态、持久化到 release 包的回归测试方案,重点覆盖启动、空数据、异常输入、重复点击与生命周期。
项目基线:应用版本
1.0.0,Bundle Name 为com.jiaweikan.one13,目标设备包含 phone、tablet 与 2in1。文中测试代码为可落地的设计示例,实际通过状态必须以本地或设备测试报告为准。
一、回归目标先从用户主链路定义
主链路不是页面列表,而是用户能够完成的闭环:
安装并启动 → 首页选择实验 → 调整参数并启动模拟 → 暂停或查看结果 → 保存实验记录 → 收藏实验 → 返回关卡页复核收藏 → 设置页清除数据 → 重启后确认数据已清除 → 卸载任何版本只要改动 Canvas、DataStore、路由、主题或布局,都可能影响这条链。回归用例应围绕闭环组织,而不是按文件平均分配。
二、建立测试层级
| 层级 | 适合验证 | 速度 |
|---|---|---|
| 纯逻辑测试 | 物理计算、格式化、边界映射 | 快 |
| 服务测试 | Preferences、记录与清除 | 中 |
| 页面测试 | 状态、点击、空页、路由 | 中 |
| 设备冒烟 | 安装、启动、系统栏、生命周期 | 慢 |
| 多设备回归 | 分屏、键鼠、窗口变化 | 最慢 |
不是所有逻辑都通过 UI 点一遍。物理公式和状态转换放到纯函数后,可以快速覆盖大量边界;系统生命周期和真实绘制必须在设备上验证。
三、为每个用例写四个字段
一个可重跑用例至少包含:
interface RegressionCase { id: string precondition: string action: string expected: string cleanup: string }例如“清除数据”不能只写“点击清除”。前置要准备收藏、笔记和记录;动作要包含确认;预期要同时检查 UI 与 Preferences;清理要把测试环境恢复为空。
四、冷启动与热启动分开
冷启动验证应用进程不存在时的初始化,热启动验证从后台恢复。两者会触发不同生命周期。
冷启动检查:
- 首屏不白屏、不闪退;
- 状态栏和底部导航可读;
DataStore.init()完成前页面不误显示错误;- 首页 Hero 和 Canvas 正常绘制;
- 四个 Tab 均可进入;
- 首次空数据没有残留占位内容。
热启动还要检查模拟计时器、当前 Tab、未保存草稿和页面滚动状态是否符合产品预期。
五、真实 Tabs 状态是第一组回归点
Index.ets维护:
@State currentIndex: number = 0 private tabController: TabsController = new TabsController()onChange更新索引,首页还通过回调切换到关卡页。至少验证:
- 点击四个 Tab,图标与文字选中态一致;
- 首页“关卡模式”能切到索引 1;
- 连续快速切换不会出现两个 Tab 同时选中;
- 从二级页返回后仍回到原 Tab;
- 横竖屏或窗口变化不把索引重置为 0。
六、首页空数据不能只验证不崩溃
HomePage默认通过getAllExperiments()获取实验并slice出热门列表。若未来数据源为空,Grid 会自然没有项,但页面是否给出空状态仍要定义。
type ContentState = | 'loading' | 'empty' | 'content' | 'error'空数组是业务状态,读取失败是错误状态,初始化未完成是 loading。回归测试要分别注入,不能都验成一个空白区域。
七、关卡页空收藏与空实验是两回事
LabPage的favoriteIds默认空数组,空收藏表示所有星标未选中;experiments为空才表示没有关卡。测试应拆开:
- 有实验、无收藏:列表完整,星标为空;
- 有收藏:对应 ID 星标选中;
- 收藏包含不存在 ID:列表不崩溃;
- 实验为空:显示明确空状态;
- 分类过滤后为空:提示当前分类无内容,而不是全局加载失败。
这能发现“一个空数组承担多个语义”的问题。
八、Preferences 初始化失败需要可观察
DataStore 多处catch后返回默认值。默认值保护了页面不崩溃,但也可能把读取异常伪装成空数据。测试环境应能注入:
interface FavoritesRepository { load(): Promise<string[]> save(ids: string[]): Promise<boolean> }页面测试替换成成功、空值、延迟和抛错实现,验证 loading、empty、content、error 四态。没有依赖边界时,异常测试会被静态 DataStore 调用绑住。
九、模拟页启动按钮的真实防重逻辑
源码的启动流程:
private startSimulation(): void { this.isRunning = true this.stopTimer() this.drawCanvas() setTimeout(() => { if (this.isRunning) { this.startTimer() } }, 120) }startTimer()内部再次调用stopTimer(),这是防止多计时器的重要设计。回归测试要快速点击启动、暂停、启动,并观察:
timerId始终只代表一个活动计时器;- 暂停后位置不再变化;
- 再启动从当前状态继续;
- 页面退出后计时器停止;
- 延迟 120ms 的回调不会在暂停后重新启动。
十、重复点击不能只看按钮文案
按钮文案从“启动模拟”切为“暂停模拟”,快速点击会在两个状态间切换。真正断言应针对可观察行为:
16ms 时间步只执行一次 body 位置增量不翻倍 学习时长不重复累计 退出后 CPU 不持续占用UI 文案正确但后台存在两个 interval,仍是严重回归。
十一、页面销毁必须释放资源
真实代码在aboutToDisappear中:
this.stopTimer() this.persistLearningTime()需要验证三条路径:系统返回、路由跳转到结果页、应用退后台。尤其是跳转结果页前可能仍在运行,离开后 Canvas 更新和 interval 都必须停止。
可以在测试构建中暴露只读诊断值:
interface SimulationDiagnostics { timerActive: boolean stepCount: number lastPersistedAt: number }release 构建不应保留敏感调试入口。
十二、异常输入从滑块边界开始
参数使用:
Slider({ value: this.paramValues[index], min: param.min, max: param.max, step: param.step })回归点包括最小值、最大值、非整数步长、快速拖动、窗口缩放中拖动和无障碍调整。每次onChange将值四舍五入到两位,显示又根据step决定小数位,需要验证内部值与显示值一致。
十三、模型层还要防御非 UI 输入
Slider 能约束正常交互,但路由参数、旧数据或测试代码可能传入NaN、Infinity、负质量和越界速度。物理引擎边界不应只依赖组件。
function normalizeMass(value: number): number { if (!Number.isFinite(value)) return 1 return Math.min(5000, Math.max(1, value)) }测试覆盖NaN、正负 Infinity、0、负数、边界两侧和超大值。归一化规则应与产品参数范围一致。
十四、引力计算已有除零保护
真实算法使用:
const distSq = Math.max(dx * dx + dy * dy, 90) const dist = Math.sqrt(distSq) const force = g / distSq当两个天体坐标相同,距离平方不会变成 0。这条规则应建立单元测试:
- 同坐标不产生 NaN 或 Infinity;
- 极近距离加速度仍为有限值;
- 两体受力方向相反;
- 合并后总质量守恒;
- 合并后速度按动量加权;
- 半径不超过源码上限 34。
十五、碰撞合并测试要验证不变量
只断言“天体数量减少”不够。应验证:
totalMassAfter = totalMassBefore momentumAfter ≈ momentumBefore one body marked inactive surviving body remains finite black hole type has priority bodyCount equals alive list length浮点比较使用误差范围,不能直接用字符串或完全相等。
十六、轨迹数组必须有上限
源码中:
if (body.trailX.length > 72) { body.trailX.shift() body.trailY.shift() }长时间运行测试应确认 X/Y 长度始终一致且不超过 72,隐藏轨迹后不会继续无界增长,重置系统后旧轨迹清除。否则几分钟冒烟正常,长时间运行才出现内存和绘制问题。
十七、越界天体清理
天体超出 Canvas 外扩 200 的范围后标记alive = false,随后过滤数组。测试应覆盖四个方向、刚好边界和边界外一像素,并确认:
- 删除后
bodyCount更新; - 绘制不再访问已删除天体;
- 全部天体离开后页面显示合理状态;
- 查看结果不会因空数组产生 NaN;
- 重置可恢复默认系统。
十八、结果页重复点击是高风险点
“查看结果分析”点击处理会:
this.persistRecord() DataStore.incrementExperimentCount() router.pushUrl({ url: resultUrl })这些异步动作没有等待,也没有submitting防重。快速双击可能写入两条记录、计数两次或发起两个路由跳转。这是当前源码可以识别的回归风险,不能说已经解决。
建议:
@State submittingResult: boolean = falseprivate async openResult(): Promise<void> { if (this.submittingResult) return this.submittingResult = true try { await this.persistRecord() await DataStore.incrementExperimentCount() await router.pushUrl({ url: resultUrl }) } finally { this.submittingResult = false } }测试快速点击 2、5、10 次,最终只能新增一条记录和一次计数。
十九、收藏快速点击可能写回旧值
实验页切换收藏时先读取数组、立即更新isFavorite,再保存。两次点击并发可能都读取同一旧数组。回归测试要模拟慢存储并连续点击:
| 点击序列 | 预期最终状态 |
|---|---|
| 一次 | 取反 |
| 两次 | 回到初始状态 |
| 三次 | 取反 |
| 保存失败 | UI 回滚或明确失败 |
若最终状态与点击奇偶不一致,需要在仓库层串行整个读改写流程。
二十、清除数据必须防重复确认
设置页已经用AlertDialog告知不可恢复,这是正确的破坏性操作边界。回归用例应验证:
- 点击取消不改数据;
- 点击确定清空所有指定键;
- 连续点击设置入口只出现一个有效对话框;
- 清除中按钮不可重复执行;
- 任一写入失败不显示“全部已清除”;
- 清除后收藏页、记录页和统计快照立即更新;
- 重启后数据仍为空。
当前多次putString/putNumber顺序执行,但异常被 DataStore 内部吞掉时,页面仍可能 Toast 成功,需通过返回结果改进。
二十一、缓存清理与业务清理分开测
设置页分别提供缓存清理和模拟数据清理。缓存目录为空、目录不可用、文件删除失败、嵌套目录和清理后重新计算大小都应覆盖。
关键断言:清缓存不能删除收藏、笔记和记录;清模拟数据不能误删应用必要资源。名称相近的两个入口最容易在重构中调用错方法。
二十二、路由参数异常
实验页通过expId和expName接收参数。回归测试传入:
- 合法实验 ID;
- 缺失 ID;
- 空字符串;
- 未知 ID;
- 超长名称;
- 包含特殊字符的名称;
- 直接进入结果页但缺少统计参数。
页面应使用明确默认实验或显示错误,不应因强制类型转换白屏。默认值必须与产品预期一致。
二十三、返回刷新与旧请求覆盖
LabPage在aboutToAppear与onPageShow都调用reloadFavorites()。首次显示可能出现两个异步读取。测试让第一次读取延迟更长、返回旧值,第二次快速返回新值,确认旧请求不会最后覆盖新状态。
可用版本号:
private reloadVersion: number = 0private async reloadFavorites(): Promise<void> { const version = ++this.reloadVersion const ids = await DataStore.loadFavorites() if (version !== this.reloadVersion) return this.favoriteIds = ids }二十四、窗口变化回归
首页和知识页使用onAreaChange改网格列数。回归宽度至少覆盖599/600/601与839/840/841,并连续拖动窗口。
检查:
- 卡片无重叠或截断;
- 当前 Tab 不重置;
- 收藏和模拟状态不丢;
- Canvas 清理并按新尺寸重绘;
- 小高度窗口所有操作可滚动到;
- 2in1 鼠标和键盘仍可操作。
二十五、视觉回归不能只比全屏像素
Canvas 轨道会随时间变化,整页像素比较容易误报。建议分区:
- 静态导航、卡片和文字做截图差异;
- Canvas 只检查非空、尺寸、关键颜色和无异常覆盖;
- 动态数字使用区域遮罩或语义断言;
- 深浅色分别建立基线;
- phone、tablet、2in1 保存独立基线。
截图差异必须由人工复核后更新,不能失败就自动覆盖基线。
二十六、构造可重复测试数据
测试不应依赖开发者设备上已有收藏。每个用例前注入固定数据:
interface RegressionFixture { favorites: string[] notes: TestNote[] records: TestRecord[] experimentCount: number learningSeconds: number }用例结束后恢复或清空。数据 ID、时间戳和排序要固定,避免结果随当前时间变化。
二十七、回归证据结构化保存
每次 release 候选包记录:
版本名 / versionCode 构建时间与提交号 设备型号与系统版本 安装包哈希 用例 ID 与结果 失败截图和日志 修复提交 复测结果“我刚才点过了”不能支撑发布决策。证据使失败可复现,也避免下一轮重复调查。
二十八、发布门禁
只有以下条件同时满足才进入上架:
- 本地硬检查与构建通过;
- 纯逻辑关键边界测试通过;
- 四 Tab 和主实验闭环通过;
- 空数据、读取失败和异常参数有明确结果;
- 重复点击不产生重复写入或路由;
- 页面退出后无活动计时器;
- 清除数据后重启仍为空;
- phone、tablet、2in1 目标布局通过;
- release 包完成安装、启动、核心流程和卸载;
- 所有失败有归属,阻断问题为零。
不能用“调试预览正常”替代 release 包冒烟。
二十九、推荐的最小自动化集合
第一阶段不必追求全量 UI 自动化,先落地高价值集合:
resolveSizeClass边界;- 物理步进有限值;
- 碰撞质量与动量不变量;
- 轨迹长度上限;
- 收藏奇偶点击;
- 结果提交防重;
- DataStore 空值、损坏 JSON 与写入失败;
- 清除数据覆盖所有键;
- 启停计时器唯一性;
- 冷启动主路由冒烟。
这十项能覆盖最容易复发且影响较大的问题。
三十、当前源码的风险清单
| 风险 | 当前证据 | 回归策略 |
|---|---|---|
| 结果重复提交 | 点击中无防重状态 | 高频点击,断言单条记录 |
| 收藏并发覆盖 | 读改写未整体串行 | 慢存储与奇偶点击 |
| 清除假成功 | 多次写入无汇总结果 | 注入一次写失败 |
| 空数据伪装错误 | catch 后默认空数组 | 注入异常并检查 error |
| 双生命周期读取 | 两处 reload | 乱序响应测试 |
| Canvas 尺寸变化 | 动态绘制依赖宽高 | 连续缩放与像素检查 |
| 计时器泄漏 | 依赖 stopTimer | 启停、退出与后台测试 |
风险清单来自代码结构,不代表线上已发生,也不能虚构测试通过。
三十一、上架前回归清单
- [ ] 冷启动、热启动和后台恢复通过;
- [ ] 四个 Tab 与首页快捷切换通过;
- [ ] loading、empty、content、error 四态可区分;
- [ ] Slider 最小、最大和步长正确;
- [ ] NaN、Infinity、负数不会进入物理核心;
- [ ] 同坐标天体不产生非有限值;
- [ ] 碰撞质量和动量满足不变量;
- [ ] 轨迹长度不超过 72;
- [ ] 启停和退出后只有零个或一个计时器;
- [ ] 结果和收藏重复点击符合预期;
- [ ] 清除数据取消、成功和失败路径完整;
- [ ] 返回刷新不被旧请求覆盖;
- [ ] 多窗口与三类设备布局可达;
- [ ] release 包安装、启动、运行和卸载正常。
总结
高质量回归测试不是把页面点一遍,而是把真实代码中的状态边界变成可重复断言:启动要区分冷、热生命周期;空数据要区分加载、错误;异常参数要在模型层归一化;重复点击要验证写入和路由只有一次;页面退出要证明计时器已经释放。
“天体运行模拟”已有不少值得保留的防护,包括计时器启动前主动停止、离页释放、滑块范围、最小距离平方和轨迹长度上限。下一步应把这些隐含规则固化为纯逻辑、页面、持久化和 release 设备四层用例,并优先补齐结果提交、收藏读改写、清除结果与乱序刷新等高风险回归点。
本文唯一标记:
CSDN-SERIES:ALL-163201431
AI 辅助声明:本文部分内容由 AI 辅助整理,源码事实、工程边界与风险结论均依据文中所列项目文件复核;未实际执行的测试不标记为通过。