HarmonyOS开发:断言验证——UI状态验证
HarmonyOS开发:断言验证——UI状态验证
📌 核心要点:断言是UI测试的"判官",通过验证组件存在性、文本匹配、属性正确性和状态一致性,判断测试是通过还是失败,没有断言的测试就是走过场。
背景与动机
你点击了登录按钮,然后呢?你怎么知道登录成功了?
如果只是点了一下就完事,那不叫测试,那叫"跑了一遍流程"。测试的核心不是"做了什么操作",而是"操作之后结果对不对"。
这就是断言干的事。你操作完之后,用断言去验证UI的状态——按钮文字变了没?页面跳转了没?错误提示出现了没?购物车数量增加了没?只有断言通过了,测试才算真正通过。
很多人写UI测试的时候,操作写了一大堆,断言就一个——甚至没有断言。这种测试跑起来永远是绿的,但什么都没验证。等到真出了bug,这种测试一个都拦不住。
核心原理
断言的层次
UI断言分四个层次,从粗到细:
graph TB
A[UI断言层次] --> B[存在性断言]
A --> C[文本断言]
A --> D[属性断言]
A --> E[状态断言]
B --> B1["组件是否存在"]
B --> B2["组件是否可见"]
B --> B3["组件数量是否正确"]
C --> C1["文本精确匹配"]
C --> C2["文本包含匹配"]
C --> C3["文本正则匹配"]
D --> D1["id是否正确"]
D --> D2["type是否正确"]
D --> D3["自定义属性值"]
D --> D4["bounds位置尺寸"]
E --> E1["是否可点击"]
E --> E2["是否已选中"]
E --> E3["是否已聚焦"]
E --> E4["是否已勾选"]
classDef root fill:#4A90D9,stroke:#2C5F8A,color:#fff
classDef exist fill:#67C23A,stroke:#3E9B2B,color:#fff
classDef text fill:#E6A23C,stroke:#B07D2B,color:#fff
classDef prop fill:#F56C6C,stroke:#C94A4A,color:#fff
classDef state fill:#9B59B6,stroke:#7D3C98,color:#fff
class A root
class B,B1,B2,B3 exist
class C,C1,C2,C3 text
class D,D1,D2,D3,D4 prop
class E,E1,E2,E3,E4 state
断言执行流程
flowchart TD
A[执行操作] --> B[获取UI状态]
B --> C[执行断言判断]
C --> D{断言通过?}
D -->|是| E[记录通过]
D -->|否| F[截图保存现场]
F --> G[记录失败信息]
G --> H{继续执行?}
H -->|是| I[执行下一个断言]
H -->|否| J[测试终止]
E --> I
I --> K[生成测试报告]
classDef start fill:#4A90D9,stroke:#2C5F8A,color:#fff
classDef process fill:#67C23A,stroke:#3E9B2B,color:#fff
classDef decision fill:#E6A23C,stroke:#B07D2B,color:#fff
classDef success fill:#67C23A,stroke:#3E9B2B,color:#fff
classDef fail fill:#F56C6C,stroke:#C94A4A,color:#fff
classDef endNode fill:#909399,stroke:#606266,color:#fff
class A start
class B,C process
class D,H decision
class E success
class F,G fail
class I,K endNode
断言的两种模式
UiTest框架提供两种断言模式:
- 硬断言(Assert):断言失败立即终止测试。适合关键验证点——如果这个都不对,后面验证没意义。
- 软断言(Verify):断言失败只记录,不终止测试。适合非关键验证点——即使这个不对,还想看看后面有没有别的问题。
实际项目中,关键路径用硬断言,辅助验证用软断言,两者配合使用。
代码实战
基础用法:存在性与文本断言
// 464_ui_assertion_basic.ets
import { Driver, ON } from '@ohos.UiTest';
export default function basicAssertionTest() {
const driver = Driver.create();
// ===== 1. 存在性断言 =====
// assertComponentExistence:断言组件存在(带等待)
// 如果组件在超时时间内出现,断言通过;否则失败
driver.assertComponentExistence(ON.id('login_page'), 5000);
console.info('✅ 登录页面存在');
// 断言组件不存在(验证错误提示消失等场景)
driver.assertComponentNotExist(ON.id('error_dialog'));
console.info('✅ 错误弹窗已消失');
// ===== 2. 文本断言 =====
// 精确匹配文本
const title = driver.findComponent(ON.id('page_title'));
if (title !== null) {
const text = title.getText();
if (text === '首页') {
console.info('✅ 标题文本正确');
} else {
console.error(`❌ 标题文本错误: 期望"首页",实际"${text}"`);
}
}
// 包含匹配文本
const priceText = driver.findComponent(ON.id('product_price'));
if (priceText !== null) {
const price = priceText.getText();
if (price.includes('¥')) {
console.info('✅ 价格格式正确');
} else {
console.error('❌ 价格格式错误: 缺少¥符号');
}
}
// ===== 3. 数量断言 =====
// 验证页面上某类组件的数量
const tabs = driver.findComponents(ON.type('TabContent'));
if (tabs.length === 4) {
console.info('✅ Tab数量正确');
} else {
console.error(`❌ Tab数量错误: 期望4,实际${tabs.length}`);
}
}
进阶用法:属性与状态断言
// 464_ui_assertion_advanced.ets
import { Driver, ON, Component } from '@ohos.UiTest';
export default function advancedAssertionTest() {
const driver = Driver.create();
// ===== 1. 属性断言 =====
// 验证组件id
const btn = driver.findComponent(ON.type('Button'));
if (btn !== null) {
const id = btn.getId();
console.info(`按钮id: ${id}`);
// 断言id不为空
if (id && id.length > 0) {
console.info('✅ 按钮有id');
}
}
// 验证组件type
const component = driver.findComponent(ON.id('my_component'));
if (component !== null) {
const type = component.getType();
if (type === 'Button') {
console.info('✅ 组件类型正确');
} else {
console.error(`❌ 组件类型错误: 期望Button,实际${type}`);
}
}
// 验证组件bounds(位置和尺寸)
const logo = driver.findComponent(ON.id('app_logo'));
if (logo !== null) {
const bounds = logo.getBounds();
console.info(`Logo位置: x=${bounds.left}, y=${bounds.top}`);
console.info(`Logo尺寸: ${bounds.width}x${bounds.height}`);
// 断言Logo在屏幕上方区域
if (bounds.top < 200) {
console.info('✅ Logo位置正确(在页面顶部)');
} else {
console.error('❌ Logo位置异常(不在页面顶部)');
}
}
// ===== 2. 状态断言 =====
// 验证组件是否可点击
const submitBtn = driver.findComponent(ON.id('submit_btn'));
if (submitBtn !== null) {
const isClickable = submitBtn.isClickable();
if (isClickable) {
console.info('✅ 提交按钮可点击');
} else {
console.error('❌ 提交按钮不可点击(可能被禁用)');
}
}
// 验证组件是否已勾选
const agreeCheckbox = driver.findComponent(ON.id('agree_checkbox'));
if (agreeCheckbox !== null) {
const isChecked = agreeCheckbox.isChecked();
console.info(`协议勾选状态: ${isChecked ? '已勾选' : '未勾选'}`);
}
// 验证组件是否已选中(Tab、Radio等)
const selectedTab = driver.findComponent(ON.id('tab_home'));
if (selectedTab !== null) {
const isSelected = selectedTab.isSelected();
if (isSelected) {
console.info('✅ 首页Tab已选中');
}
}
// ===== 3. 自定义断言函数 =====
// 封装常用的断言逻辑,提高复用性
function assertTextEquals(component: Component | null, expected: string, label: string): boolean {
if (component === null) {
console.error(`❌ ${label}: 组件不存在`);
return false;
}
const actual = component.getText();
if (actual === expected) {
console.info(`✅ ${label}: 文本匹配`);
return true;
} else {
console.error(`❌ ${label}: 期望"${expected}",实际"${actual}"`);
return false;
}
}
function assertTextContains(component: Component | null, keyword: string, label: string): boolean {
if (component === null) {
console.error(`❌ ${label}: 组件不存在`);
return false;
}
const actual = component.getText();
if (actual.includes(keyword)) {
console.info(`✅ ${label}: 包含"${keyword}"`);
return true;
} else {
console.error(`❌ ${label}: 文本"${actual}"不包含"${keyword}"`);
return false;
}
}
function assertBoundsInRange(
component: Component | null,
minLeft: number, maxLeft: number,
minTop: number, maxTop: number,
label: string
): boolean {
if (component === null) {
console.error(`❌ ${label}: 组件不存在`);
return false;
}
const bounds = component.getBounds();
const inRange = bounds.left >= minLeft && bounds.left <= maxLeft &&
bounds.top >= minTop && bounds.top <= maxTop;
if (inRange) {
console.info(`✅ ${label}: 位置在预期范围内`);
return true;
} else {
console.error(`❌ ${label}: 位置(${bounds.left},${bounds.top})不在范围[${minLeft}-${maxLeft},${minTop}-${maxTop}]内`);
return false;
}
}
// 使用自定义断言
const welcomeText = driver.findComponent(ON.id('welcome_msg'));
assertTextContains(welcomeText, '欢迎', '欢迎消息');
const cartCount = driver.findComponent(ON.id('cart_count'));
assertTextEquals(cartCount, '3', '购物车数量');
const fab = driver.findComponent(ON.id('fab_button'));
assertBoundsInRange(fab, 300, 400, 700, 800, '浮动按钮');
}
完整示例:表单验证的断言体系
// 464_ui_assertion_full.ets
import { Driver, ON, Component } from '@ohos.UiTest';
// 表单验证断言器
class FormAssertionHelper {
private driver: Driver;
private errors: string[] = [];
constructor(driver: Driver) {
this.driver = driver;
this.errors = [];
}
// 硬断言:失败立即终止
assertExist(selector: ON, timeout: number = 5000, message?: string): Component {
this.driver.assertComponentExistence(selector, timeout);
const component = this.driver.findComponent(selector);
if (component === null) {
const msg = message ?? `组件不存在: ${selector}`;
throw new Error(msg);
}
return component;
}
// 软断言:失败只记录
verifyText(selector: ON, expected: string, label: string): boolean {
const component = this.driver.findComponent(selector);
if (component === null) {
this.errors.push(`${label}: 组件不存在`);
return false;
}
const actual = component.getText();
if (actual !== expected) {
this.errors.push(`${label}: 期望"${expected}",实际"${actual}"`);
return false;
}
return true;
}
// 验证错误提示
verifyErrorHint(selector: ON, expectedVisible: boolean, label: string): boolean {
const component = this.driver.findComponent(selector);
const isVisible = component !== null;
if (isVisible !== expectedVisible) {
this.errors.push(`${label}: 期望${expectedVisible ? '可见' : '不可见'},实际${isVisible ? '可见' : '不可见'}`);
return false;
}
return true;
}
// 验证按钮状态
verifyButtonState(selector: ON, expectedEnabled: boolean, label: string): boolean {
const component = this.driver.findComponent(selector);
if (component === null) {
this.errors.push(`${label}: 按钮不存在`);
return false;
}
const isClickable = component.isClickable();
if (isClickable !== expectedEnabled) {
this.errors.push(`${label}: 期望${expectedEnabled ? '可点击' : '禁用'},实际${isClickable ? '可点击' : '禁用'}`);
return false;
}
return true;
}
// 获取所有错误
getErrors(): string[] {
return this.errors;
}
// 是否有错误
hasErrors(): boolean {
return this.errors.length > 0;
}
// 清空错误
clearErrors(): void {
this.errors = [];
}
// 打印报告
printReport(): void {
if (this.errors.length === 0) {
console.info('✅ 所有断言通过');
} else {
console.error(`❌ 共${this.errors.length}个断言失败:`);
this.errors.forEach((err, i) => {
console.error(` ${i + 1}. ${err}`);
});
}
}
}
// 注册表单测试
export default function registrationFormTest() {
const driver = Driver.create();
const assertion = new FormAssertionHelper(driver);
// ===== 场景1:空表单提交 =====
// 不填任何内容,直接点提交
const submitBtn = driver.findComponent(ON.id('submit_btn'));
submitBtn?.click();
// 验证:错误提示应该出现
assertion.verifyErrorHint(ON.id('username_error'), true, '用户名错误提示');
assertion.verifyErrorHint(ON.id('email_error'), true, '邮箱错误提示');
assertion.verifyErrorHint(ON.id('password_error'), true, '密码错误提示');
// 验证:提交按钮应该是禁用状态
assertion.verifyButtonState(ON.id('submit_btn'), false, '提交按钮');
// ===== 场景2:部分填写 =====
// 只填用户名
const usernameInput = driver.findComponent(ON.id('username_input'));
usernameInput?.inputText('testuser');
// 验证:用户名错误提示应该消失
assertion.verifyErrorHint(ON.id('username_error'), false, '用户名错误提示');
// 验证:其他错误提示仍然存在
assertion.verifyErrorHint(ON.id('email_error'), true, '邮箱错误提示');
assertion.verifyErrorHint(ON.id('password_error'), true, '密码错误提示');
// ===== 场景3:全部填写正确 =====
const emailInput = driver.findComponent(ON.id('email_input'));
emailInput?.inputText('test@example.com');
const passwordInput = driver.findComponent(ON.id('password_input'));
passwordInput?.inputText('Abc123456');
const confirmPasswordInput = driver.findComponent(ON.id('confirm_password_input'));
confirmPasswordInput?.inputText('Abc123456');
// 勾选协议
const agreeCheckbox = driver.findComponent(ON.id('agree_checkbox'));
agreeCheckbox?.click();
// 验证:所有错误提示消失
assertion.verifyErrorHint(ON.id('username_error'), false, '用户名错误提示');
assertion.verifyErrorHint(ON.id('email_error'), false, '邮箱错误提示');
assertion.verifyErrorHint(ON.id('password_error'), false, '密码错误提示');
// 验证:提交按钮变为可用
assertion.verifyButtonState(ON.id('submit_btn'), true, '提交按钮');
// 点击提交
submitBtn?.click();
// 验证:跳转到成功页面
driver.assertComponentExistence(ON.text('注册成功'), 5000);
// 打印断言报告
assertion.printReport();
}
踩坑与注意事项
坑1:断言不够具体
// ❌ 断言太粗——只验证了"有个按钮"
driver.assertComponentExistence(ON.type('Button'));
// ✅ 断言要具体——验证按钮的文本、状态、位置
const btn = driver.findComponent(ON.id('submit_btn'));
assertEqual(btn.getText(), '提交');
assertEqual(btn.isClickable(), true);
断言越具体,测试越有价值。一个"组件存在"的断言只能告诉你页面没崩,但一个"按钮文本是’提交’且可点击"的断言能告诉你业务逻辑是对的。
坑2:断言时机不对
操作完立刻断言,UI还没更新,断言必然失败。
// ❌ 点击后立刻断言
deleteBtn.click();
driver.assertComponentNotExist(ON.id('deleted_item')); // 太早了
// ✅ 断言带等待
deleteBtn.click();
driver.assertComponentNotExist(ON.id('deleted_item'), 5000); // 等消失
坑3: getText返回空字符串
有些组件(比如Image、Divider)没有文本,getText()返回空字符串。对这类组件做文本断言没意义,应该用属性或状态断言。
坑4:浮点数比较
价格、百分比这类数值,在UI上可能是"¥99.00"这样的字符串。断言时要注意格式化差异:
// ❌ 直接比较,格式可能不一致
const price = priceComponent.getText();
if (price === '99') { ... } // 实际可能是"99.00"
// ✅ 解析数值后比较
const priceNum = parseFloat(price.replace('¥', ''));
if (Math.abs(priceNum - 99) < 0.01) { ... }
坑5:软断言和硬断言的混用
关键路径用硬断言,辅助验证用软断言,这个原则要坚持。但别搞反了——关键验证点用软断言,测试永远显示通过,bug就漏过去了。
HarmonyOS 6适配说明
HarmonyOS 6对断言验证做了以下增强:
- 新增assertMatches正则断言:
driver.assertMatches(ON.id('price'), /\d+\.\d{2}/)直接用正则验证文本格式 - 快照断言:
driver.assertSnapshot()保存当前UI状态快照,后续可以对比差异 - 批量断言:
driver.assertAll([assertion1, assertion2, ...])一次性执行多个断言,收集所有失败信息 - 自定义断言消息:所有断言方法支持自定义失败消息,方便定位问题
- 断言结果的结构化输出:断言结果以JSON格式输出,方便集成到CI/CD流水线
迁移注意:assertComponentExistence的第二个参数从number改为AssertOptions对象,旧代码需要适配:
// 旧写法
driver.assertComponentExistence(ON.id('xxx'), 5000);
// 新写法
driver.assertComponentExistence(ON.id('xxx'), { timeout: 5000 });
总结
断言是测试的灵魂。没有断言的测试就像没有裁判的比赛——谁都能说自己赢了。写断言的时候问自己一个问题:如果这个断言失败了,我能不能从失败信息直接定位到bug? 如果不能,说明断言不够具体。
| 维度 | 评价 |
|---|---|
| 学习难度 | ⭐⭐ 断言语法简单,难点在于断言什么、怎么断 |
| 使用频率 | ⭐⭐⭐⭐⭐ 每个测试都需要断言 |
| 重要程度 | ⭐⭐⭐⭐⭐ 决定测试是否真的在验证 |
记住:宁可多写几个断言,也别漏掉关键验证点。一个断言不够就写两个,两个不够就写三个。测试的价值不在于"跑了多少操作",而在于"验证了多少结果"。
- 点赞
- 收藏
- 关注作者
评论(0)