HarmonyOS开发:自动化测试
HarmonyOS开发:自动化测试
核心要点:CI流水线里没有自动化测试,就像买了车不装刹车——构建是能跑了,但你怎么知道跑对了方向?
背景与动机
上一篇文章搞定了构建自动化,CI流水线能自动编译出HAP了。然后呢?
你确定这个HAP能正常启动?确定新功能没把老功能搞坏?确定边界条件都处理了?
别急着点头。你手动测了一遍?那下次改代码呢?再手动测一遍?下下次呢?
手动测试的问题和手动构建一模一样:不可重复、不可持续、不可信赖。你今天测了10个用例,明天赶进度可能只测5个,后天直接跳过测试。而那些被跳过的用例,恰恰可能藏着致命bug。
自动化测试在CI中的价值:
- 每次构建必跑:代码提交就测试,有问题立刻暴露
- 全量回归:不用挑着测,所有用例都跑,不怕漏
- 结果可追溯:测试报告自动生成,哪次构建出了问题一目了然
- 释放人力:测试同学不用反复做回归,专注探索性测试
鸿蒙项目的自动化测试有它的特殊性——Hypium是官方测试框架,命令行执行方式和JUnit完全不同,测试结果格式也需要专门解析。
核心原理
CI中的自动化测试流程:构建完成 → 自动执行测试 → 解析结果 → 失败则阻断流水线。
flowchart TB
A[CI构建完成] --> B[发现测试用例]
B --> C{测试类型}
C -->|单元测试| D[执行ArkTS UT]
C -->|UI测试| E[执行Hypium UI测试]
C -->|兼容性测试| F[执行多设备测试]
D --> G[收集测试结果]
E --> G
F --> G
G --> H{结果解析}
H -->|全部通过| I[✅ 流水线继续]
H -->|存在失败| J{失败策略}
J -->|阻断| K[❌ 流水线中止]
J -->|记录| L[⚠️ 流水线继续+告警]
J -->|重试| M[🔄 重新执行失败用例]
M --> G
classDef start fill:#A29BFE,stroke:#6C5CE7,color:#fff
classDef test fill:#55EFC4,stroke:#00B894,color:#333
classDef decision fill:#FFEAA7,stroke:#FDCB6E,color:#333
classDef success fill:#00B894,stroke:#00A381,color:#fff
classDef fail fill:#FF7675,stroke:#D63031,color:#fff
classDef warn fill:#FDCB6E,stroke:#F0B429,color:#333
classDef retry fill:#74B9FF,stroke:#0984E3,color:#fff
class A,B start
class D,E,F,G test
class C,H,J decision
class I success
class K fail
class L warn
class M retry
鸿蒙自动化测试的三层体系:
| 层级 | 框架 | 运行方式 | CI集成方式 |
|---|---|---|---|
| 单元测试 | ArkTS测试框架 | hvigorw test@ut | 直接在CI中执行 |
| UI测试 | Hypium | hdc命令行 | 需要连接设备/模拟器 |
| 兼容性测试 | DevEco Testing | 云测试平台 | API触发 |
代码实战
基础用法:Hypium命令行执行
Hypium是HarmonyOS的自动化测试框架,支持单元测试和UI测试。在CI中使用命令行执行。
# ===== 单元测试 =====
# 执行指定模块的单元测试
./hvigorw --mode module -p module=entry@default -p product=default test@ut --no-daemon
# 执行所有模块的单元测试
./hvigorw test@ut --no-daemon
# ===== Hypium UI测试 =====
# 前提:设备已通过hdc连接
hdc list targets # 确认设备连接
# 执行UI测试用例
# 方式一:通过hvigorw
./hvigorw --mode module -p module=entry@default test@uitest --no-daemon
# 方式二:直接使用hdc推送并执行测试
# 1. 推送测试HAP到设备
hdc file send entry/build/default/outputs/default/entry-test.hap /data/local/tmp/
# 2. 安装测试HAP
hdc install -r /data/local/tmp/entry-test.hap
# 3. 执行测试
hdc shell aa test -b com.example.entry -m entry_test
# ===== 查看测试结果 =====
# 单元测试结果目录
ls entry/build/default/outputs/default/test/
# UI测试结果需要从设备拉取
hdc file recv /data/local/tmp/test-results/ ./test-results/
单元测试的编写方式:
// entry/src/ohosTest/ets/test/ListIndex.test.ets
// 鸿蒙单元测试示例
import { describe, it, expect, beforeEach } from '@ohos/hypium';
export default function listIndexTest() {
describe('列表索引工具测试', () => {
// 每个测试用例前的准备工作
beforeEach(() => {
// 初始化测试数据
});
it('should_find_item_by_id', 0, () => {
// 基础断言测试
const list = [1, 2, 3, 4, 5];
const index = list.indexOf(3);
expect(index).assertEqual(2);
});
it('should_return_negative_when_not_found', 0, () => {
const list = [1, 2, 3, 4, 5];
const index = list.indexOf(99);
expect(index).assertEqual(-1);
});
it('should_handle_empty_list', 0, () => {
const list: number[] = [];
const index = list.indexOf(1);
expect(index).assertEqual(-1);
});
});
}
进阶用法:CI中的测试集成与结果解析
CI环境里跑测试,关键不只是"跑起来",还要"结果可读"、“失败可追溯”。
# .gitlab-ci.yml - 自动化测试集成
stages:
- build
- unit-test
- ui-test
- report
# ===== 单元测试 =====
unit_test:
stage: unit-test
needs: [build]
script:
- echo "执行单元测试..."
- ./hvigorw --mode module -p module=entry@default -p product=default test@ut --no-daemon || true
# 即使测试失败也继续,后续步骤收集结果
artifacts:
when: always # 无论成功失败都收集产物
paths:
- entry/build/default/outputs/default/test/
reports:
junit: entry/build/default/outputs/default/test/**/*.xml
expire_in: 7 days
rules:
- if: '$CI_PIPELINE_SOURCE == "push"'
# ===== UI测试 =====
ui_test:
stage: ui-test
needs: [build]
tags:
- harmonyos-device # 需要连接真机/模拟器的CI节点
script:
- echo "检查设备连接..."
- hdc list targets
- echo "安装测试应用..."
- hdc install -r entry/build/default/outputs/default/entry-default-signed.hap
- echo "执行UI测试..."
- hdc shell aa test -b com.example.entry -m entry_test
- echo "拉取测试结果..."
- mkdir -p ui-test-results
- hdc file recv /data/local/tmp/test-results/ ./ui-test-results/ || true
artifacts:
when: always
paths:
- ui-test-results/
expire_in: 7 days
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
# ===== 测试报告 =====
test_report:
stage: report
needs: [unit_test, ui_test]
script:
- echo "生成测试报告..."
- python3 scripts/generate_test_report.py
artifacts:
paths:
- test-report.html
expire_in: 30 days
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
测试结果解析脚本:
# generate_test_report.py - 测试报告生成器
import os
import json
import xml.etree.ElementTree as ET
from datetime import datetime
from pathlib import Path
class TestReportGenerator:
"""鸿蒙测试报告生成器"""
def __init__(self):
self.results = {
'unit_tests': {'total': 0, 'passed': 0, 'failed': 0, 'skipped': 0, 'failures': []},
'ui_tests': {'total': 0, 'passed': 0, 'failed': 0, 'skipped': 0, 'failures': []},
}
def parse_junit_xml(self, xml_path: str) -> dict:
"""解析JUnit格式的测试结果XML"""
result = {'total': 0, 'passed': 0, 'failed': 0, 'skipped': 0, 'failures': []}
try:
tree = ET.parse(xml_path)
root = tree.getroot()
for testsuite in root.iter('testsuite'):
result['total'] += int(testsuite.get('tests', 0))
result['failed'] += int(testsuite.get('failures', 0))
result['skipped'] += int(testsuite.get('skipped', 0))
# 收集失败用例详情
for testcase in testsuite.iter('testcase'):
failure = testcase.find('failure')
if failure is not None:
result['failures'].append({
'name': testcase.get('name', 'unknown'),
'classname': testcase.get('classname', 'unknown'),
'message': failure.get('message', ''),
'detail': failure.text or ''
})
result['passed'] = result['total'] - result['failed'] - result['skipped']
except Exception as e:
print(f"解析XML失败: {xml_path}, 错误: {e}")
return result
def collect_results(self):
"""收集所有测试结果"""
# 收集单元测试结果
unit_test_dir = Path('entry/build/default/outputs/default/test')
if unit_test_dir.exists():
for xml_file in unit_test_dir.rglob('*.xml'):
result = self.parse_junit_xml(str(xml_file))
for key in ['total', 'passed', 'failed', 'skipped']:
self.results['unit_tests'][key] += result[key]
self.results['unit_tests']['failures'].extend(result['failures'])
# 收集UI测试结果
ui_test_dir = Path('ui-test-results')
if ui_test_dir.exists():
for xml_file in ui_test_dir.rglob('*.xml'):
result = self.parse_junit_xml(str(xml_file))
for key in ['total', 'passed', 'failed', 'skipped']:
self.results['ui_tests'][key] += result[key]
self.results['ui_tests']['failures'].extend(result['failures'])
def generate_html_report(self) -> str:
"""生成HTML测试报告"""
self.collect_results()
# 计算总体结果
total = self.results['unit_tests']['total'] + self.results['ui_tests']['total']
passed = self.results['unit_tests']['passed'] + self.results['ui_tests']['passed']
failed = self.results['unit_tests']['failed'] + self.results['ui_tests']['failed']
status = "✅ 通过" if failed == 0 else "❌ 失败"
pass_rate = (passed / total * 100) if total > 0 else 0
html = f"""<!DOCTYPE html>
<html>
<head>
<title>鸿蒙测试报告 - {datetime.now().strftime('%Y-%m-%d %H:%M')}</title>
<style>
body {{ font-family: -apple-system, sans-serif; margin: 20px; background: #f5f5f5; }}
.card {{ background: white; border-radius: 8px; padding: 20px; margin: 10px 0; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }}
.status {{ font-size: 24px; font-weight: bold; }}
.pass {{ color: #27ae60; }}
.fail {{ color: #e74c3c; }}
.stats {{ display: flex; gap: 20px; margin: 20px 0; }}
.stat-item {{ text-align: center; padding: 10px 20px; border-radius: 8px; }}
.failure-list {{ background: #fff5f5; border-left: 4px solid #e74c3c; padding: 10px; margin: 5px 0; }}
</style>
</head>
<body>
<div class="card">
<h1>🧪 鸿蒙自动化测试报告</h1>
<p class="status {'pass' if failed == 0 else 'fail'}">{status}</p>
<p>通过率: {pass_rate:.1f}% | 总用例: {total} | 通过: {passed} | 失败: {failed}</p>
</div>
<div class="card">
<h2>📊 单元测试</h2>
<div class="stats">
<div class="stat-item">总计: {self.results['unit_tests']['total']}</div>
<div class="stat-item pass">通过: {self.results['unit_tests']['passed']}</div>
<div class="stat-item fail">失败: {self.results['unit_tests']['failed']}</div>
</div>
{''.join(f\'<div class="failure-list"><strong>{f["name"]}</strong><br>{f["message"]}</div>\' for f in self.results["unit_tests"]["failures"])}
</div>
<div class="card">
<h2>📱 UI测试</h2>
<div class="stats">
<div class="stat-item">总计: {self.results['ui_tests']['total']}</div>
<div class="stat-item pass">通过: {self.results['ui_tests']['passed']}</div>
<div class="stat-item fail">失败: {self.results['ui_tests']['failed']}</div>
</div>
</div>
</body>
</html>"""
with open('test-report.html', 'w', encoding='utf-8') as f:
f.write(html)
return html
if __name__ == '__main__':
generator = TestReportGenerator()
generator.generate_html_report()
print("✅ 测试报告已生成: test-report.html")
完整示例:测试失败处理策略
测试失败不可怕,可怕的是不知道怎么处理。一个成熟的CI流水线必须有清晰的失败处理策略。
// entry/src/ohosTest/ets/test/UserService.test.ets
// 带重试和分类的测试用例
import { describe, it, expect, beforeAll, afterAll } from '@ohos/hypium';
export default function userServiceTest() {
describe('用户服务测试', () => {
// 模拟用户服务
let userService: UserService | null = null;
beforeAll(async () => {
// 初始化服务(只执行一次)
userService = new UserService();
await userService.init();
});
afterAll(() => {
// 清理资源
userService = null;
});
// ===== 稳定用例:核心功能,失败即阻断 =====
it('should_login_successfully', 0, async () => {
const result = await userService?.login('testuser', 'password123');
expect(result?.success).assertTrue();
expect(result?.token).assertNotNull();
});
it('should_fetch_user_profile', 0, async () => {
const profile = await userService?.getUserProfile('testuser');
expect(profile).assertNotNull();
expect(profile?.name).assertEqual('Test User');
});
// ===== 不稳定用例:网络相关,允许重试 =====
// 标记为1表示不稳定用例(Hypium支持用例级别标记)
it('should_sync_data_from_server', 1, async () => {
// 网络请求可能因环境不稳定而失败
const result = await userService?.syncData();
expect(result?.synced).assertTrue();
});
// ===== 边界用例:异常场景,失败记录但不阻断 =====
it('should_handle_network_timeout', 0, async () => {
// 模拟网络超时
try {
await userService?.requestWithTimeout(1); // 1ms超时
// 如果没抛异常,说明超时处理有问题
expect(false).assertTrue();
} catch (error) {
// 预期会抛异常
expect(error).assertNotNull();
}
});
});
}
// 用户服务实现(被测试的代码)
class UserService {
private token: string = '';
async init(): Promise<void> {
// 初始化逻辑
}
async login(username: string, password: string): Promise<{ success: boolean; token?: string }> {
if (username === 'testuser' && password === 'password123') {
this.token = 'mock-token-123';
return { success: true, token: this.token };
}
return { success: false };
}
async getUserProfile(username: string): Promise<{ name: string } | null> {
if (username === 'testuser') {
return { name: 'Test User' };
}
return null;
}
async syncData(): Promise<{ synced: boolean }> {
// 模拟网络请求
return new Promise((resolve) => {
setTimeout(() => resolve({ synced: true }), 100);
});
}
async requestWithTimeout(timeout: number): Promise<string> {
return new Promise((_, reject) => {
setTimeout(() => reject(new Error('timeout')), timeout);
});
}
}
CI中的失败处理脚本:
#!/bin/bash
# handle_test_failure.sh - 测试失败处理策略
# 读取测试结果
FAILED_COUNT=$1
if [ "$FAILED_COUNT" -eq 0 ]; then
echo "✅ 所有测试通过"
exit 0
fi
echo "⚠️ 有 ${FAILED_COUNT} 个测试失败"
# 策略1:区分稳定用例和不稳定用例
# 稳定用例失败 → 阻断流水线
# 不稳定用例失败 → 记录告警,不阻断
STABLE_FAILURES=$(grep -r "stable.*fail" test-results/ 2>/dev/null | wc -l)
FLAKY_FAILURES=$(grep -r "flaky.*fail" test-results/ 2>/dev/null | wc -l)
echo " 稳定用例失败: ${STABLE_FAILURES}"
echo " 不稳定用例失败: ${FLAKY_FAILURES}"
if [ "$STABLE_FAILURES" -gt 0 ]; then
echo "❌ 稳定用例存在失败,阻断流水线"
exit 1
fi
# 策略2:不稳定用例自动重试
if [ "$FLAKY_FAILURES" -gt 0 ]; then
echo "🔄 不稳定用例失败,执行重试..."
MAX_RETRIES=2
for i in $(seq 1 $MAX_RETRIES); do
echo " 第 ${i} 次重试..."
./hvigorw --mode module -p module=entry@default test@ut --no-daemon
# 检查重试后是否还有失败
NEW_FAILED=$(grep -c "failures=" test-results/**/*.xml 2>/dev/null || echo "0")
if [ "$NEW_FAILED" -eq 0 ]; then
echo "✅ 重试后所有测试通过"
exit 0
fi
done
echo "⚠️ 重试后仍有失败,发送告警但不阻断"
# 发送告警通知
curl -X POST "https://your-webhook-url" \
-H "Content-Type: application/json" \
-d "{\"text\": \"⚠️ 不稳定用例重试后仍失败,请关注\"}"
fi
exit 0 # 不稳定用例失败不阻断流水线
踩坑与注意事项
坑1:CI环境没有设备跑UI测试
UI测试需要真机或模拟器,CI服务器通常没有。
解决方案:
# 方案一:使用远程设备实验室
# 华为DevEco Testing提供云设备,通过API触发
curl -X POST "https://devetesting.huawei.com/api/v1/test" \
-H "Authorization: Bearer $TOKEN" \
-d '{"appId": "com.example.entry", "deviceType": "phone"}'
# 方案二:CI节点连接本地设备池
# 用hdc连接局域网内的测试设备
hdc tconn 192.168.1.100:5555 # 远程连接设备
hdc list targets # 验证连接
坑2:测试结果XML格式不规范
Hypium生成的XML有时候格式和标准JUnit不完全兼容,CI平台解析失败。
解决方案:写一个格式转换脚本。
import xml.etree.ElementTree as ET
def fix_hypium_xml(input_path: str, output_path: str):
"""修复Hypium生成的XML格式"""
tree = ET.parse(input_path)
root = tree.getroot()
# 确保testsuites根节点存在
if root.tag != 'testsuites':
new_root = ET.Element('testsuites')
new_root.append(root)
tree = ET.ElementTree(new_root)
# 修复缺失的属性
for testsuite in tree.iter('testsuite'):
if 'tests' not in testsuite.attrib:
testsuite.set('tests', str(len(list(testsuite.iter('testcase')))))
tree.write(output_path, encoding='utf-8', xml_declaration=True)
坑3:测试用例之间互相影响
用例A修改了全局状态,用例B就挂了。这种问题最难排查,因为单独跑每个用例都通过,一起跑就挂。
解决方案:严格隔离测试数据,每个用例自己准备、自己清理。
import { describe, it, expect, beforeEach, afterEach } from '@ohos/hypium';
export default function isolatedTest() {
describe('隔离测试示例', () => {
let testData: Map<string, string>;
// 每个用例前重新初始化数据
beforeEach(() => {
testData = new Map<string, string>();
testData.set('key1', 'value1');
});
// 每个用例后清理
afterEach(() => {
testData.clear();
});
it('test_case_1', 0, () => {
testData.set('key2', 'value2');
expect(testData.size).assertEqual(2);
// 用例结束后数据会被清理,不会影响下一个用例
});
it('test_case_2', 0, () => {
// 这里只有beforeEach设置的数据,不受test_case_1影响
expect(testData.size).assertEqual(1);
});
});
}
坑4:测试超时导致CI卡死
某个用例卡住了,整个CI流水线就挂在那。
解决方案:给测试命令设置超时。
# GitLab CI
unit_test:
stage: unit-test
script:
- timeout 600 ./hvigorw test@ut --no-daemon # 10分钟超时
timeout: 15m # 整个Job的超时
# Jenkins
stage('单元测试') {
steps {
timeout(time: 10, unit: 'MINUTES') {
sh './hvigorw test@ut --no-daemon'
}
}
}
坑5:测试覆盖率数据丢失
跑完测试想看覆盖率,结果发现没生成覆盖率报告。
解决方案:构建时开启覆盖率选项。
// build-profile.json5 中配置覆盖率
{
"app": {
"products": [{
"name": "default",
"buildOption": {
"arkOptions": {
"compilerOptions": {
// 开启覆盖率插桩
"coverage": true
}
}
}
}]
}
}
# 执行带覆盖率的测试
./hvigorw --mode module -p module=entry@default test@ut --no-daemon \
-p coverage=true
# 覆盖率报告位置
ls entry/build/default/outputs/default/coverage/
HarmonyOS 6适配说明
HarmonyOS 6对自动化测试的改进:
-
Hypium 2.0:新版测试框架支持更灵活的用例编排,可以按标签、优先级筛选执行。
-
测试结果标准化:Hypium 2.0的输出格式完全兼容JUnit XML,不再需要格式转换。
-
分布式测试:支持在多个设备上并行执行测试,大幅缩短兼容性测试时间。
-
性能测试集成:Hypium 2.0内置性能指标采集,可以在功能测试的同时收集启动时间、帧率等数据。
-
Mock能力增强:ArkTS的Mock框架支持更细粒度的依赖替换,单元测试不再依赖外部服务。
总结
CI中没有自动化测试,就像开车不看仪表盘——你不知道油量、不知道速度、不知道引擎状态,直到抛锚才发现问题。
| 维度 | 评价 |
|---|---|
| 学习难度 | ⭐⭐⭐⭐ Hypium框架和ArkTS测试写法需要学习,CI集成也有一定复杂度 |
| 使用频率 | ⭐⭐⭐⭐⭐ 每次代码提交都会触发测试,是质量保障的核心手段 |
| 重要程度 | ⭐⭐⭐⭐⭐ 没有自动化测试的CI只是半成品,无法真正保障代码质量 |
几个关键提醒:
- 先写核心用例,不要一上来就追求覆盖率100%。覆盖20%的核心路径比覆盖80%的边缘路径更有价值
- 区分稳定用例和不稳定用例,不同失败策略,避免不稳定用例拖垮整条流水线
- 测试数据要隔离,用例之间互相影响是最难排查的问题之一
- 超时是必须的,没有超时保护的CI流水线迟早会卡死
- 测试报告要可视化,数字比日志更容易让人理解测试状态
测试自动化搞好了,下一步就是部署自动化——让构建产物自动安装到设备上,完成"提交→构建→测试→部署"的完整闭环。
- 点赞
- 收藏
- 关注作者
评论(0)