【万能转换器|19】HarmonyOS ArkTS 回归测试实战:覆盖启动、空数据、异常输入和重复点击
本轮证据边界: 当前源码共有 65 个
it()声明,其中 UnitConverter 14 个、CalcEngine 42 个、History 8 个、Ability 1 个。本轮只进行了静态源码复核,没有执行测试套件、构建、安装或真机回归,因此"65 个源码用例"绝不等于"65 个测试已通过"。启动、空数据、异常输入、重复点击和重启恢复属于基于现有缺口提出的设备回归矩阵;除文中明确引用的现有测试外,不把建议测试描述为当前工程已经实现。
工具类应用的回归测试有一个常见误区:公式算对了,就认为页面稳定。实际发布后更容易暴露的问题,往往发生在公式之外:服务还没初始化时页面已经可点、空列表进入编辑模式、非法输入留下上一次结果、连续点击"转换"写入多条历史、异步删除过程中再次操作、默认单位刚修改就返回、剪贴板写入失败却没有反馈。这些状态很难靠一条"1 米等于 100 厘米"覆盖。
"万能转换器"的真实源码已经具备一层不错的算法测试。entry/src/ohosTest/ets/test 中的 List.test.ets 聚合了 Ability.test.ets、UnitConverter.test.ets、CalcEngine.test.ets 和 History.test.ets,按源码中的 it() 声明统计共有 65 个用例:14 个单位换算用例、42 个计算引擎用例、8 个历史模型用例和 1 个模板式 Ability 断言。它们覆盖温度非线性换算、NaN/Infinity 格式化、零利率、无效 BMI、90 度正切、错误进制、序列化容错等边界。
但"有 65 个测试文件中的用例"不等于"本次已经执行并全部通过",本文没有伪造测试运行结果。源码证据还显示,现有 ohosTest 主要保护纯函数和模型;Ability.test.ets 只是模板断言,没有真实启动、路由和页面操作;未发现针对 ConvertPage、HistoryPage、FavoriteToolsPage、DefaultUnitsPage 的 UI 自动化脚本。尤其是重复点击、异步服务未就绪、删除中途失败和重启后的持久化恢复,仍需要设备级回归。
本文面向 HarmonyOS 5.0 及以上版本,基于上述测试源码和五个真实页面,建立"纯函数单测 + 服务状态测试 + ArkUI 页面回归 + 安装启动稳定性"的分层方案。
唯一标记:
AGC18-HMOS-18-19-REGRESSION-STARTUP-EMPTY-INVALID-REPEAT

一、先把"已有覆盖"和"计划覆盖"分开
测试文章最忌讳把测试计划写成测试结果。当前源码能确认的事实如下:
| 层级 | 已有源码 | 可确认覆盖 | 仍缺少的证据 |
|---|---|---|---|
| 单位换算 | UnitConverter.test.ets |
14 个声明用例 | 本次执行报告 |
| 计算引擎 | CalcEngine.test.ets |
40 个声明用例 | 本次执行报告 |
| 历史模型 | History.test.ets |
8 个声明用例 | Preferences 真机读写 |
| Ability | Ability.test.ets |
1 个模板断言 | 真实安装与启动 |
| ArkUI 页面 | 未发现对应 UI 脚本 | 无 | 路由、空态、点击、弹窗、返回 |
本文后续出现的 UI 场景、测试代码和门禁值,若没有在当前工程实现,都会明确标为"建议方案"。这能避免把未来工作误写成现状,也便于团队逐条落地。
二、现有63个用例保护了什么
List.test.ets 的聚合关系很直接:
ts
export default function testsuite() {
abilityTest();
unitConverterTest();
calcEngineTest();
historyModelTest();
}
其中真正有业务价值的是三组纯逻辑测试:
- 单位换算:长度、重量、温度、数据容量、时间、速度、批量换算与格式化。
- 计算引擎:房贷、BMI、体脂、个税、利息、欧姆定律、合阻、几何、日期、汇率、进制和金额大写。
- 历史模型:ID、序列化往返、空值、畸形 JSON、缺失字段补默认值和时间格式化。
这些用例执行快、定位准,适合作为每次提交的第一道门禁。它们解决"计算核是否被改坏",却不能回答页面是否能正常启动和交互。
三、现有用例中的边界设计值得保留
测试没有只写正常样例,而是主动覆盖异常值:
ts
it('convertAll with NaN propagates NaN', 0, () => {
const results = convertAll(group, 'm', NaN);
for (const result of results) {
expect(Number.isNaN(result.value)).assertTrue();
expect(result.display).assertEqual('-');
}
});
CalcEngine.test.ets 还检查:
text
BMI 输入 0 → 无效等级
90° 正切 → NaN
错误进制字符串 → valid=false
无效长度估算 → 空数组
零利率房贷 → 本金按期数均分
并联电阻 → 忽略 0 和负值
这类用例建立了稳定的领域合同。页面回归不必再次证明所有数学公式,只要验证 UI 能把非法输入映射到这些既定结果,并且不保存错误历史。
四、启动回归的风险在异步初始化
EntryAbility.onCreate() 会启动三个异步服务:
ts
HistoryService.init(this.context).catch(...);
SettingsService.init(this.context).catch(...);
FavoriteToolsService.init(this.context).catch(...);
这些 Promise 没有在 loadContent('pages/Index') 前统一等待。因此页面有机会先显示,用户也可能在服务尚未完成初始化时点击收藏、转换或设置。源码做了一些局部保护,例如收藏页用 try/catch 捕获服务未就绪,转换页保存历史时也会静默跳过;但这并不是一个统一的启动状态。
启动回归至少应验证冷启动、热启动、后台恢复、清数据后首次启动和快速点击首屏工具。尤其要确认"页面看见了但服务不可用"时,用户不会得到成功假象。
五、冷启动测试要观察四个时点
建议把冷启动拆成四个可观测时点:
text
T0:进程创建
T1:WindowStage 创建
T2:Index 首帧可见
T3:History / Settings / Favorites 全部 ready
当前源码没有公开统一的 AppReady 状态。建议方案是由启动编排器等待必要服务,或者把准备状态写入 AppStorage:
ts
// 建议方案,非当前源码
export type StartupState = 'booting' | 'ready' | 'degraded';
await Promise.all([
HistoryService.init(context),
SettingsService.init(context),
FavoriteToolsService.init(context),
]);
AppStorage.setOrCreate<StartupState>('startupState', 'ready');
页面测试就能明确断言:booting 时关键写操作不可用,ready 后才响应;某个非关键服务失败时进入 degraded,而不是无限加载。
六、Ability.test 目前不能证明真实启动
当前 Ability.test.ets 的唯一用例是:
ts
it('assertContain', 0, () => {
let a = 'abc';
let b = 'b';
expect(a).assertContain(b);
expect(a).assertEqual(a);
});
它证明 Hypium 套件可以加载一个简单断言,却没有启动 EntryAbility、等待首页、点击 Tab 或检查崩溃。它不应被计入"启动流程已覆盖"的证据。
这并不意味着要删除模板用例。更合理的做法是保留一个环境冒烟用例,同时新增真实启动测试:确认应用主页面出现、底部导航可见、系统返回可用,日志中没有未捕获异常。
七、空数据回归要覆盖三类页面
项目中至少有三种空数据语义:
HistoryPage:暂无记录,提示"使用任意工具后会自动记录"。HistoryPage收藏子 Tab:还没有收藏,提示点击星标。FavoriteToolsPage:还没有收藏的工具。
空态测试不能只截图文字,还应验证操作约束:
text
空历史时"清空"是否隐藏或无害
空筛选结果时编辑模式是否能退出
空收藏时"全部清空"是否不可触发
从空态新增第一条数据后是否立即切换为列表
删除最后一条后是否回到正确空态
重启后空态是否稳定
HistoryPage.allSelected() 已经在过滤结果为空时返回 false,这是一个值得直接单测的边界。
八、历史页的空态文案来自状态函数
真实代码把空态标题和提示集中到两个方法:
ts
private emptyTitle(): string {
if (this.activeTab === 'favorite') return '还没有收藏';
return '暂无记录';
}
private emptyHint(): string {
if (this.activeTab === 'favorite') {
return '点击列表项右侧的 ☆ 即可收藏';
}
return '使用任意工具后会自动记录';
}
这比在多个 Builder 分支中重复字符串更容易测试。建议进一步把"过滤类型 → 空态模型"提取成纯函数,让单测直接覆盖全部、转换、计算、收藏四个 Tab。
UI 自动化仍需验证文字真正显示,因为纯函数正确不代表布局没有被底栏遮住,也不代表切换 Tab 后页面用的是最新状态。

九、异常输入要同时检查按钮、结果和历史
ConvertPage 对输入的处理有三条链路:
ts
private canConvert(): boolean {
const value = parseFloat(this.inputText);
return Number.isFinite(value);
}
ts
this.results = convertAll(
this.group,
this.fromKey,
Number.isFinite(value) ? value : NaN,
);
ts
if (trimmed.length === 0) return;
const value = parseFloat(trimmed);
if (!Number.isFinite(value)) return;
因此异常输入回归不能只看"转换按钮变灰"。还要确认结果显示为 -、复制与清空按钮状态合理、离开页面不会写历史、再次输入合法值能恢复。
十、parseFloat 带来一个需要测试的语义
parseFloat() 会接受数字前缀。例如输入 "12abc" 时可能得到 12,Number.isFinite(12) 为真。当前 canConvert() 因此可能把包含尾随字符的字符串视为可转换。
这不是本文臆测的崩溃,而是 JavaScript/ArkTS 数值解析语义与当前实现组合出的测试点。产品需要明确:
- 若允许宽松输入,应在 UI 中把标准化后的数值反馈给用户。
- 若只允许完整数字,应使用更严格的正则或数值解析函数,并为
12abc、多个小数点、单独负号、指数形式和空格建立用例。
建议用例:
ts
// 建议方案,非当前源码
expect(parseStrictNumber('12')).assertEqual(12);
expect(parseStrictNumber('12.5')).assertEqual(12.5);
expect(parseStrictNumber('12abc')).assertUndefined();
expect(parseStrictNumber('-')).assertUndefined();
十一、非法输入不能留下旧结果
当合法值改为非法值时,recomputeResults() 会把 NaN 传给 convertAll(),现有单测证明结果格式会变为 -。回归测试还应覆盖状态切换顺序:
text
输入 1 → 出现有效结果
清空 → 所有结果变为 -
输入 abc → 按钮禁用,结果仍为 -
输入 2 → 结果恢复
切换源单位 → 结果按 2 重新计算
点击结果行作为新输入 → 输入和结果同步
这类"有效 → 无效 → 有效"比单独输入一个非法值更容易发现缓存、@Watch 和列表刷新问题。
十二、重复点击转换会触发多次异步保存
转换按钮的点击处理为:
ts
private handleConvertClick(): void {
this.recomputeResults();
this.recordHistoryIfValid(true);
}
按钮只根据输入是否合法启用,没有 saving 或 busy 状态。用户连续点击会连续调用 HistoryService.add()。服务层存在"相同 kind、type、summary 的记录在 5 秒内更新原记录"的去重逻辑,这是保护层,但页面仍可能弹出多个完成提示,异步返回顺序也值得验证。
重复点击测试应该快速点击 2、5、10 次,然后断言:
text
页面不冻结
历史数量符合去重合同
最终记录时间正确
完成提示不会堆叠到不可用
按钮状态能恢复
离开页面不会再额外重复保存
十三、去重合同应在服务层直接单测
HistoryService.add() 的关键逻辑是:若最近记录的 kind、type、summary 相同,且时间差小于 5 秒,则更新已有记录并移到顶部;否则创建新记录。
这段逻辑比页面点击更适合服务测试。建议注入可控时钟:
ts
// 建议方案,非当前源码
interface Clock {
now(): number;
}
const clock = new FakeClock(1_000);
await service.add(payload);
clock.advance(4_000);
await service.add(payload);
expect(service.listRecent(10).length).assertEqual(1);
clock.advance(6_000);
await service.add(payload);
expect(service.listRecent(10).length).assertEqual(2);
如果不注入时钟,测试只能真实等待 5 秒,运行慢且容易抖动。
十四、删除选中存在串行异步窗口
历史批量删除当前逐条等待:
ts
for (const id of ids) {
await service.remove(id);
}
this.selected = [];
this.editMode = false;
remove() 每次都会过滤缓存、全量写回 Preferences 并广播。选中 50 条记录,就会发生 50 次序列化、写入和刷新。在删除期间页面没有显式 deleting 状态,用户可能再次点击。
回归测试应覆盖:
- 连续点击"删除"是否重复执行。
- 删除过程中返回页面是否留下半完成状态。
- 中间一次写入失败时,前面已删和后面未删如何展示。
- 删除最后一条后空态是否正确。
- 编辑模式和
selected是否总能复位。
更好的服务接口是一次 removeMany(ids),单次持久化并返回删除数量。
十五、清空和恢复默认都需要幂等测试
HistoryPage.confirmClearAll() 和 DefaultUnitsPage.resetAll() 都有确认框,成功后显示 Toast。幂等回归应验证:
text
取消 → 数据完全不变
确认一次 → 数据恢复目标状态
确认后再次触发 → 不崩溃、不产生错误状态
操作中快速返回 → 重进页面状态正确
写入失败 → 不显示成功 Toast
重启 → 清空/默认状态保持
当前代码的成功 Toast 都放在 Promise then() 中,这一点是正确方向;失败时只记录日志,用户不可见,仍需补测试和交互。
十六、收藏页要测废弃key和快速切换
FavoriteToolsPage.itemsOf() 会解析收藏 JSON,并通过 findToolByKey() 过滤已经不存在的工具 key:
ts
set.forEach((key: string) => {
const item = findToolByKey(key);
if (item) output.push(item);
});
这是迁移兼容的重要边界。建议单测以下输入:
text
空字符串
畸形 JSON
重复 key
不存在 key
一个合法 + 一个废弃 key
大量 key
UI 回归则要快速点击同一星标,检查最终收藏状态与 Preferences 一致。当前 toggle() 是异步写入,页面没有针对同一 key 的请求锁;连续点击可能形成顺序竞争,不能只凭最后一次动画判断持久化结果。
十七、默认单位要测设置、回退和路由
ConvertPage.aboutToAppear() 会读取 defaultUnitOf(),并检查该 key 是否仍存在:
ts
const preferred = defaultUnitOf(type, this.defaultUnitsJson);
const exists = this.group.units.find(
(unit: Unit) => unit.key === preferred,
);
this.fromKey = exists ? preferred : this.group.baseKey;
这条回退链路应覆盖:
- 从未设置时使用内置默认单位。
- 修改后进入转换页,源单位立即变化。
- Preferences 中是废弃 key 时回退
baseKey。 - 恢复默认后各类型回到预期单位。
- 快速切换多个类型不会把 key 写到错误类型。
- 设置失败时页面不能假装已保存。
当前页面的 selectUnit() 失败只写日志,UI 可能先因 AppStorage 更新或列表状态出现短暂错觉,需要真机验证实际时序。
十八、回归矩阵应按状态而不是页面数量展开
一个可执行的最小矩阵如下:
| 场景 | 初始状态 | 操作 | 断言 |
|---|---|---|---|
| 冷启动 | 清数据 | 启动并立刻点工具 | 不崩溃,核心页可用 |
| 空历史 | 无记录 | 进入历史 | 正确空态,无无效编辑 |
| 空收藏 | 无收藏 | 进入收藏 | 正确空态 |
| 非法输入 | 12abc |
点击转换 | 按产品合同拒绝或标准化 |
| 状态恢复 | 合法→非法→合法 | 连续编辑 | 结果不残留 |
| 重复转换 | 合法输入 | 快点10次 | 历史与提示符合去重合同 |
| 重复收藏 | 未收藏 | 快点10次 | 最终 UI 与持久化一致 |
| 批量删除 | 多条选中 | 连续确认 | 单次执行,状态复位 |
| 恢复默认 | 自定义单位 | 确认后重启 | 默认单位持续生效 |
| 后台恢复 | 页面有草稿输入 | 切后台再返回 | 不闪退,状态符合设计 |
矩阵中的每一行都要记录设备、系统版本、包版本、前置数据、操作步骤和结果,避免"我点过了,没问题"的口头结论。
十九、把测试放进发布门禁而不是最后一天
建议流水线分四层:
text
提交门禁:
纯函数单测 + 静态检查
合并门禁:
服务层持久化测试 + 关键页面 UI 自动化
候选包门禁:
assembleApp / 签名包 + 安装启动核心流程卸载
上架门禁:
AppAnalyzer 上架前体检 + DevEco Testing 或 AGC 云测试
华为开发者测试服务把质量覆盖划分为单元测试、UI 测试、专项测试、上架预检和线上监测。对于这个项目,最划算的新增自动化不是再写几十个同类公式样例,而是补上启动、空态、非法输入、重复点击、持久化恢复和删除失败。
还要强调:本文只审阅了测试源码,没有在此发布流程中执行项目 ohosTest、安装包或真机测试,因此不能写"65 个用例全部通过""真机零崩溃"或"审核已通过"。
二十、最小落地顺序与结论
基于当前代码,建议按风险和收益排序:
- 保留现有 62 个业务逻辑用例和 1 个环境模板用例,并在 CI 真实执行、保存报告。
- 把 Ability 模板用例升级为真实启动冒烟。
- 为 HistoryService 的 5 秒去重注入时钟并直接单测。
- 为收藏、默认单位和 Preferences 恢复补服务测试。
- 建立
ConvertPage的合法、非法、恢复和重复点击 UI 回归。 - 建立历史空态、最后一条删除、批量删除和失败恢复回归。
- 给异步按钮增加
saving/deleting/toggling状态,再验证快速点击。 - 对候选包执行安装、冷启动、核心流程、后台恢复和卸载。
- 使用 AppAnalyzer、DevEco Testing 或 AGC 云测试补充稳定性、兼容性和上架预检。
"万能转换器"的算法层并不是测试荒地。温度换算、NaN、零利率、错误 JSON、进制非法输入等边界已经被写进源码用例。真正的缺口位于逻辑层之上:服务异步初始化、ArkUI 状态变化、Preferences 持久化、快速重复操作和候选包生命周期。
回归测试的目标也不是让用例数量漂亮,而是让每次修改后都能回答四个问题:能否启动,空数据能否自洽,异常输入能否安全收敛,用户连续操作能否得到唯一且可解释的结果。把这四条做成自动化门禁,才是工具类 HarmonyOS 应用稳定上架和持续迭代的底座。
官方资料可继续参考华为开发者的 HarmonyOS 开发者测试服务入门、DevEco Testing 资源与工具、开发态快速定位 AppFreeze 冻屏、提交 HarmonyOS 应用 与 应用市场审核政策。具体命令、设备能力和测试框架 API,应以项目目标 SDK、DevEco Studio 与 DevEco Testing 对应版本为准。

**AI 辅助声明:**本文在人工核对真实 HarmonyOS 测试源码、Ability 生命周期、服务初始化与输入校验后,使用 AI 辅助整理结构、润色表达并生成配图;测试数量、覆盖范围和未执行边界均以文中列出的源码证据为准。
CSDN-SERIES:ALL-163250228