前言

说实话,大部分鸿蒙开发者对测试这块不太重视。原因也简单——赶进度的时候谁有空写测试?但等到线上出了 Bug 被用户骂的时候,才后悔当初没多写几个用例。

鸿蒙的测试框架 @ohos.hypium 现在已经比较成熟了,覆盖了单元测试、UI 自动化测试和性能测试。我最近给团队的新闻列表模块补了一套完整的测试,跑通之后感觉确实靠谱,今天分享一下整个链路。

鸿蒙测试框架概览

A clean Notion-style diagram showing the HarmonyOS

@ohos.hypium 是 HarmonyOS 的官方测试框架,提供三种测试能力:

测试类型 用途 执行位置
单元测试 函数、类、业务逻辑 DevEco Studio 内
UI 测试 组件渲染、交互、截图对比 模拟器/真机
性能测试 FPS、内存、启动耗时 真机(推荐)

项目里测试代码放在 ohosTest 目录下,跟 src/main 同级:

entry/
├── src/
│   ├── main/                  # 业务代码
│   │   └── ets/
│   └── ohosTest/              # 测试代码
│       └── ets/
│           ├── test/
│           │   ├── List.test.ets
│           │   └── UiTest.test.ets
│           └── testability/
│               └── pages/

单元测试

单元测试是最基础的,用来验证函数逻辑、数据处理、业务规则。

基本函数测试

import { describe, it, expect } from '@ohos/hypium'
import { NewsUtils } from '../main/ets/utils/NewsUtils'

export default function unitTests() {
  describe('NewsUtils 测试', () => {

    it('标题截断:超长标题应该截断并加省略号', 0, () => {
      const longTitle = '这是一条非常非常长的新闻标题用来测试截断逻辑是否正确工作'
      const result = NewsUtils.truncateTitle(longTitle, 20)
      expect(result.length).assertLessOrEqual(23) // 20 + '...'
      expect(result.endsWith('...')).assertTrue()
    })

    it('标题截断:短标题不应该被截断', 0, () => {
      const shortTitle = '今日要闻'
      const result = NewsUtils.truncateTitle(shortTitle, 20)
      expect(result).assertEqual('今日要闻')
    })

    it('时间格式化:今天应该显示时分', 0, () => {
      const today = new Date()
      today.setHours(14, 30, 0)
      const result = NewsUtils.formatTime(today)
      expect(result).assertEqual('14:30')
    })

    it('时间格式化:昨天应该显示"昨天"', 0, () => {
      const yesterday = new Date()
      yesterday.setDate(yesterday.getDate() - 1)
      const result = NewsUtils.formatTime(yesterday)
      expect(result).assertEqual('昨天')
    })
  })
}

异步测试

A Notion-style code snippet visualization. Show Ty

网络请求、数据库操作这些异步逻辑,用 async/await 配合 expect 来测试:

import { describe, it, expect, beforeAll, afterAll } from '@ohos/hypium'
import { NewsRepository } from '../main/ets/repository/NewsRepository'

export default function asyncTests() {
  describe('NewsRepository 异步测试', () => {

    it('获取新闻列表应返回非空数组', 0, async () => {
      const repo = new NewsRepository()
      const newsList = await repo.fetchNewsList(1, 20)

      expect(newsList).assertNotNull()
      expect(newsList.length).assertLarger(0)
      expect(newsList[0].title).assertNotNull()
    })

    it('分页参数正确传递', 0, async () => {
      const repo = new NewsRepository()

      const page1 = await repo.fetchNewsList(1, 10)
      const page2 = await repo.fetchNewsList(2, 10)

      // 两页数据不应该完全相同
      expect(page1[0].id).assertNotEqual(page2[0].id)
    })

    it('非法页码应返回空数组', 0, async () => {
      const repo = new NewsRepository()
      const result = await repo.fetchNewsList(-1, 10)
      expect(result.length).assertEqual(0)
    })
  })
}

Mock 能力

依赖外部服务的逻辑,用 Mock 隔离掉:

A Notion-style flowchart illustrating an asynchron

import { describe, it, expect } from '@ohos/hypium'
import { NewsViewModel } from '../main/ets/viewmodel/NewsViewModel'

export default function mockTests() {
  describe('NewsViewModel Mock 测试', () => {

    it('刷新后 shouldUpdate 标记应该被清除', 0, () => {
      const vm = new NewsViewModel()

      // Mock 数据源
      const mockData = [
        { id: 1, title: '新闻1', category: 'tech', timestamp: Date.now() },
        { id: 2, title: '新闻2', category: 'sports', timestamp: Date.now() },
      ]

      vm.setMockData(mockData)
      expect(vm.shouldUpdate).assertTrue()

      vm.refreshData()
      expect(vm.shouldUpdate).assertFalse()
      expect(vm.displayList.length).assertEqual(2)
    })

    it('按分类筛选', 0, () => {
      const vm = new NewsViewModel()
      vm.setMockData([
        { id: 1, title: 'AI 突破', category: 'tech', timestamp: Date.now() },
        { id: 2, title: '足球赛果', category: 'sports', timestamp: Date.now() },
        { id: 3, title: '5G 进展', category: 'tech', timestamp: Date.now() },
      ])

      vm.filterByCategory('tech')
      expect(vm.displayList.length).assertEqual(2)
      expect(vm.displayList[0].title).assertEqual('AI 突破')
    })
  })
}

UI 测试

UI 测试用来验证组件渲染是否正确、用户交互是否符合预期。鸿蒙的 UI 测试通过 UiDriverUiComponent 来操作。

import { describe, it, expect, beforeAll, afterAll } from '@ohos/hypium'
import { UiDriver, UiComponent, DriverExtensionApi } from '@kit.TestKit'

export default function uiTests() {
  let driver: UiDriver

  beforeAll(() => {
    driver = new UiDriver()
  })

  afterAll(() => {
    driver.destroy()
  })

  describe('新闻列表页 UI 测试', () => {

    it('页面加载后应显示新闻列表', 0, async () => {
      // 等待页面渲染完成
      await driver.delayMs(2000)

      // 通过文本查找组件
      const listTitle = await driver.findComponentByText('今日新闻')
      expect(listTitle).assertNotNull()

      // 验证列表项存在
      const firstItem = await driver.findComponentByText('新闻标题1')
      expect(firstItem).assertNotNull()
    })

    it('下拉刷新应该更新列表', 0, async () => {
      // 获取列表组件
      const list = await driver.findComponentByType('List')
      expect(list).assertNotNull()

      // 模拟下拉刷新:从顶部往下拖
      await driver.swipe(500, 300, 500, 800, 20)
      await driver.delayMs(1500)

      // 刷新后检查刷新提示
      const refreshText = await driver.findComponentByText('已刷新')
      // 刷新提示可能一闪而过,这里检查列表数据已更新即可
      const updatedItem = await driver.findComponentByText('新闻标题1')
      expect(updatedItem).assertNotNull()
    })

    it('点击新闻项应跳转到详情页', 0, async () => {
      const newsItem = await driver.findComponentByText('新闻标题1')
      await newsItem.click()
      await driver.delayMs(1000)

      // 验证详情页的返回按钮存在
      const backBtn = await driver.findComponentByText('返回')
      expect(backBtn).assertNotNull()

      // 返回上一页
      await backBtn.click()
      await driver.delayMs(500)
    })

    it('截图对比测试', 0, async () => {
      await driver.delayMs(1000)

      // 截取当前屏幕
      const screenshot = await driver.screenCap()
      expect(screenshot).assertNotNull()

      // 保存截图用于后续对比
      // 首次运行生成基准图,后续运行与基准图对比
      const compareResult = await driver.compareScreen(screenshot, {
        threshold: 0.01, // 允许 1% 的像素差异
        baselinePath: 'src/ohosTest/resources/baseline/news_list.png'
      })
      expect(compareResult.isMatch).assertTrue()
    })
  })
}

性能测试

性能测试在真机上跑比较准,模拟器数据不太靠谱。

import { describe, it, expect, beforeAll } from '@ohos/hypium'
import { performanceTest, PerfMetrics } from '@kit.TestKit'

export default function perfTests() {

  describe('新闻列表页性能测试', () => {

    it('列表滑动帧率应大于 55fps', 0, async () => {
      const driver = new UiDriver()

      // 开始录制帧率
      await performanceTest.startFrameMonitor()

      // 模拟快速滑动列表
      const list = await driver.findComponentByType('List')
      for (let i = 0; i < 5; i++) {
        await driver.swipe(500, 800, 500, 200, 10)
        await driver.delayMs(300)
      }

      // 停止录制,获取帧率数据
      const metrics: PerfMetrics = await performanceTest.stopFrameMonitor()

      expect(metrics.averageFps).assertLarger(55)
      expect(metrics.jankCount).assertLess(5) // 卡顿次数少于 5 次
      expect(metrics.maxFrameTime).assertLess(33) // 最大帧耗时小于 33ms(约30fps)

      driver.destroy()
    })

    it('页面启动耗时应该小于 500ms', 0, async () => {
      // 启动耗时监控
      const startTime = performanceTest.getTimestamp()

      // 模拟页面导航
      const driver = new UiDriver()
      await driver.navigateTo('pages/NewsList')
      await driver.waitUntilComponentVisible('news_list_container', 3000)

      const endTime = performanceTest.getTimestamp()
      const loadTime = endTime - startTime

      expect(loadTime).assertLess(500) // 启动耗时 < 500ms

      driver.destroy()
    })

    it('100条数据加载后内存增量应小于 30MB', 0, async () => {
      const memBefore = performanceTest.getMemoryUsage()

      // 加载 100 条数据
      const driver = new UiDriver()
      const loadMore = await driver.findComponentByText('加载更多')
      for (let i = 0; i < 5; i++) {
        await loadMore.click()
        await driver.delayMs(500)
      }

      const memAfter = performanceTest.getMemoryUsage()
      const memDelta = memAfter.totalPss - memBefore.totalPss

      // 内存增量不超过 30MB
      expect(memDelta).assertLess(30 * 1024) // 单位 KB

      driver.destroy()
    })
  })
}

测试配置与执行

在 DevEco Studio 里运行测试,需要在 build-profile.json5 里确保 ohosTest 模块被包含:

{
  "modules": [
    {
      "name": "entry",
      "srcPath": "./entry",
      "targets": [
        { "name": "default", "applyToProducts": ["default"] }
      ]
    }
  ]
}

运行方式:

  • IDE 内运行:右键测试文件 → Run ‘ohosTest’
  • 命令行运行hvigorw assembleHap --no-daemon 然后通过 hdc 安装到设备上执行

一些实用建议

单元测试的覆盖率目标:业务逻辑层(ViewModel、Repository)争取 80% 以上,UI 层 60% 就够了。别追求 100%,那会花太多时间在边界条件上。

UI 测试不稳定怎么办:UI 测试受设备性能影响很大,delayMs 的等待时间不要太抠,宁可多等一会儿。用 waitForComponent 代替硬编码的等待时间更靠谱。

性能测试的基准线:第一次跑的时候把数据记录下来作为 baseline,后续 CI 每次跑完跟 baseline 对比,偏差超过 10% 就告警。

别在 CI 上跑真机性能测试:CI 的模拟器性能跟真机差太多,性能数据没有参考意义。建议搞一台专门的测试机,定期跑性能回归。

写测试确实花时间,但那种"改了一行代码,跑一遍测试全绿"的安心感,是任何东西都换不来的。我的建议是从单元测试开始,先把业务逻辑的测试补上,UI 测试慢慢来。别一上来就想搞全链路覆盖,那样大概率坚持不下去。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐