鸿蒙开发系列 · 专家篇
前言
写到第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使用
.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
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 质量门禁标准
五、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 可测试代码的特征
七、小结与下篇预告
本文建立了鸿蒙应用的质量防线:
Hypium框架:类Jest的API,三层测试体系
单元测试:工具类测试、Mock隔离、边界条件覆盖
UI自动化:UiTest模拟用户操作,testId保证稳定性
覆盖率与门禁:70%覆盖率红线、多指标质量门禁
CI/CD集成:GitHub Actions流水线、从提交到构建的全自动化
测试是工程化的分水岭。没有测试,你只能" hope it works";有了测试,你才能"know it works"。
经过19篇的积累,我们的应用从Hello World成长为一个功能完整、跨平台、带AI能力的商业级产品。但代码写完了不等于产品上线了。第20篇——也是本系列的收官之作——将完整梳理鸿蒙应用的上架与发布流程,从签名打包、应用市场审核、版本管理到上线后的运营数据分析,帮你的应用真正触达用户。
参考阅读
本系列第18篇:《ArkUI-X跨平台开发:一套代码多端运行实践》