【Flutter实战】层次化UI定位 + BDD

发布时间:2026/6/27 1:05:06
【Flutter实战】层次化UI定位 + BDD 一、痛点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-testidxxx])​​find.byKey(ValueKey(xxx))​跨平台仅WebWeb/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 SetString _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 什么是BDDBDDBehavior-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 步骤 Futurevoid userIsOnInboundAddPage() async { // 导航到新增入库单页面 // ... } // When 步骤 Futurevoid 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(); } Futurevoid enterSourceNo(String sourceNo) async { final input find.byKey( const ValueKey(TestKeys.inboundAddSourceNoInput), ); await tester.enterText(input, sourceNo); } Futurevoid clickSubmitButton() async { final btn find.byKey( const ValueKey(TestKeys.inboundAddSubmitBtn), ); await tester.tap(btn); await tester.pumpAndSettle(); } // Then 步骤 Futurevoid 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 锦上添花页面多了之后步骤定义可能会重复这时候可以加上POMPage Object Modelclass InboundAddPagePOM { final WidgetTester tester; InboundAddPagePOM(this.tester); Futurevoid selectWarehouse(String name) async { // ... 具体实现 } Futurevoid selectSupplier(String name) async { // ... 具体实现 } Futurevoid enterSourceNo(String no) async { // ... 具体实现 } Futurevoid clickSubmit() async { // ... 具体实现 } }BDD步骤复用POMPOM里封装了定位逻辑层次更清晰。五、实战智能仓储系统案例说了这么多理论来看看我们项目中的实际应用。5.1 项目背景我们做的是一个智能仓储管理系统WMSFlutter 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端的>天然跨平台一套标识三端复用强类型检查写错了编译不通过性能更好Widget树diff的时候直接用Key对比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不仅测试稳定还能让产品、测试、开发都看懂测试团队协作效率直接拉满。