HarmonyOS开发:测试脚本维护——测试代码重构
HarmonyOS开发:测试脚本维护——测试代码重构
📌 核心要点:UI测试脚本的可维护性决定了测试体系的寿命,Page Object模式分离定位与操作、测试数据集中管理、重构策略持续优化,让测试代码和业务代码一样经得起时间考验。
背景与动机
你写了50个UI测试用例,跑得挺顺。然后产品改了个需求——“登录"按钮改成"立即登录”。
你打开测试代码一看,ON.text('登录')出现了30次。改吧。改完第15次的时候你发现,有个地方是ON.text('登录账号'),这改不改?改完第30次,又发现有个地方是ON.id('login_btn'),这个不用改——但下次改id呢?
这就是测试代码的维护问题。测试代码和业务代码一样,不维护就会腐化。而且测试代码的腐化速度往往更快——因为大家觉得"测试代码不重要",随便写写就行。
但随便写的测试代码,三个月后你自己都看不懂,半年后改一个按钮要改十几个文件。最终的结果就是:测试代码没人维护,测试用例没人跑,整个测试体系形同虚设。
所以你需要像对待业务代码一样对待测试代码——设计模式、重构、代码复用,一个都不能少。
核心原理
测试代码的三个层次
graph TB
A[测试代码] --> B[测试用例层<br/>业务流程描述]
A --> C[Page Object层<br/>页面操作封装]
A --> D[测试数据层<br/>数据与配置管理]
B --> B1["只描述做什么"]
B --> B2["不关心怎么做"]
B --> B3["可读性优先"]
C --> C1["封装组件查找"]
C --> C2["封装页面操作"]
C --> C3["一处修改全局生效"]
D --> D1["测试数据集中管理"]
D --> D2["环境配置分离"]
D --> D3["数据驱动测试"]
classDef root fill:#4A90D9,stroke:#2C5F8A,color:#fff
classDef test fill:#67C23A,stroke:#3E9B2B,color:#fff
classDef page fill:#E6A23C,stroke:#B07D2B,color:#fff
classDef data fill:#F56C6C,stroke:#C94A4A,color:#fff
class A root
class B,B1,B2,B3 test
class C,C1,C2,C3 page
class D,D1,D2,D3 data
Page Object模式
Page Object是UI测试最经典的设计模式。核心思想:每个页面对应一个类,类里封装这个页面的组件查找和操作方法。测试用例只调用Page Object的方法,不直接操作组件。
flowchart LR
A[测试用例] -->|"调用方法"| B[LoginPage]
A -->|"调用方法"| C[HomePage]
A -->|"调用方法"| D[CartPage]
B -->|"查找组件"| E[Driver]
C -->|"查找组件"| E
D -->|"查找组件"| E
E -->|"操作设备"| F[被测App]
classDef test fill:#67C23A,stroke:#3E9B2B,color:#fff
classDef page fill:#E6A23C,stroke:#B07D2B,color:#fff
classDef driver fill:#4A90D9,stroke:#2C5F8A,color:#fff
classDef app fill:#F56C6C,stroke:#C94A4A,color:#fff
class A test
class B,C,D page
class E driver
class F app
重构前后对比
| 维度 | 重构前 | 重构后 |
|---|---|---|
| 组件定位 | 散落在各测试用例中 | 集中在Page Object中 |
| 操作逻辑 | 每个用例重复编写 | Page Object封装复用 |
| 改一个按钮文案 | 改N个文件 | 改1个文件 |
| 测试可读性 | 代码和逻辑混杂 | 业务流程清晰 |
| 维护成本 | 高,随用例数线性增长 | 低,新增用例成本低 |
代码实战
基础用法:从硬编码到Page Object
先看反面教材——所有逻辑都堆在测试用例里:
// ❌ 重构前:硬编码,难维护
import { Driver, ON } from '@ohos.UiTest';
export default function testLogin_old() {
const driver = Driver.create();
// 组件定位硬编码
const usernameInput = driver.findComponent(ON.id('username_input'));
usernameInput?.inputText('admin');
const passwordInput = driver.findComponent(ON.id('password_input'));
passwordInput?.inputText('123456');
const loginBtn = driver.findComponent(ON.text('登录'));
loginBtn?.click();
driver.assertComponentExistence(ON.text('首页'), 5000);
}
改成Page Object模式:
// ✅ 重构后:Page Object封装
import { Driver, ON, Component } from '@ohos.UiTest';
// ===== Page Object: 登录页 =====
class LoginPage {
private driver: Driver;
// 组件定位集中管理——改一个地方全局生效
private readonly SELECTORS = {
usernameInput: ON.id('username_input'),
passwordInput: ON.id('password_input'),
loginBtn: ON.text('登录'), // 改成"立即登录"?只改这里
loginPage: ON.id('login_page'),
};
constructor(driver: Driver) {
this.driver = driver;
}
// 页面操作封装
async login(username: string, password: string): Promise<boolean> {
const usernameInput = this.driver.findComponent(this.SELECTORS.usernameInput);
usernameInput?.inputText(username);
const passwordInput = this.driver.findComponent(this.SELECTORS.passwordInput);
passwordInput?.inputText(password);
const loginBtn = this.driver.findComponent(this.SELECTORS.loginBtn);
loginBtn?.click();
// 等待登录完成
try {
this.driver.assertComponentExistence(ON.text('首页'), 5000);
return true;
} catch {
return false;
}
}
// 验证页面是否加载
isLoaded(): boolean {
return this.driver.findComponent(this.SELECTORS.loginPage) !== null;
}
// 获取错误提示
getErrorMessage(): string {
const errorHint = this.driver.findComponent(ON.id('login_error'));
return errorHint?.getText() ?? '';
}
}
// ===== 测试用例:简洁清晰 =====
export default async function testLogin_new() {
const driver = Driver.create();
const loginPage = new LoginPage(driver);
// 测试用例只描述业务流程
const success = await loginPage.login('admin', '123456');
if (success) {
console.info('✅ 登录成功');
} else {
console.error(`❌ 登录失败: ${loginPage.getErrorMessage()}`);
}
}
看到了吗?测试用例从"找组件→操作→验证"变成了"调方法→看结果"。代码量没少多少,但维护成本天差地别——改按钮文案只改SELECTORS里的一行。
进阶用法:完整的Page Object体系
// 470_test_script_maintain_advanced.ets
import { Driver, ON, Component } from '@ohos.UiTest';
// ===== 基础Page Object =====
abstract class BasePage {
protected driver: Driver;
constructor(driver: Driver) {
this.driver = driver;
}
// 通用等待方法
protected async waitForComponent(selector: ON, timeout: number = 5000): Promise<Component> {
const startTime = Date.now();
while (Date.now() - startTime < timeout) {
const component = this.driver.findComponent(selector);
if (component !== null) return component;
await this.sleep(500);
}
throw new Error(`等待组件超时: ${selector}`);
}
// 通用点击
protected async safeClick(selector: ON, timeout?: number): Promise<void> {
const component = await this.waitForComponent(selector, timeout);
component.click();
}
// 通用输入
protected async safeInput(selector: ON, text: string, timeout?: number): Promise<void> {
const component = await this.waitForComponent(selector, timeout);
component.inputText(text);
}
// 页面是否加载
abstract isLoaded(): boolean;
// 截图
screenshot(name: string): void {
this.driver.captureScreen(`/data/local/tmp/${name}_${Date.now()}.png`);
}
protected sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
// ===== 首页Page Object =====
class HomePage extends BasePage {
private readonly SELECTORS = {
homePage: ON.id('home_page'),
searchBox: ON.id('search_box'),
tabHome: ON.id('tab_home'),
tabCart: ON.id('tab_cart'),
tabProfile: ON.id('tab_profile'),
productList: ON.id('product_list'),
};
isLoaded(): boolean {
return this.driver.findComponent(this.SELECTORS.homePage) !== null;
}
// 搜索商品
async searchProduct(keyword: string): Promise<SearchResultPage> {
await this.safeClick(this.SELECTORS.searchBox);
await this.safeInput(ON.id('search_input'), keyword);
this.driver.pressKey(KeyCode.ENTER);
await this.sleep(1000);
return new SearchResultPage(this.driver);
}
// 浏览商品
async browseProduct(index: number): Promise<ProductDetailPage> {
const list = await this.waitForComponent(this.SELECTORS.productList);
const items = list.findComponents(ON.type('ListItem'));
if (index >= items.length) {
throw new Error(`商品索引超出范围: ${index} >= ${items.length}`);
}
items[index].click();
await this.sleep(1000);
return new ProductDetailPage(this.driver);
}
// 导航到购物车
async goToCart(): Promise<CartPage> {
await this.safeClick(this.SELECTORS.tabCart);
await this.sleep(500);
return new CartPage(this.driver);
}
// 导航到个人中心
async goToProfile(): Promise<ProfilePage> {
await this.safeClick(this.SELECTORS.tabProfile);
await this.sleep(500);
return new ProfilePage(this.driver);
}
}
// ===== 搜索结果页Page Object =====
class SearchResultPage extends BasePage {
private readonly SELECTORS = {
resultList: ON.id('search_result_list'),
searchInput: ON.id('search_input'),
};
isLoaded(): boolean {
return this.driver.findComponent(this.SELECTORS.resultList) !== null;
}
// 获取搜索结果数量
getResultCount(): number {
const list = this.driver.findComponent(this.SELECTORS.resultList);
if (list === null) return 0;
return list.findComponents(ON.type('ListItem')).length;
}
// 选择第N个搜索结果
async selectResult(index: number): Promise<ProductDetailPage> {
const list = await this.waitForComponent(this.SELECTORS.resultList);
const items = list.findComponents(ON.type('ListItem'));
if (index >= items.length) {
throw new Error(`搜索结果索引超出范围`);
}
items[index].click();
await this.sleep(1000);
return new ProductDetailPage(this.driver);
}
}
// ===== 商品详情页Page Object =====
class ProductDetailPage extends BasePage {
private readonly SELECTORS = {
detailPage: ON.id('product_detail_page'),
productName: ON.id('product_name'),
productPrice: ON.id('product_price'),
favoriteBtn: ON.id('favorite_btn'),
addToCartBtn: ON.id('add_to_cart'),
buyNowBtn: ON.id('buy_now'),
};
isLoaded(): boolean {
return this.driver.findComponent(this.SELECTORS.detailPage) !== null;
}
// 获取商品名称
getProductName(): string {
const name = this.driver.findComponent(this.SELECTORS.productName);
return name?.getText() ?? '';
}
// 获取商品价格
getProductPrice(): string {
const price = this.driver.findComponent(this.SELECTORS.productPrice);
return price?.getText() ?? '';
}
// 收藏
async favorite(): Promise<void> {
await this.safeClick(this.SELECTORS.favoriteBtn);
}
// 加入购物车
async addToCart(): Promise<void> {
await this.safeClick(this.SELECTORS.addToCartBtn);
await this.sleep(1000);
}
// 立即购买
async buyNow(): Promise<CheckoutPage> {
await this.safeClick(this.SELECTORS.buyNowBtn);
await this.sleep(1000);
return new CheckoutPage(this.driver);
}
// 返回
async goBack(): Promise<void> {
this.driver.pressKey(KeyCode.BACK);
await this.sleep(500);
}
}
// ===== 购物车Page Object =====
class CartPage extends BasePage {
private readonly SELECTORS = {
cartPage: ON.id('cart_page'),
cartItemCheckbox: ON.id('cart_item_checkbox'),
checkoutBtn: ON.text('结算'),
emptyCart: ON.id('empty_cart'),
};
isLoaded(): boolean {
return this.driver.findComponent(this.SELECTORS.cartPage) !== null;
}
// 获取购物车商品数量
getCartItemCount(): number {
const items = this.driver.findComponents(ON.id('cart_item'));
return items.length;
}
// 全选
async selectAll(): Promise<void> {
await this.safeClick(this.SELECTORS.cartItemCheckbox);
}
// 结算
async checkout(): Promise<CheckoutPage> {
await this.safeClick(this.SELECTORS.checkoutBtn);
await this.sleep(1000);
return new CheckoutPage(this.driver);
}
// 是否为空
isEmpty(): boolean {
return this.driver.findComponent(this.SELECTORS.emptyCart) !== null;
}
}
// ===== 结算页Page Object =====
class CheckoutPage extends BasePage {
private readonly SELECTORS = {
checkoutPage: ON.id('checkout_page'),
confirmOrderBtn: ON.id('confirm_order'),
};
isLoaded(): boolean {
return this.driver.findComponent(this.SELECTORS.checkoutPage) !== null;
}
// 确认订单
async confirmOrder(): Promise<boolean> {
await this.safeClick(this.SELECTORS.confirmOrderBtn);
try {
this.driver.assertComponentExistence(ON.text('订单提交成功'), 10000);
return true;
} catch {
return false;
}
}
}
// ===== 个人中心Page Object =====
class ProfilePage extends BasePage {
private readonly SELECTORS = {
profilePage: ON.id('profile_page'),
orderEntry: ON.text('我的订单'),
settingsEntry: ON.text('设置'),
};
isLoaded(): boolean {
return this.driver.findComponent(this.SELECTORS.profilePage) !== null;
}
// 进入订单列表
async goToOrders(): Promise<void> {
await this.safeClick(this.SELECTORS.orderEntry);
await this.sleep(500);
}
// 进入设置
async goToSettings(): Promise<void> {
await this.safeClick(this.SELECTORS.settingsEntry);
await this.sleep(500);
}
}
完整示例:数据驱动测试 + 重构策略
// 470_test_script_maintain_full.ets
import { Driver, ON } from '@ohos.UiTest';
// ===== 测试数据管理 =====
class TestDataManager {
private static instance: TestDataManager;
private data: Map<string, Map<string, string>>;
private constructor() {
this.data = new Map();
this.loadTestData();
}
static getInstance(): TestDataManager {
if (!TestDataManager.instance) {
TestDataManager.instance = new TestDataManager();
}
return TestDataManager.instance;
}
// 加载测试数据(实际项目从JSON文件读取)
private loadTestData(): void {
// 登录测试数据
const loginData = new Map<string, string>();
loginData.set('valid_username', 'admin');
loginData.set('valid_password', 'Admin123456');
loginData.set('invalid_username', 'wrong_user');
loginData.set('invalid_password', 'wrong_pass');
loginData.set('empty_username', '');
loginData.set('empty_password', '');
this.data.set('login', loginData);
// 搜索测试数据
const searchData = new Map<string, string>();
searchData.set('keyword_valid', '蓝牙耳机');
searchData.set('keyword_empty', '');
searchData.set('keyword_special', '@#$%');
searchData.set('keyword_long', '这是一个非常长的搜索关键词用来测试输入框的边界情况');
this.data.set('search', searchData);
// 环境配置
const envData = new Map<string, string>();
envData.set('wait_timeout', '5000');
envData.set('long_timeout', '15000');
envData.set('retry_count', '2');
this.data.set('env', envData);
}
// 获取测试数据
getData(category: string, key: string): string {
return this.data.get(category)?.get(key) ?? '';
}
// 获取环境配置
getConfig(key: string): string {
return this.data.get('env')?.get(key) ?? '';
}
// 生成唯一数据(避免测试间冲突)
generateUnique(prefix: string): string {
return `${prefix}_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
}
}
// ===== 数据驱动测试框架 =====
class DataDrivenTestRunner {
private driver: Driver;
private dataManager: TestDataManager;
constructor() {
this.driver = Driver.create();
this.dataManager = TestDataManager.getInstance();
}
// 数据驱动测试:用多组数据跑同一个测试逻辑
async runWithData(
testFn: (driver: Driver, data: Record<string, string>) => Promise<boolean>,
dataSet: Array<Record<string, string>>,
testName: string
): Promise<void> {
console.info(`\n🧪 数据驱动测试: ${testName}`);
for (let i = 0; i < dataSet.length; i++) {
const data = dataSet[i];
const caseName = data._name ?? `用例${i + 1}`;
try {
const passed = await testFn(this.driver, data);
const status = passed ? '✅' : '❌';
console.info(` ${status} ${caseName}`);
} catch (error) {
console.error(` ❌ ${caseName}: ${(error as Error).message}`);
}
}
}
getDriver(): Driver {
return this.driver;
}
getDataManager(): TestDataManager {
return this.dataManager;
}
}
// ===== 重构后的测试用例 =====
// 测试1:登录流程(数据驱动)
async function testLoginFlow(): Promise<void> {
const runner = new DataDrivenTestRunner();
const driver = runner.getDriver();
const loginPage = new LoginPage(driver);
// 定义多组测试数据
const loginDataSets = [
{ _name: '正常登录', username: 'admin', password: 'Admin123456', expectSuccess: 'true' },
{ _name: '空用户名', username: '', password: 'Admin123456', expectSuccess: 'false' },
{ _name: '空密码', username: 'admin', password: '', expectSuccess: 'false' },
{ _name: '错误密码', username: 'admin', password: 'wrong', expectSuccess: 'false' },
];
await runner.runWithData(async (drv, data) => {
// 确保在登录页
if (!loginPage.isLoaded()) {
// 导航到登录页...
}
const success = await loginPage.login(data.username, data.password);
const expected = data.expectSuccess === 'true';
if (success !== expected) {
if (expected) {
console.error(`登录失败: ${loginPage.getErrorMessage()}`);
}
return false;
}
return true;
}, loginDataSets, '登录流程');
}
// 测试2:购物流程(链式调用,可读性极高)
async function testShoppingFlow(): Promise<void> {
const driver = Driver.create();
// 从首页开始
const homePage = new HomePage(driver);
if (!homePage.isLoaded()) {
throw new Error('首页未加载');
}
// 搜索 → 选择商品 → 加入购物车 → 结算
const searchResult = await homePage.searchProduct('蓝牙耳机');
const productDetail = await searchResult.selectResult(0);
const productName = productDetail.getProductName();
console.info(`正在购买: ${productName}`);
await productDetail.addToCart();
// 返回首页,进入购物车
await productDetail.goBack();
const cartPage = await homePage.goToCart();
if (cartPage.isEmpty()) {
throw new Error('购物车为空,加入购物车可能失败');
}
await cartPage.selectAll();
const checkoutPage = await cartPage.checkout();
const orderSuccess = await checkoutPage.confirmOrder();
if (!orderSuccess) {
throw new Error('订单提交失败');
}
console.info('🎉 购物流程测试完成');
}
// 测试3:搜索功能(数据驱动 + 多关键词)
async function testSearchFunction(): Promise<void> {
const runner = new DataDrivenTestRunner();
const driver = runner.getDriver();
const searchDatasets = [
{ _name: '正常关键词', keyword: '蓝牙耳机', expectHasResult: 'true' },
{ _name: '空关键词', keyword: '', expectHasResult: 'false' },
{ _name: '特殊字符', keyword: '@#$%', expectHasResult: 'false' },
{ _name: '超长关键词', keyword: '这是一个非常长的搜索关键词用来测试输入框的边界情况', expectHasResult: 'false' },
];
await runner.runWithData(async (drv, data) => {
const homePage = new HomePage(drv);
if (!homePage.isLoaded()) return false;
const searchResult = await homePage.searchProduct(data.keyword);
const hasResult = searchResult.getResultCount() > 0;
const expected = data.expectHasResult === 'true';
return hasResult === expected;
}, searchDatasets, '搜索功能');
}
// 执行所有测试
export default async function runAllRefactoredTests() {
console.info('🧪 ===== 开始重构后的测试 =====');
await testLoginFlow();
await testShoppingFlow();
await testSearchFunction();
console.info('\n🧪 ===== 所有测试完成 =====');
}
踩坑与注意事项
坑1:Page Object过度封装
Page Object不是越细越好。如果一个方法只被一个测试用例用到,封装它反而增加了维护成本——你改方法签名的时候要同时改调用方。
原则:被2个以上测试用例使用的操作才封装成方法;只被1个用例使用的操作直接写在测试用例里。
坑2:Page Object之间的导航耦合
homePage.searchProduct()返回SearchResultPage——这种链式调用很优雅,但有个问题:如果搜索流程变了(比如搜索不再跳转新页面,而是在当前页显示结果),你需要改searchProduct的返回值类型,所有调用方都要改。
解决方案:Page Object的导航方法返回下一个Page Object是合理的,但不要在Page Object里做太多业务判断。如果流程可能变化,用更灵活的返回方式。
坑3:测试数据的硬编码
测试数据直接写在代码里,改数据就要改代码。如果测试数据经常变化(比如接口字段调整),维护成本很高。
解决方案:测试数据集中管理,从JSON文件读取,代码和数据分离。
// 测试数据文件 test_data.json
{
"login": {
"valid": { "username": "admin", "password": "Admin123456" },
"invalid": { "username": "wrong", "password": "wrong" }
}
}
// 代码中读取
const testData = JSON.parse(fileIo.readTextSync('./test_data.json'));
坑4:BasePage的"上帝类"倾向
BasePage很容易变成什么都往里塞的"上帝类"。今天加个等待方法,明天加个截图方法,后天加个日志方法……BasePage越来越臃肿。
解决方案:BasePage只放最通用的方法(等待、点击、输入)。其他能力(截图、日志、性能采集)用独立的工具类,通过组合而非继承来使用。
坑5:忘记处理测试失败后的状态清理
测试失败后,App可能停在某个中间状态。下一个测试用例假设App在首页,但实际在详情页——于是找不到组件,也失败了。这种"级联失败"会让整个测试套件崩溃。
解决方案:每个测试用例开始前重置到已知状态。
// 每个测试用例的beforeEach
async function resetToHomePage(driver: Driver): Promise<void> {
// 连按返回键直到回到首页
for (let i = 0; i < 5; i++) {
const home = driver.findComponent(ON.id('home_page'));
if (home !== null) break;
driver.pressKey(KeyCode.BACK);
await sleep(300);
}
// 确保在首页Tab
const homeTab = driver.findComponent(ON.id('tab_home'));
homeTab?.click();
}
HarmonyOS 6适配说明
HarmonyOS 6对测试脚本维护做了以下增强:
- 测试脚手架生成:DevEco Studio内置Page Object代码生成器,根据UI组件树自动生成Page Object骨架代码
- 测试数据管理器:框架内置
TestDataManager类,支持从JSON/YAML文件加载测试数据 - 测试用例模板:提供数据驱动测试、参数化测试等模板,一键生成测试用例框架
- 智能等待策略:
WaitStrategy类封装多种等待策略(固定等待、条件等待、指数退避等待),替代手写sleep - 测试代码质量检查:DevEco Studio新增测试代码质量检查,检测硬编码、重复代码等问题
迁移注意:BasePage模式在HarmonyOS 6中仍然适用,但建议使用框架内置的TestPage基类替代自定义BasePage,减少维护成本。
总结
测试代码的维护性不是"锦上添花",是"生死攸关"。维护性差的测试代码,三个月后就是一堆没人敢碰的遗留代码。维护性好的测试代码,可以持续为项目保驾护航。
| 维度 | 评价 |
|---|---|
| 学习难度 | ⭐⭐⭐ Page Object概念简单,设计合理的抽象层次需要经验 |
| 使用频率 | ⭐⭐⭐⭐⭐ 每个测试项目都需要 |
| 重要程度 | ⭐⭐⭐⭐⭐ 决定测试体系的寿命 |
核心原则:测试代码和业务代码同等重要。你不会在业务代码里到处硬编码、不封装、不重构,那测试代码也不应该。把Page Object模式、数据管理、重构策略用起来,让测试代码经得起时间的考验。
- 点赞
- 收藏
- 关注作者
评论(0)