一、痛点: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>做Web的同学可能熟悉>答案是: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端的>
6.2 踩过的坑
1. 不要用嵌套类
一开始我们想搞 TestKeys.inbound.add.submitBtn 这种嵌套结构,看起来更清晰,但Dart里嵌套类的getter不是编译时常量,不能用在 const ValueKey() 里。最后还是用了扁平化的静态常量。
2. 不要过度添加Key
不是所有Widget都需要加Key,只给测试需要操作和验证的元素加就够了。加太多反而增加维护成本。
3. 动态列表用索引拼接
列表里的元素是动态的,没法提前定义常量,用方法来生成:
static String inboundListTableRow(int index) => 'inbound.list.table.row_$index';6.3 未来规划
接下来我们打算做这几件事:
- 扩展到所有模块:把剩下的库存、SKU、基础数据模块都加上
- CI校验:在流水线里加一步,自动检查有没有重复的Key
- 文档自动生成:从常量文件自动生成测试标识文档
- AI辅助生成:用AI根据BDD场景自动生成测试代码
写在最后
UI自动化测试的痛点,本质上是"UI的易变性"和"测试的稳定性"之间的矛盾。
层次化UI定位,就是用"业务语义的稳定性"来对抗"UI实现的易变性"。
只要业务没变,不管你UI怎么改,测试都稳如老狗。
再配合BDD,不仅测试稳定,还能让产品、测试、开发都看懂测试,团队协作效率直接拉满。