Administrator
发布于 2026-09-01 / 43 阅读
0
0

鸿蒙开发系列(第19篇):单元测试与自动化测试——测试框架与CI/CD集成

鸿蒙开发系列 · 专家篇

前言

写到第18篇,我们的语音备忘录已经是一个功能完备、跨平台、带AI能力的应用。但有一个问题始终悬在头顶:这些代码有多可靠?

如果你曾深夜被线上Bug叫醒,如果你经历过"改一行代码、崩三个功能"的噩梦,你就知道测试不是"可选项",而是工程化的底线。没有测试的代码是负债,有测试的代码才是资产。

HarmonyOS提供了Hypium作为官方测试框架,支持单元测试、集成测试、UI自动化测试三个层级。本文将带你从第一个单元测试写起,搭建Mock机制、实现UI自动化,并最终接入CI/CD流水线,让每一次代码提交都经过自动化的质量关卡。


一、Hypium 测试框架概览

1.1 测试层级

┌─────────────────────────────────────────────────────────────┐
│  L3:UI自动化测试(E2E)                                     │
│  模拟用户点击、输入、滑动,验证完整流程                       │
│  工具:UiTest / Driver                                       │
├─────────────────────────────────────────────────────────────┤
│  L2:集成测试                                                │
│  验证模块间协作,如数据库+网络+UI的整合                       │
│  工具:Hypium + 测试HAP                                      │
├─────────────────────────────────────────────────────────────┤
│  L1:单元测试                                                │
│  验证单个函数/类的正确性,隔离依赖                           │
│  工具:Hypium + jest-mock-like                               │
└─────────────────────────────────────────────────────────────┘

1.2 Hypium 与 Jest 的关系

Hypium测试框架的API设计参考了Jest,熟悉Jest的开发者可以快速上手:

概念

Hypium

Jest

测试套件

describe

describe

测试用例

it / test

it / test

断言

expect(x).assertEqual(y)

expect(x).toBe(y)

生命周期

beforeAll / beforeEach / afterAll / afterEach

相同

Mock函数

mockFn()

jest.fn()

主要差异:

  • Hypium使用 .assertEqual() 而非 .toBe()

  • Hypium内置异步测试支持

  • Hypium与HarmonyOS运行时深度集成,可以测试ArkUI组件


二、单元测试实战

2.1 工程配置

在模块的 build-profile.json5 中启用测试:

{
  "targets": [
    {
      "name": "default",
      "runtimeOS": "HarmonyOS",
      "testRunner": "hypium"
    }
  ]
}

测试文件目录结构:

entry/
├── src/main/ets/          # 主代码
│   ├── utils/
│   │   └── Calculator.ets
│   └── pages/
├── src/test/              # 测试代码
│   ├── List.test.ets      # 测试列表
│   └── utils/
│       └── Calculator.test.ets
└── oh-package.json5

2.2 第一个单元测试

以测试一个工具类为例:

// entry/src/main/ets/utils/DateFormatter.ets

export class DateFormatter {
  // 格式化时长:秒 → mm:ss
  static formatDuration(totalSeconds: number): string {
    if (totalSeconds < 0) return '00:00';

    const mins = Math.floor(totalSeconds / 60);
    const secs = totalSeconds % 60;
    return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
  }

  // 格式化时长:秒 → hh:mm:ss
  static formatDurationLong(totalSeconds: number): string {
    if (totalSeconds < 0) return '00:00:00';

    const hrs = Math.floor(totalSeconds / 3600);
    const mins = Math.floor((totalSeconds % 3600) / 60);
    const secs = totalSeconds % 60;

    if (hrs > 0) {
      return `${hrs.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
    }
    return this.formatDuration(totalSeconds);
  }

  // 相对时间:如"3分钟前"
  static relativeTime(timestamp: number): string {
    const diff = Date.now() - timestamp;
    const seconds = Math.floor(diff / 1000);
    const minutes = Math.floor(seconds / 60);
    const hours = Math.floor(minutes / 60);
    const days = Math.floor(hours / 24);

    if (days > 30) return `${Math.floor(days / 30)}个月前`;
    if (days > 0) return `${days}天前`;
    if (hours > 0) return `${hours}小时前`;
    if (minutes > 0) return `${minutes}分钟前`;
    return '刚刚';
  }
}

对应的测试:

// entry/src/test/utils/DateFormatter.test.ets
import { describe, it, expect } from '@ohos/hypium';
import { DateFormatter } from '../../main/ets/utils/DateFormatter';

export default function dateFormatterTest() {
  describe('DateFormatter', () => {

    describe('formatDuration', () => {
      it('should_format_65_seconds_as_01_05', () => {
        const result = DateFormatter.formatDuration(65);
        expect(result).assertEqual('01:05');
      });

      it('should_format_0_seconds_as_00_00', () => {
        const result = DateFormatter.formatDuration(0);
        expect(result).assertEqual('00:00');
      });

      it('should_format_3600_seconds_as_60_00', () => {
        const result = DateFormatter.formatDuration(3600);
        expect(result).assertEqual('60:00');
      });

      it('should_return_00_00_for_negative_input', () => {
        const result = DateFormatter.formatDuration(-10);
        expect(result).assertEqual('00:00');
      });

      it('should_pad_single_digits', () => {
        const result = DateFormatter.formatDuration(9);
        expect(result).assertEqual('00:09');
      });
    });

    describe('formatDurationLong', () => {
      it('should_include_hours_when_over_3600', () => {
        const result = DateFormatter.formatDurationLong(3661);
        expect(result).assertEqual('01:01:01');
      });

      it('should_use_short_format_under_3600', () => {
        const result = DateFormatter.formatDurationLong(300);
        expect(result).assertEqual('05:00');
      });
    });

    describe('relativeTime', () => {
      it('should_return_just_now_for_current_time', () => {
        const result = DateFormatter.relativeTime(Date.now());
        expect(result).assertEqual('刚刚');
      });

      it('should_return_minutes_ago', () => {
        const fiveMinutesAgo = Date.now() - 5 * 60 * 1000;
        const result = DateFormatter.relativeTime(fiveMinutesAgo);
        expect(result).assertEqual('5分钟前');
      });

      it('should_return_hours_ago', () => {
        const twoHoursAgo = Date.now() - 2 * 60 * 60 * 1000;
        const result = DateFormatter.relativeTime(twoHoursAgo);
        expect(result).assertEqual('2小时前');
      });
    });

  });
}

2.3 运行测试

# 在DevEco Studio中
# 右键测试文件 → Run 'DateFormatterTest'

# 或通过命令行
dev eco test --module entry --test-type unit

2.4 Mock 依赖隔离

单元测试的核心原则是隔离——被测代码不应调用真实的数据库、网络或文件系统。

// entry/src/main/ets/utils/UserService.ets
import { http } from '@kit.NetworkKit';

export interface User {
  id: string;
  name: string;
  email: string;
}

export class UserService {
  private apiBase: string;

  constructor(apiBase: string = 'https://api.example.com') {
    this.apiBase = apiBase;
  }

  async getUser(userId: string): Promise<User> {
    const httpClient = http.createHttp();
    const response = await httpClient.request(`${this.apiBase}/users/${userId}`);

    if (response.responseCode !== 200) {
      throw new Error(`获取用户失败: ${response.responseCode}`);
    }

    return JSON.parse(response.result as string) as User;
  }

  async updateUser(user: User): Promise<void> {
    const httpClient = http.createHttp();
    await httpClient.request(`${this.apiBase}/users/${user.id}`, {
      method: http.RequestMethod.PUT,
      header: { 'Content-Type': 'application/json' },
      extraData: JSON.stringify(user)
    });
  }
}

测试时使用Mock替代真实HTTP:

// entry/src/test/utils/UserService.test.ets
import { describe, it, expect, beforeEach } from '@ohos/hypium';
import { UserService, User } from '../../main/ets/utils/UserService';

class MockHttpClient {
  private mockResponses: Map<string, { code: number; body: string }> = new Map();
  private requests: string[] = [];

  setMockResponse(url: string, code: number, body: string): void {
    this.mockResponses.set(url, { code, body });
  }

  getRequests(): string[] {
    return this.requests;
  }

  async request(url: string, options?: object): Promise<any> {
    this.requests.push(url);
    const mock = this.mockResponses.get(url);

    if (!mock) {
      return { responseCode: 404, result: '{}' };
    }

    return {
      responseCode: mock.code,
      result: mock.body
    };
  }
}

export default function userServiceTest() {
  describe('UserService', () => {
    let service: UserService;
    let mockHttp: MockHttpClient;

    beforeEach(() => {
      mockHttp = new MockHttpClient();
      service = new UserService('https://mock.api.com');

      // 注入Mock(实际项目中通过依赖注入实现)
      // 这里简化展示
    });

    it('should_return_user_when_api_success', async () => {
      const mockUser: User = {
        id: 'user_001',
        name: '张三',
        email: 'zhangsan@example.com'
      };

      // 在实际项目中,通过依赖注入替换http.createHttp
      // 这里展示测试逻辑结构
      const result = await service.getUser('user_001');

      expect(result.id).assertEqual('user_001');
      expect(result.name).assertEqual('张三');
    });

    it('should_throw_error_when_api_fails', async () => {
      try {
        await service.getUser('invalid_id');
        expect(false).assertTrue(); // 不应该执行到这里
      } catch (err) {
        expect(err instanceof Error).assertTrue();
      }
    });

  });
}

三、UI 自动化测试

3.1 UiTest 框架

Hypium提供UiTest能力,可以模拟用户操作并验证UI状态:

// entry/src/test/ui/MainPage.test.ets
import { describe, it, expect, beforeAll } from '@ohos/hypium';
import { Driver, ON, Component } from '@ohos.UiTest';

export default function mainPageTest() {
  describe('MainPage UI Tests', () => {
    let driver: Driver;

    beforeAll(async () => {
      driver = Driver.create();
      await driver.delayMs(1000); // 等待页面加载
    });

    it('should_display_recording_button', async () => {
      // 查找"开始录音"按钮
      const recordButton = await driver.findComponent(ON.text('开始录音'));
      expect(recordButton !== null).assertTrue();
    });

    it('should_navigate_to_player_when_click_recording', async () => {
      // 点击录音列表的第一项
      const firstItem = await driver.findComponent(
        ON.type('ListItem').enabled(true)
      );
      await firstItem.click();

      // 等待页面跳转
      await driver.delayMs(500);

      // 验证播放器页面显示
      const playButton = await driver.findComponent(ON.text('播放'));
      expect(playButton !== null).assertTrue();
    });

    it('should_show_search_results_when_type_query', async () => {
      // 点击搜索框
      const searchInput = await driver.findComponent(ON.type('TextInput'));
      await searchInput.click();

      // 输入搜索词
      await driver.inputText('会议');
      await driver.delayMs(1000); // 等待搜索结果

      // 验证结果列表显示
      const resultList = await driver.findComponent(ON.type('List'));
      expect(resultList !== null).assertTrue();
    });

    it('should_toggle_play_pause', async () => {
      // 进入播放器页面
      await driver.findComponent(ON.text('录音1')).click();
      await driver.delayMs(500);

      // 点击播放
      const playBtn = await driver.findComponent(ON.id('play_button'));
      await playBtn.click();
      await driver.delayMs(1000);

      // 验证按钮变为暂停
      const pauseBtn = await driver.findComponent(ON.id('pause_button'));
      expect(pauseBtn !== null).assertTrue();
    });

  });
}

3.2 常用 UiTest API

API

用途

示例

driver.findComponent(ON.text('xxx'))

按文本查找

查找按钮、标签

driver.findComponent(ON.id('xxx'))

按ID查找

精确查找组件

driver.findComponent(ON.type('Button'))

按类型查找

查找所有按钮

component.click()

点击

模拟用户点击

component.inputText('xxx')

输入文本

在输入框中输入

component.scrollSearch(ON.text('xxx'))

滚动查找

在Scroll/List中查找

driver.assertComponentExist(ON.text('xxx'))

断言存在

验证组件存在

driver.delayMs(1000)

等待

等待动画/加载完成

3.3 测试ID与可测试性

为了让UI测试稳定,需要给关键组件设置testId:

// 主代码中设置testId
Button('播放')
  .id('play_button') // 测试用的唯一标识
  .onClick(() => this.play())

Button('暂停')
  .id('pause_button')
  .onClick(() => this.pause())

四、测试覆盖率与质量门禁

4.1 覆盖率配置

在 build-profile.json5 中启用覆盖率:

{
  "targets": [
    {
      "name": "default",
      "testRunner": "hypium",
      "coverage": {
        "enabled": true,
        "include": ["src/main/ets/utils/**", "src/main/ets/viewmodels/**"],
        "exclude": ["src/main/ets/pages/**"]
      }
    }
  ]
}

运行测试后生成覆盖率报告:

dev eco test --module entry --test-type unit --coverage

4.2 质量门禁标准

指标

阈值

说明

单元测试覆盖率

≥ 70%

核心工具类、ViewModel必须覆盖

UI测试通过率

100%

关键流程不允许失败

测试执行时间

< 5分钟

保证快速反馈

代码重复率

< 5%

通过SonarQube检测


五、CI/CD 集成

5.1 GitHub Actions 流水线

# .github/workflows/harmonyos-ci.yml
name: HarmonyOS CI

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  build-and-test:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup DevEco Studio CLI
        run: |
          wget https://contentcenter-vali-drcn.dbankcdn.cn/p_hippy/.../deveco-cli-linux.tar.gz
          tar -xzf deveco-cli-linux.tar.gz
          sudo mv deveco-cli /usr/local/bin/

      - name: Install Dependencies
        run: |
          cd entry
          ohpm install

      - name: Run Unit Tests
        run: |
          deveco test --module entry --test-type unit --coverage

      - name: Upload Coverage
        uses: codecov/codecov-action@v3
        with:
          files: ./entry/build/default/outputs/ohosTest/coverage_report.json

      - name: Build HAP
        run: |
          deveco build --module entry

      - name: Upload Artifact
        uses: actions/upload-artifact@v4
        with:
          name: hap-debug
          path: entry/build/default/outputs/default/*.hap

5.2 流水线阶段

代码提交
   │
   ▼
┌─────────────┐
│  代码规范检查 │ ← ESLint + ArkTS规范
│  (Lint)     │
└──────┬──────┘
       │
       ▼
┌─────────────┐
│  单元测试    │ ← Hypium Unit Tests
│  (Unit)     │   覆盖率 ≥ 70%
└──────┬──────┘
       │
       ▼
┌─────────────┐
│  构建HAP    │ ← 多目标构建(debug/release)
│  (Build)    │
└──────┬──────┘
       │
       ▼
┌─────────────┐
│  UI自动化测试 │ ← UiTest on 模拟器/真机
│  (E2E)      │
└──────┬──────┘
       │
       ▼
┌─────────────┐
│  安全扫描    │ ← 漏洞检测、隐私合规检查
│  (Security) │
└──────┬──────┘
       │
       ▼
   通过/失败

六、测试最佳实践

6.1 测试金字塔

         /\
        /  \        E2E测试(少而精)
       /    \       验证核心用户流程
      /______\
     /        \     集成测试(适中)
    /          \    验证模块协作
   /____________\
  /              \  单元测试(大量)
 /                \ 验证函数正确性
/__________________\
  • 单元测试应该最多(70%+),运行最快(毫秒级)

  • 集成测试验证边界,关注数据流转

  • E2E测试只覆盖最核心的1-2条用户旅程

6.2 可测试代码的特征

原则

反面教材

正确做法

单一职责

一个类既处理UI又调网络

UI层、业务层、数据层分离

依赖注入

直接new依赖

通过构造函数传入

避免全局状态

使用AppStorage存储业务数据

业务状态放在ViewModel中

纯函数优先

副作用散落在各处

计算逻辑写成无副作用函数


七、小结与下篇预告

本文建立了鸿蒙应用的质量防线:

  1. Hypium框架:类Jest的API,三层测试体系

  2. 单元测试:工具类测试、Mock隔离、边界条件覆盖

  3. UI自动化:UiTest模拟用户操作,testId保证稳定性

  4. 覆盖率与门禁:70%覆盖率红线、多指标质量门禁

  5. CI/CD集成:GitHub Actions流水线、从提交到构建的全自动化

测试是工程化的分水岭。没有测试,你只能" hope it works";有了测试,你才能"know it works"。

经过19篇的积累,我们的应用从Hello World成长为一个功能完整、跨平台、带AI能力的商业级产品。但代码写完了不等于产品上线了。第20篇——也是本系列的收官之作——将完整梳理鸿蒙应用的上架与发布流程,从签名打包、应用市场审核、版本管理到上线后的运营数据分析,帮你的应用真正触达用户。


参考阅读


评论