一、痛点:UI一变,测试全挂?
做过Flutter自动化测试的同学,想必都有过这样的噩梦:
场景一:产品说"这个按钮位置调一下,颜色换一下",开发改完一提交,测试脚本80%全红了。
场景二:UI大改版,测试同学加班加点改定位表达式,改到怀疑人生。
场景三:同一个元素,在Web端用CSS选择器,在iOS端用class chain,在Android端用id,三套脚本维护成本爆炸。
根本原因是什么?
传统的UI自动化测试,定位方式严重依赖UI结构:
- find.text('提交') ------ 文案改了就挂
- find.byType(ElevatedButton).first ------ 按钮顺序变了就挂
- find.ancestor(of: ..., matching: ...) ------ 层级变了就挂
有没有一种方式,能让元素定位像数据库主键一样稳定?
答案是:有!层次化UI定位 + BDD
二、方案:层次化UI定位是什么?
2.1 核心思想
层次化UI定位,简单来说就是:
给每个关键UI元素分配一个"业务语义ID",这个ID只跟业务有关,跟UI怎么实现、怎么排版没关系。
就像每个人都有身份证号,不管你换什么衣服、剪什么发型,身份证号不变。UI元素的业务ID也是一样,不管你按钮放左边还是右边,颜色是红还是蓝,只要业务含义没变,ID就不变。
2.2 命名规范
我们采用四层命名结构:
[模块].[页面].[组件].[元素]
| 层级 | 说明 | 示例 |
|---|---|---|
| 模块 | 业务模块名称 | inbound(入库)、outbound(出库) |
| 页面 | 页面名称 | list(列表页)、add(新增页) |
| 组件 | 页面内组件 | searchForm(搜索表单)、productList(商品列表) |
| 元素 | 具体交互元素 | submitBtn(提交按钮)、warehouseDropdown(仓库下拉框) |
举几个栗子:
inbound.list.searchForm.searchBtn # 入库单列表 - 搜索表单 - 搜索按钮
inbound.add.basicInfo.warehouseDropdown # 新增入库单 - 基本信息 - 仓库下拉框
inbound.add.productList.addBtn # 新增入库单 - 商品列表 - 添加按钮
outbound.list.table.row_0 # 出库单列表 - 表格 - 第0行
2.3 为什么是四层?
- 太少(1-2层):容易重名,特别是复杂页面
- 太多(5层以上):太啰嗦,写起来麻烦
- 四层刚刚好:覆盖了大部分业务场景,又不至于太复杂
三、Flutter中的实现
3.1 技术选型:ValueKey vs data-testid
做Web的同学可能熟悉 data-testid 属性,Flutter里有没有类似的东西?
答案是:ValueKey!
| 对比维度 | Web端data-testid | FlutterValueKey |
|---|---|---|
| 元素标识方式 | HTML属性 | Widget的key参数 |
| 测试定位 | document.querySelector('data-testid="xxx"') | find.byKey(ValueKey('xxx')) |
| 跨平台 | 仅Web | Web/iOS/Android 三端通用 |
| 类型安全 | 无(字符串) | 有(Dart强类型) |
| 编译时检查 | 无 | 有(常量引用检查) |
结论:Flutter的ValueKey方案,比Web端的data-testid更强!
3.2 第一步:创建常量管理文件
集中管理是关键! 千万不要把字符串散落在各个文件里,否则以后改起来想死。
我们创建一个 test_keys.dart 文件,用静态常量统一管理:
// lib/constants/test_keys.dart
abstract class TestKeys {
TestKeys._();
// ========== 入库单模块 ==========
static const inboundListAddBtn = 'inbound.list.addBtn';
static const inboundListTable = 'inbound.list.table';
static const inboundListEmptyState = 'inbound.list.emptyState';
static const inboundListSearchFormSearchBtn = 'inbound.list.searchForm.searchBtn';
static const inboundListSearchFormResetBtn = 'inbound.list.searchForm.resetBtn';
static const inboundListSearchFormBillNoInput = 'inbound.list.searchForm.billNoInput';
static const inboundListSearchFormStatusDropdown = 'inbound.list.searchForm.statusDropdown';
static const inboundAddBackBtn = 'inbound.add.backBtn';
static const inboundAddCancelBtn = 'inbound.add.cancelBtn';
static const inboundAddSubmitBtn = 'inbound.add.submitBtn';
static const inboundAddWarehouseDropdown = 'inbound.add.basicInfo.warehouseDropdown';
static const inboundAddSupplierDropdown = 'inbound.add.basicInfo.supplierDropdown';
static const inboundAddSourceTypeDropdown = 'inbound.add.basicInfo.sourceTypeDropdown';
static const inboundAddSourceNoInput = 'inbound.add.basicInfo.sourceNoInput';
static const inboundAddPriorityDropdown = 'inbound.add.basicInfo.priorityDropdown';
static const inboundAddRemarkInput = 'inbound.add.basicInfo.remarkInput';
static const inboundAddProductListAddBtn = 'inbound.add.productList.addBtn';
static const inboundAddProductListTable = 'inbound.add.productList.table';
static const inboundAddProductDialogSkuInput = 'inbound.add.productDialog.skuInput';
static const inboundAddProductDialogQuantityInput = 'inbound.add.productDialog.quantityInput';
static const inboundAddProductDialogConfirmBtn = 'inbound.add.productDialog.confirmBtn';
// ========== 出库单模块 ==========
static const outboundListAddBtn = 'outbound.list.addBtn';
static const outboundListTable = 'outbound.list.table';
// ... 更多标识
// 动态标识(列表行)
static String inboundListTableRow(int index) => 'inbound.list.table.row_$index';
}
为什么用静态常量而不是嵌套类?
一开始我们也尝试过嵌套类(TestKeys.inbound.add.submitBtn),但发现一个问题:
// 这样写会报错!因为嵌套类的getter不是编译时常量
const ValueKey(TestKeys.inbound.add.submitBtn) // 编译错误
所以最后选择了扁平化的静态常量,确保可以在 const 表达式中使用:
const ValueKey(TestKeys.inboundAddSubmitBtn) // 没问题
3.3 第二步:给Widget加Key
这一步最简单,就是给关键交互元素加上 key 参数:
// 改造前
ElevatedButton(
onPressed: _submitInboundOrder,
child: const Text('提交'),
)
// 改造后
ElevatedButton(
key: const ValueKey(TestKeys.inboundAddSubmitBtn),
onPressed: _submitInboundOrder,
child: const Text('提交'),
)
哪些元素需要加Key?
| 元素类型 | 是否需要 | 说明 |
|---|---|---|
| 按钮 | 需要 | 点击操作是最常见的测试步骤 |
| 输入框 | 需要 | 文本输入是测试的核心操作 |
| 下拉框 | 需要 | 选择操作也是高频测试场景 |
| 复选框/开关 | 需要 | 状态切换需要定位 |
| 列表/表格 | 需要 | 用于断言数据是否正确展示 |
| 空状态/错误状态 | 需要 | 验证异常场景 |
| 纯展示文本 | 不需要 | 用业务ID定位文本意义不大 |
| 装饰性容器 | 不需要 | 不参与交互的不需要 |
原则:只给测试需要操作或验证的元素加Key,不要滥用。
3.4 第三步:测试中使用
在Flutter测试中,用 find.byKey() 来定位元素:
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/constants/test_keys.dart';
void main() {
testWidgets('创建入库单测试', (tester) async {
// 点击新增按钮
await tester.tap(find.byKey(
const ValueKey(TestKeys.inboundListAddBtn),
));
await tester.pumpAndSettle();
// 选择仓库
await tester.tap(find.byKey(
const ValueKey(TestKeys.inboundAddWarehouseDropdown),
));
await tester.pumpAndSettle();
// 输入来源单号
await tester.enterText(
find.byKey(const ValueKey(TestKeys.inboundAddSourceNoInput)),
'PO202406080001',
);
// 点击提交
await tester.tap(find.byKey(
const ValueKey(TestKeys.inboundAddSubmitBtn),
));
await tester.pumpAndSettle();
// 验证成功
expect(find.text('入库单已提交'), findsOneWidget);
});
}
看到没有?整个测试脚本里,没有一个 find.text()、没有一个 find.byType(),全是业务语义的Key!
以后UI怎么改,只要业务没变,测试脚本一行都不用改。
3.5 进阶:唯一性校验
人总会犯错,万一两个元素用了同一个Key怎么办?
我们写了一个简单的运行时校验工具:
// lib/utils/test_key_validator.dart
class TestKeyValidator {
static final Set<String> _registeredKeys = {};
static bool _validationEnabled = true;
static void register(String key) {
if (!_validationEnabled) return;
if (_registeredKeys.contains(key)) {
throw ArgumentError('重复的测试标识: $key');
}
_registeredKeys.add(key);
}
static void disableValidation() {
_validationEnabled = false;
}
}
开发环境开启校验,生产环境关闭。开发时如果发现重复的Key,直接报错,从源头避免问题。
四、与BDD的完美结合
4.1 什么是BDD?
BDD(Behavior-Driven Development,行为驱动开发)是一种协作式的软件开发方法,核心是:
用自然语言描述系统行为,让非技术人员也能看懂测试。
BDD用Gherkin语法来写测试场景:
Scenario: 正常创建采购入库单
Given 用户在新增入库单页面
When 用户选择仓库"主仓库"
And 选择供应商"供应商A"
And 输入来源单号"PO202406080001"
And 添加商品"SKU001"数量100
And 点击提交按钮
Then 应成功创建入库单
And 入库单状态为"待验收"
产品、测试、开发都能看懂这份文档,这就是BDD的魅力。
4.2 为什么要跟层次化定位结合?
BDD解决了"测试写什么"的问题,但没有解决"测试怎么实现才稳定"的问题。
如果BDD步骤的底层实现还是用 find.text() 这种脆弱的定位方式,那BDD场景写得再漂亮,一到UI改版还是全挂。
层次化定位 + BDD \= 既好读又稳定的自动化测试
┌──────────────────────────────────────────────────────────┐
│ BDD场景(自然语言) │
│ "用户点击提交按钮" ←── 业务语义,人人都懂 │
└──────────────────────┬───────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────┐
│ 步骤定义(代码实现) │
│ find.byKey(ValueKey(TestKeys.inboundAddSubmitBtn)) │
│ ←── 稳定定位,UI改版不影响 │
└──────────────────────────────────────────────────────────┘
4.3 实战:BDD步骤定义
我们用 bdd_framework 来实现BDD,结合层次化定位:
import 'package:flutter_test/flutter_test.dart';
import 'package:bdd_framework/bdd_framework.dart';
import '../constants/test_keys.dart';
class InboundSteps {
final WidgetTester tester;
InboundSteps(this.tester);
// Given 步骤
Future<void> userIsOnInboundAddPage() async {
// 导航到新增入库单页面
// ...
}
// When 步骤
Future<void> selectWarehouse(String warehouseName) async {
final dropdown = find.byKey(
const ValueKey(TestKeys.inboundAddWarehouseDropdown),
);
await tester.tap(dropdown);
await tester.pumpAndSettle();
final item = find.text(warehouseName);
await tester.tap(item);
await tester.pumpAndSettle();
}
Future<void> enterSourceNo(String sourceNo) async {
final input = find.byKey(
const ValueKey(TestKeys.inboundAddSourceNoInput),
);
await tester.enterText(input, sourceNo);
}
Future<void> clickSubmitButton() async {
final btn = find.byKey(
const ValueKey(TestKeys.inboundAddSubmitBtn),
);
await tester.tap(btn);
await tester.pumpAndSettle();
}
// Then 步骤
Future<void> shouldSeeSuccessMessage() async {
expect(find.text('入库单已提交'), findsOneWidget);
}
}
然后BDD测试用例就变成了这样:
void main() {
final feature = BddFeature('入库单新增功能');
feature.scenario('正常创建采购入库单', (tester) async {
final steps = InboundSteps(tester);
await steps.userIsOnInboundAddPage();
await steps.selectWarehouse('主仓库');
await steps.selectSupplier('供应商A');
await steps.enterSourceNo('PO202406080001');
await steps.addProduct('SKU001', 100);
await steps.clickSubmitButton();
await steps.shouldSeeSuccessMessage();
});
}
你看,步骤实现里全是 TestKeys.xxx,没有一个脆弱的定位方式。
以后UI改版,只要业务语义没变,BDD场景不用改,步骤定义也不用改,测试照样通过。
4.4 Page Object Model 锦上添花
页面多了之后,步骤定义可能会重复,这时候可以加上POM(Page Object Model):
class InboundAddPagePOM {
final WidgetTester tester;
InboundAddPagePOM(this.tester);
Future<void> selectWarehouse(String name) async {
// ... 具体实现
}
Future<void> selectSupplier(String name) async {
// ... 具体实现
}
Future<void> enterSourceNo(String no) async {
// ... 具体实现
}
Future<void> clickSubmit() async {
// ... 具体实现
}
}
BDD步骤复用POM,POM里封装了定位逻辑,层次更清晰。
五、实战:智能仓储系统案例
说了这么多理论,来看看我们项目中的实际应用。
5.1 项目背景
我们做的是一个智能仓储管理系统(WMS),Flutter Web开发,功能包括:
- 入库管理(入库单、验收、上架)
- 出库管理(出库单、拣货、打包、发货)
- 库存管理
- SKU管理
- 基础数据管理
业务比较复杂,页面多,交互也多,自动化测试的需求很迫切。
5.2 实施过程
我们的实施分了三步走:
第一步:制定规范
先花了半天时间,团队一起讨论出了:
- 命名规范(四层结构)
- 哪些元素需要加Key
- 代码review的时候要检查Key
规范文档写好了,后面就按规矩来。
第二步:核心页面试点
选了最复杂的入库单模块作为试点:
| 页面 | 元素数量 |
|---|---|
| 入库单列表页 | 11个 |
| 入库单新增页 | 23个 |
| 合计 | 34个 |
开发花了大约2个小时,给这两个页面的关键元素都加上了Key。
第三步:全面推广
试点效果不错,就开始全面推广:
| 模块 | 页面数 | 标识数量 |
|---|---|---|
| 入库管理 | 6个 | ~50个 |
| 出库管理 | 8个 | ~60个 |
| 上架任务 | 1个 | ~15个 |
| 拣货任务 | 1个 | ~15个 |
| 合计 | 16个 | ~140个 |
目前已经完成了入库、出库、上架任务三个核心模块的改造。
5.3 具体示例:出库单列表页
来看看出库单列表页的改造前后对比:
改造前(测试定位长这样):
// 点击新增按钮
await tester.tap(find.text('新增出库单'));
// 输入出库单号
await tester.enterText(find.byType(TextField).first, 'OUT20240608001');
// 选择状态
await tester.tap(find.byType(DropdownButtonFormField).last);
改造后:
// 点击新增按钮
await tester.tap(find.byKey(
const ValueKey(TestKeys.outboundListAddBtn),
));
// 输入出库单号
await tester.enterText(
find.byKey(const ValueKey(TestKeys.outboundListSearchFormBillNoInput)),
'OUT20240608001',
);
// 选择状态
await tester.tap(find.byKey(
const ValueKey(TestKeys.outboundListSearchFormStatusDropdown),
));
对比一下:
| 维度 | 改造前 | 改造后 |
|---|---|---|
| 可读性 | 不知道第一个TextField是啥 | 一看就知道是单号输入框 |
| 稳定性 | 加个搜索框就挂了 | UI怎么改都不怕 |
| 可维护性 | 改UI要同步改测试 | 业务不变就不用改 |
5.4 真实案例:一次UI改版的故事
上个月,产品说:"搜索表单要重新设计一下,原来的一行改成两行布局,再加几个筛选条件。"
开发同学吭哧吭哧改了两天UI,然后提测。
测试同学本来以为要加班改测试脚本,结果跑了一遍自动化测试,全绿!
为什么?因为虽然UI布局变了,但每个元素的业务语义没变,Key也没变,测试脚本一个字都不用改。
这就是层次化定位的威力!
六、总结与展望
6.1 总结一下
层次化UI定位的核心就是三句话:
- 用业务语义给UI元素命名 ------ 业务不变,标识不变
- 集中管理,常量引用 ------ 一处修改,全局生效
- 跟BDD配合使用 ------ 既好读又稳定
Flutter的 ValueKey 方案,比Web端的 data-testid 体验更好,因为:
- 天然跨平台(一套标识三端复用)
- 强类型检查(写错了编译不通过)
- 性能更好(Widget树diff的时候直接用Key对比)
6.2 踩过的坑
- 不要用嵌套类
一开始我们想搞 TestKeys.inbound.add.submitBtn 这种嵌套结构,看起来更清晰,但Dart里嵌套类的getter不是编译时常量,不能用在 const ValueKey() 里。最后还是用了扁平化的静态常量。
- 不要过度添加Key
不是所有Widget都需要加Key,只给测试需要操作和验证的元素加就够了。加太多反而增加维护成本。
- 动态列表用索引拼接
列表里的元素是动态的,没法提前定义常量,用方法来生成:
static String inboundListTableRow(int index) => 'inbound.list.table.row_$index';
6.3 未来规划
接下来我们打算做这几件事:
- 扩展到所有模块:把剩下的库存、SKU、基础数据模块都加上
- CI校验:在流水线里加一步,自动检查有没有重复的Key
- 文档自动生成:从常量文件自动生成测试标识文档
- AI辅助生成:用AI根据BDD场景自动生成测试代码
写在最后
UI自动化测试的痛点,本质上是"UI的易变性"和"测试的稳定性"之间的矛盾。
层次化UI定位,就是用"业务语义的稳定性"来对抗"UI实现的易变性"。
只要业务没变,不管你UI怎么改,测试都稳如老狗。
再配合BDD,不仅测试稳定,还能让产品、测试、开发都看懂测试,团队协作效率直接拉满。