系列文章《鸿蒙开发从入门到专家》第 46 篇 · 中级
上一篇讲了单元测试。单元测试验证的是"函数对不对",但用户不按函数操作,用户按按钮。一个按钮的 onClick 绑定错了,单元测试不一定能发现。
UI 自动化测试补上这个缺口:模拟用户操作,验证界面行为是否符合预期。鸿蒙的 UI 自动化测试框架叫 Hypium,支持 ArkTS 和 Python 两种语言。这篇以 ArkTS 侧的 @ohos/hypium 为主,讲怎么搭一个可维护的 UI 测试体系。
一、UiTest 框架基础
1.1 核心类
鸿蒙 UI 测试框架基于 UiTest,核心是两个类:
注意:早期版本叫 UiDriver,新版 API 12+ 重命名为 Driver,API 有微调。以下代码基于 API 12+ 的 Driver。
1.2 第一个 UI 测试
import { describe, it, beforeAll } from '@ohos/hypium';
import { Driver, ON } from '@kit.TestKit';
export default function uiAbilityTest() {
describe('LoginPageTest', () => {
let driver: Driver;
beforeAll(async () => {
driver = Driver.create();
await driver.delayMs(1000); // 等应用启动完成
});
it('should_navigate_to_home_after_login', 0, async () => {
// 找到用户名输入框并输入
const usernameInput = await driver.findComponent(ON.id('username_input'));
await usernameInput.setText('testuser');
// 找到密码输入框并输入
const passwordInput = await driver.findComponent(ON.id('password_input'));
await passwordInput.setText('password123');
// 点击登录按钮
const loginButton = await driver.findComponent(ON.text('Login'));
await loginButton.click();
// 等待首页加载
await driver.delayMs(2000);
// 验证是否到达首页
const homeTitle = await driver.findComponent(ON.text('Welcome'));
expect(homeTitle !== null).assertTrue();
});
});
}
1.3 控件定位方式
ON(Object Name)是控件定位的入口,提供多种查找策略:
尽量用 ON.id 定位。文本定位在多语言环境下会失效,类型定位可能匹配到多个控件。
给关键控件设置 id 是好习惯:
TextInput({ placeholder: 'Username' })
.id('username_input') // 测试时用 ON.id('username_input') 定位
Button('Login')
.id('login_button')
二、页面对象模式
直接在测试用例里写 driver.findComponent(ON.id('xxx')) 会产生大量重复代码。登录页面的控件定位逻辑,5 个测试用例要写 5 遍。改一个 id 要改 5 个文件。
页面对象模式(Page Object Pattern)解决这个问题:把每个页面的控件定位和操作封装成一个类,测试用例调用页面对象的方法。
2.1 定义页面对象
// pages/LoginPage.ts
import { Driver, ON, UiComponent } from '@kit.TestKit';
export class LoginPage {
constructor(private driver: Driver) {}
// 定位控件
async getUsernameInput(): Promise<UiComponent> {
return await this.driver.findComponent(ON.id('username_input'));
}
async getPasswordInput(): Promise<UiComponent> {
return await this.driver.findComponent(ON.id('password_input'));
}
async getLoginButton(): Promise<UiComponent> {
return await this.driver.findComponent(ON.id('login_button'));
}
// 封装操作
async login(username: string, password: string): Promise<void> {
const userInput = await this.getUsernameInput();
await userInput.setText(username);
const passInput = await this.getPasswordInput();
await passInput.setText(password);
const btn = await this.getLoginButton();
await btn.click();
}
// 获取错误提示
async getErrorMessage(): Promise<string | null> {
const errorText = await this.driver.findComponent(ON.id('error_message'));
if (errorText) {
return await errorText.getText();
}
return null;
}
}
2.2 测试用例使用页面对象
import { LoginPage } from './pages/LoginPage';
export default function uiAbilityTest() {
describe('LoginPageTest', () => {
let driver: Driver;
beforeAll(async () => {
driver = Driver.create();
await driver.delayMs(1000);
});
it('should_show_error_with_wrong_credentials', 0, async () => {
const loginPage = new LoginPage(driver);
await loginPage.login('wronguser', 'wrongpass');
await driver.delayMs(1000);
const errorMsg = await loginPage.getErrorMessage();
expect(errorMsg).assertContain('Invalid');
});
it('should_navigate_to_home_with_valid_credentials', 0, async () => {
const loginPage = new LoginPage(driver);
await loginPage.login('testuser', 'password123');
await driver.delayMs(2000);
const homeTitle = await driver.findComponent(ON.text('Welcome'));
expect(homeTitle !== null).assertTrue();
});
});
}
好处是:页面 UI 变了(比如改了 id),只需要改页面对象类的一个方法,所有测试用例自动适配。
三、常用操作 API
3.1 等待策略
UI 测试最常见的不稳定来源是"控件还没出现就去找"。用固定 delayMs 是最低效的方式,网络快慢波动会导致偶发失败。
driver.waitForComponent 是更好的方式——它会轮询查找,超时才失败:
// 等待控件出现(默认超时 5s)
const button = await driver.waitForComponent(ON.text('Submit'), 5000);
// 等待控件消失
await driver.waitForComponent(ON.text('Loading'), 3000, true);
// 第三参数 true 表示等待消失
3.2 滑动操作
// 向上滑动列表
await driver.swipe({ startX: 300, startY: 800, endX: 300, endY: 200 });
// 向左滑动某个控件
const item = await driver.findComponent(ON.id('list_item_1'));
await item.swipe({ direction: SwipeDirection.LEFT });
3.3 按键模拟
// 返回键
await driver.pressBack();
// 指定 keyCode
await driver.pressKey(KeyCode.KEYCODE_HOME);
// 输入文本
const input = await driver.findComponent(ON.id('search'));
await input.setText('HarmonyOS');
四、测试场景设计
4.1 关键流程覆盖
不需要测试每个页面的每个按钮。优先覆盖:
核心业务路径:注册 → 登录 → 首页 → 核心功能 → 退出。
异常路径:网络断开时的行为、权限拒绝时的行为、空数据时的展示。
跨页面状态:A 页面操作后 B 页面是否正确更新。
4.2 测试数据管理
把测试数据和测试代码分离。用一个 TestData 类集中管理:
// test/TestData.ts
export class TestData {
static readonly validUser = {
username: 'testuser',
password: 'Test1234!',
email: 'test@example.com'
};
static readonly invalidUser = {
username: 'nonexistent',
password: 'wrong',
email: 'invalid-email'
};
static readonly longText = 'A'.repeat(500);
static readonly specialChars = '<script>alert(1)</script>';
}
测试用例中引用:
it('should_reject_sql_injection_in_username', 0, async () => {
const loginPage = new LoginPage(driver);
await loginPage.login(TestData.specialChars, 'password');
const msg = await loginPage.getErrorMessage();
expect(msg).assertContain('Invalid');
});
4.3 截图与报告
Hypium 支持在测试过程中截图,失败时自动保存:
it('should_display_correct_layout', 0, async () => {
await driver.delayMs(500);
await driver.screenCapture('login_page_layout');
// 截图保存在测试报告目录下
});
五、Python 侧的 Hypium
除了 ArkTS 侧的 @ohos/hypium,华为还提供了 Python 版的 DevEco Testing Hypium。两者定位不同:
Python Hypium 的典型用法:
from hypium import *
driver = UiDriver.connect(device_sn="DEVICE_ID")
# 启动应用
driver.start_app("com.example.app")
# 授权弹窗处理
driver.touch(BY.key('permission_dialog_allow_button'))
# 输入并点击
username = driver.find_all_components(BY.type("TextInput"))[0]
driver.input_text(username, "testuser")
driver.touch(BY.text('Login'))
# 验证
driver.wait(2)
home_title = driver.find_component(BY.text('Welcome'))
assert home_title is not None
如果你的团队已有 Python 测试基础设施,或者需要跨应用做 UI 回归,Python Hypium 更合适。如果只是给自己的应用做 UI 测试,ArkTS Hypium 更轻量。
小结
下一篇讲调试技巧。测试发现了 bug,但不知道为什么出 bug——这时候需要调试器上场。