HarmonyOS APP开发:测试用例编写规范与最佳实践

举报
Jack20 发表于 2026/06/24 15:10:55 2026/06/24
【摘要】 HarmonyOS APP开发:测试用例编写规范与最佳实践📌 核心要点:好的测试用例不是"能跑就行",而是命名清晰、结构规范、场景覆盖全面、维护成本低。本文梳理鸿蒙项目中测试用例的编写规范,让你的测试代码从"凑合"变成"专业"。 一、背景与动机你接过别人的项目吗?打开测试目录一看,全是test1、test2、testNormal这种命名,断言写了一堆assertEqual(result,...

HarmonyOS APP开发:测试用例编写规范与最佳实践

📌 核心要点:好的测试用例不是"能跑就行",而是命名清晰、结构规范、场景覆盖全面、维护成本低。本文梳理鸿蒙项目中测试用例的编写规范,让你的测试代码从"凑合"变成"专业"。


一、背景与动机

你接过别人的项目吗?打开测试目录一看,全是test1test2testNormal这种命名,断言写了一堆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 支持外部数据源

行为变更

  1. 测试模板生成:DevEco Studio 6中新建测试文件时,可以选择不同的测试模板(基础测试、参数化测试、异步测试等),不再需要从零开始写。

  2. 命名规范Lint:新增测试命名Lint规则,不符合团队规范的测试用例名会有警告提示,可以在Settings中自定义规则。

  3. @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)
      }
    })
  })
}

六、总结

维度 评价
学习难度 ⭐⭐
使用频率 ⭐⭐⭐⭐⭐
重要程度 ⭐⭐⭐⭐⭐

测试规范的核心就八个字:清晰、独立、全面、稳定。命名让人一眼看懂意图,用例之间互不干扰,正常路径边界条件异常路径都覆盖到,跑一百次结果都一样。这不是什么高深的东西,但需要你每次写测试时都自觉遵守。团队里定好规范,写进代码审查清单,不符合规范的测试不予合并——习惯是管出来的,不是等出来的。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。