HarmonyOS APP开发:测试用例编写规范与最佳实践
HarmonyOS APP开发:测试用例编写规范与最佳实践
📌 核心要点:好的测试用例不是"能跑就行",而是命名清晰、结构规范、场景覆盖全面、维护成本低。本文梳理鸿蒙项目中测试用例的编写规范,让你的测试代码从"凑合"变成"专业"。
一、背景与动机
你接过别人的项目吗?打开测试目录一看,全是test1、test2、testNormal这种命名,断言写了一堆assertEqual(result, true),连测的是什么都要猜半天。更离谱的是,有些测试明明跑不过,但之前的人直接@Skip跳过了,留了个注释"TODO: 以后修"——这个"以后",大概率就是永远不会。
测试代码也是代码,而且是需要长期维护的代码。如果测试写得像一团乱麻,最后的结果就是没人愿意跑测试、没人愿意改测试、测试形同虚设。
规范不是形式主义,是效率工具。一套好的测试规范,能让新成员快速理解测试意图,让CI流水线稳定运行,让重构时心里有底。
二、核心原理
2.1 测试用例质量模型
flowchart TB
A[测试用例质量] --> B[可读性]
A --> C[可靠性]
A --> D[可维护性]
A --> E[覆盖率]
B --> B1[命名清晰]
B --> B2[结构统一]
B --> B3[意图明确]
C --> C1[结果稳定]
C --> C2[无外部依赖]
C --> C3[无执行顺序依赖]
D --> D1[低耦合]
D --> D2[易扩展]
D --> D3[少重复]
E --> E1[正常路径]
E --> E2[边界条件]
E --> E3[异常路径]
classDef mainStyle fill:#4CAF50,stroke:#388E3C,color:#fff,font-weight:bold
classDef readStyle fill:#3498DB,stroke:#2980B9,color:#fff
classDef reliableStyle fill:#2ECC71,stroke:#27AE60,color:#fff
classDef maintainStyle fill:#F39C12,stroke:#E67E22,color:#fff
classDef coverStyle fill:#9B59B6,stroke:#8E44AD,color:#fff
class A mainStyle
class B,B1,B2,B3 readStyle
class C,C1,C2,C3 reliableStyle
class D,D1,D2,D3 maintainStyle
class E,E1,E2,E3 coverStyle
2.2 AAA测试模式
每个测试用例都应该遵循Arrange-Act-Assert三段式结构:
| 阶段 | 作用 | 示例 |
|---|---|---|
| Arrange | 准备测试数据和环境 | const calc = new Calculator() |
| Act | 执行被测方法 | const result = calc.add(2, 3) |
| Assert | 验证结果 | assertEqual(result, 5) |
这三段不是硬性要求用注释隔开,而是逻辑上必须清晰。如果一个测试用例你看了半天都分不清哪是准备、哪是执行、哪是验证,那这个测试就该重写。
2.3 测试命名规范
| 规范 | 格式 | 示例 |
|---|---|---|
| 方法名_场景_预期 | method_scenario_expected |
add_twoPositives_returnsSum |
| 给定_当_那么 | givenX_whenY_thenZ |
givenTwoNumbers_whenAdd_thenReturnsSum |
| 应该_当 | should_expected_when_condition |
should_returnSum_when_addTwoNumbers |
推荐第一种,简短且信息量够用。团队统一选一种就行,别混着用。
三、代码实战
3.1 基础用法:规范的测试文件结构
// PasswordValidator.ets - 业务代码
export class PasswordValidator {
private minLength: number
private requireUppercase: boolean
private requireNumber: boolean
private requireSpecial: boolean
constructor(options?: {
minLength?: number
requireUppercase?: boolean
requireNumber?: boolean
requireSpecial?: boolean
}) {
this.minLength = options?.minLength ?? 8
this.requireUppercase = options?.requireUppercase ?? true
this.requireNumber = options?.requireNumber ?? true
this.requireSpecial = options?.requireSpecial ?? false
}
validate(password: string): { valid: boolean; errors: string[] } {
const errors: string[] = []
if (password.length < this.minLength) {
errors.push(`密码长度不能少于${this.minLength}位`)
}
if (this.requireUppercase && !/[A-Z]/.test(password)) {
errors.push('密码必须包含大写字母')
}
if (this.requireNumber && !/[0-9]/.test(password)) {
errors.push('密码必须包含数字')
}
if (this.requireSpecial && !/[!@#$%^&*]/.test(password)) {
errors.push('密码必须包含特殊字符')
}
return { valid: errors.length === 0, errors: errors }
}
}
// PasswordValidatorTest.ets - 规范的测试代码
import { describe, it, beforeEach } from '@ohos/hypium'
import { PasswordValidator } from '../../../main/ets/utils/PasswordValidator'
export default function passwordValidatorTest() {
describe('PasswordValidator密码验证器', () => {
let validator: PasswordValidator
beforeEach(() => {
validator = new PasswordValidator()
})
describe('validate密码校验', () => {
it('validate_合法密码_返回valid', 0, () => {
const result = validator.validate('Test1234')
assertEqual(result.valid, true)
assertEqual(result.errors.length, 0)
})
it('validate_密码过短_返回长度错误', 0, () => {
const result = validator.validate('Te1')
assertEqual(result.valid, false)
assertEqual(result.errors.length > 0, true)
})
it('validate_缺少大写字母_返回大写错误', 0, () => {
const result = validator.validate('test1234')
assertEqual(result.valid, false)
})
it('validate_缺少数字_返回数字错误', 0, () => {
const result = validator.validate('Testtest')
assertEqual(result.valid, false)
})
it('validate_空密码_返回多个错误', 0, () => {
const result = validator.validate('')
assertEqual(result.valid, false)
assertEqual(result.errors.length >= 2, true)
})
})
describe('validate自定义规则', () => {
it('validate_自定义最小长度_按新规则校验', 0, () => {
const customValidator = new PasswordValidator({ minLength: 12 })
const result = customValidator.validate('Test1234')
assertEqual(result.valid, false)
})
it('validate_不要求大写_纯小写也通过', 0, () => {
const customValidator = new PasswordValidator({ requireUppercase: false })
const result = customValidator.validate('test1234')
assertEqual(result.valid, true)
})
it('validate_要求特殊字符_缺少则失败', 0, () => {
const customValidator = new PasswordValidator({ requireSpecial: true })
const result = customValidator.validate('Test1234')
assertEqual(result.valid, false)
})
})
})
}
3.2 进阶用法:边界条件与异常路径覆盖
// DateUtils.ets - 业务代码
export class DateUtils {
static formatDuration(ms: number): string {
if (ms < 0) {
throw new Error('时长不能为负数')
}
if (ms === 0) return '0秒'
const seconds = Math.floor(ms / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
if (days > 0) return `${days}天${hours % 24}小时`
if (hours > 0) return `${hours}小时${minutes % 60}分钟`
if (minutes > 0) return `${minutes}分钟${seconds % 60}秒`
return `${seconds}秒`
}
static isLeapYear(year: number): boolean {
if (year < 1) {
throw new Error('年份必须为正整数')
}
return (year % 4 === 0 && year % 100 !== 0) || (year % 400 === 0)
}
static getDaysInMonth(year: number, month: number): number {
if (month < 1 || month > 12) {
throw new Error('月份必须在1-12之间')
}
const daysInMonth = [31, DateUtils.isLeapYear(year) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
return daysInMonth[month - 1]
}
}
// DateUtilsTest.ets - 边界条件全覆盖
import { describe, it } from '@ohos/hypium'
import { DateUtils } from '../../../main/ets/utils/DateUtils'
export default function dateUtilsTest() {
describe('DateUtils日期工具', () => {
describe('formatDuration时长格式化', () => {
it('formatDuration_零毫秒_返回0秒', 0, () => {
assertEqual(DateUtils.formatDuration(0), '0秒')
})
it('formatDuration_不足一分钟_返回秒', 0, () => {
assertEqual(DateUtils.formatDuration(5000), '5秒')
})
it('formatDuration_刚好一分钟_返回1分钟0秒', 0, () => {
assertEqual(DateUtils.formatDuration(60000), '1分钟0秒')
})
it('formatDuration_一小时以上_返回小时分钟', 0, () => {
assertEqual(DateUtils.formatDuration(3720000), '1小时2分钟')
})
it('formatDuration_一天以上_返回天小时', 0, () => {
assertEqual(DateUtils.formatDuration(93600000), '1天2小时')
})
it('formatDuration_负数_抛出异常', 0, () => {
assertThrowError(() => {
DateUtils.formatDuration(-1)
})
})
})
describe('isLeapYear闰年判断', () => {
it('isLeapYear_普通闰年_返回true', 0, () => {
assertEqual(DateUtils.isLeapYear(2024), true)
})
it('isLeapYear_世纪非闰年_返回false', 0, () => {
assertEqual(DateUtils.isLeapYear(1900), false)
})
it('isLeapYear_400年闰年_返回true', 0, () => {
assertEqual(DateUtils.isLeapYear(2000), true)
})
it('isLeapYear_普通非闰年_返回false', 0, () => {
assertEqual(DateUtils.isLeapYear(2023), false)
})
it('isLeapYear_零年_抛出异常', 0, () => {
assertThrowError(() => {
DateUtils.isLeapYear(0)
})
})
})
describe('getDaysInMonth每月天数', () => {
it('getDaysInMonth_二月平年_28天', 0, () => {
assertEqual(DateUtils.getDaysInMonth(2023, 2), 28)
})
it('getDaysInMonth_二月闰年_29天', 0, () => {
assertEqual(DateUtils.getDaysInMonth(2024, 2), 29)
})
it('getDaysInMonth_四月_30天', 0, () => {
assertEqual(DateUtils.getDaysInMonth(2024, 4), 30)
})
it('getDaysInMonth_一月_31天', 0, () => {
assertEqual(DateUtils.getDaysInMonth(2024, 1), 31)
})
it('getDaysInMonth_月份超范围_抛出异常', 0, () => {
assertThrowError(() => {
DateUtils.getDaysInMonth(2024, 13)
})
})
it('getDaysInMonth_月份为零_抛出异常', 0, () => {
assertThrowError(() => {
DateUtils.getDaysInMonth(2024, 0)
})
})
})
})
}
3.3 完整示例:测试用例审查清单工具
// TestCaseReview.ets - 测试用例质量审查工具
export interface TestCaseInfo {
name: string
hasArrange: boolean
hasAct: boolean
hasAssert: boolean
hasBoundaryTest: boolean
hasExceptionTest: boolean
namingConvention: string
estimatedDuration: number
}
export interface ReviewResult {
totalCases: number
passedCases: number
failedCases: number
warnings: string[]
score: number
}
export class TestCaseReviewer {
private cases: TestCaseInfo[] = []
addCase(caseInfo: TestCaseInfo): void {
this.cases.push(caseInfo)
}
review(): ReviewResult {
const warnings: string[] = []
let passedCount = 0
for (const testCase of this.cases) {
if (!testCase.hasArrange || !testCase.hasAct || !testCase.hasAssert) {
warnings.push(`${testCase.name}: 缺少AAA结构(Arrange=${testCase.hasArrange}, Act=${testCase.hasAct}, Assert=${testCase.hasAssert})`)
}
if (!testCase.hasBoundaryTest) {
warnings.push(`${testCase.name}: 缺少边界条件测试`)
}
if (!testCase.hasExceptionTest) {
warnings.push(`${testCase.name}: 缺少异常路径测试`)
}
if (testCase.namingConvention !== 'method_scenario_expected') {
warnings.push(`${testCase.name}: 命名不符合规范(当前: ${testCase.namingConvention})`)
}
if (testCase.estimatedDuration > 1000) {
warnings.push(`${testCase.name}: 单个用例耗时超过1秒(${testCase.estimatedDuration}ms),考虑拆分`)
}
const caseWarnings = warnings.filter(w => w.startsWith(testCase.name))
if (caseWarnings.length === 0) {
passedCount++
}
}
const score = this.cases.length > 0
? Math.round((passedCount / this.cases.length) * 100)
: 0
return {
totalCases: this.cases.length,
passedCases: passedCount,
failedCases: this.cases.length - passedCount,
warnings: warnings,
score: score
}
}
reset(): void {
this.cases = []
}
}
// TestCaseReviewerTest.ets
import { describe, it, beforeEach } from '@ohos/hypium'
import { TestCaseReviewer } from '../../../main/ets/utils/TestCaseReview'
export default function testCaseReviewerTest() {
describe('TestCaseReviewer审查工具', () => {
let reviewer: TestCaseReviewer
beforeEach(() => {
reviewer = new TestCaseReviewer()
})
it('review_全部合规_满分', 0, () => {
reviewer.addCase({
name: 'add_twoPositives_returnsSum',
hasArrange: true,
hasAct: true,
hasAssert: true,
hasBoundaryTest: true,
hasExceptionTest: true,
namingConvention: 'method_scenario_expected',
estimatedDuration: 50
})
const result = reviewer.review()
assertEqual(result.score, 100)
assertEqual(result.warnings.length, 0)
})
it('review_缺少边界测试_扣分', 0, () => {
reviewer.addCase({
name: 'add_normalCase_returnsSum',
hasArrange: true,
hasAct: true,
hasAssert: true,
hasBoundaryTest: false,
hasExceptionTest: true,
namingConvention: 'method_scenario_expected',
estimatedDuration: 50
})
const result = reviewer.review()
assertEqual(result.score, 0)
assertEqual(result.warnings.length, 1)
})
it('review_命名不规范_警告', 0, () => {
reviewer.addCase({
name: 'test1',
hasArrange: true,
hasAct: true,
hasAssert: true,
hasBoundaryTest: true,
hasExceptionTest: true,
namingConvention: 'unknown',
estimatedDuration: 50
})
const result = reviewer.review()
assertEqual(result.warnings.length, 1)
})
it('review_空用例列表_零分', 0, () => {
const result = reviewer.review()
assertEqual(result.totalCases, 0)
assertEqual(result.score, 0)
})
})
}
四、踩坑与注意事项
坑点1:一个测试只验证一件事
别在一个测试用例里塞五六个断言验证五六个不相关的功能。一个测试挂了,你不知道是哪个断言出的问题。一个测试只关注一个行为,断言可以多个,但必须围绕同一个验证点。
坑点2:别在测试里写条件逻辑
if (result > 0) { assertEqual(...) } else { assertEqual(...) }——这种写法意味着你的测试有两条路径,但每次只走一条,另一条永远没被验证到。测试应该是线性的,不需要条件分支。
坑点3:测试之间不能有依赖
测试用例的执行顺序不应该影响结果。如果测试B依赖测试A先跑完才能通过,那A挂了B也跟着挂,而且你很难排查是A的问题还是B的问题。每个测试用例都应该是独立可运行的,beforeEach就是干这个的。
坑点4:别测私有方法
私有方法是实现细节,不是契约。你今天测了私有方法,明天重构改了内部实现,测试就挂了——但功能其实完全没问题。只测公开接口,私有方法通过公开方法间接覆盖。
坑点5:浮点数比较要用容差
assertEqual(0.1 + 0.2, 0.3) 这个断言会失败,因为浮点数精度问题,0.1 + 0.2 实际等于 0.30000000000000004。比较浮点数时要用容差:assertEqual(Math.abs(0.1 + 0.2 - 0.3) < 0.0001, true)。
坑点6:测试数据别硬编码业务敏感值
测试里写assertEqual(user.age, 25),如果业务规则变了,你要翻遍所有测试改数字。测试数据用常量或工厂方法生成,改一处就够了。
坑点7:别为了覆盖率写无意义测试
100%覆盖率不是目标,有意义才是。写一个assertEqual(true, true)确实能覆盖一行代码,但毫无价值。覆盖率是手段不是目的,先保证关键路径和边界条件被覆盖,再追求覆盖率数字。
五、HarmonyOS 6适配说明
API差异表
| 功能/接口 | HarmonyOS 5 | HarmonyOS 6 | 变更说明 |
|---|---|---|---|
| 测试模板 | 手动创建 | DevEco内置模板 | 新建测试文件时可选择模板 |
| 命名检查 | 无 | Lint规则 | 内置测试命名规范检查 |
| AAA结构 | 无提示 | 结构标记 | 支持Arrange/Act/Assert注释标记 |
| 测试数据 | 硬编码 | @TestData | 支持外部数据源 |
行为变更
-
测试模板生成:DevEco Studio 6中新建测试文件时,可以选择不同的测试模板(基础测试、参数化测试、异步测试等),不再需要从零开始写。
-
命名规范Lint:新增测试命名Lint规则,不符合团队规范的测试用例名会有警告提示,可以在Settings中自定义规则。
-
@TestData数据源:新增
@TestData装饰器,可以从JSON文件或数据库加载测试数据,不再需要在测试代码中硬编码大量数据。
适配代码
// HarmonyOS 6 @TestData外部数据源
import { describe, it, TestData } from '@ohos/hypium'
export default function userDataTest() {
describe('用户数据验证', () => {
@TestData('test_data/users.json')
it('validate_外部数据_批量验证', 0, (data: Record<string, string>[]) => {
for (const item of data) {
const result = UserValidator.validate(item.input)
assertEqual(result.valid, item.expectedValid)
}
})
})
}
六、总结
| 维度 | 评价 |
|---|---|
| 学习难度 | ⭐⭐ |
| 使用频率 | ⭐⭐⭐⭐⭐ |
| 重要程度 | ⭐⭐⭐⭐⭐ |
测试规范的核心就八个字:清晰、独立、全面、稳定。命名让人一眼看懂意图,用例之间互不干扰,正常路径边界条件异常路径都覆盖到,跑一百次结果都一样。这不是什么高深的东西,但需要你每次写测试时都自觉遵守。团队里定好规范,写进代码审查清单,不符合规范的测试不予合并——习惯是管出来的,不是等出来的。
- 点赞
- 收藏
- 关注作者
评论(0)