【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 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定位的核心就是三句话:

  1. 用业务语义给UI元素命名 ------ 业务不变,标识不变
  2. 集中管理,常量引用 ------ 一处修改,全局生效
  3. 跟BDD配合使用 ------ 既好读又稳定

Flutter的 ValueKey​ 方案,比Web端的 data-testid​ 体验更好,因为:

  • 天然跨平台(一套标识三端复用)
  • 强类型检查(写错了编译不通过)
  • 性能更好(Widget树diff的时候直接用Key对比)

6.2 踩过的坑

  1. 不要用嵌套类

一开始我们想搞 TestKeys.inbound.add.submitBtn​ 这种嵌套结构,看起来更清晰,但Dart里嵌套类的getter不是编译时常量,不能用在 const ValueKey()​ 里。最后还是用了扁平化的静态常量。

  1. 不要过度添加Key

不是所有Widget都需要加Key,只给测试需要操作和验证的元素加就够了。加太多反而增加维护成本。

  1. 动态列表用索引拼接

列表里的元素是动态的,没法提前定义常量,用方法来生成:

复制代码
static String inboundListTableRow(int index) => 'inbound.list.table.row_$index';

6.3 未来规划

接下来我们打算做这几件事:

  1. 扩展到所有模块:把剩下的库存、SKU、基础数据模块都加上
  2. CI校验:在流水线里加一步,自动检查有没有重复的Key
  3. 文档自动生成:从常量文件自动生成测试标识文档
  4. AI辅助生成:用AI根据BDD场景自动生成测试代码

写在最后

UI自动化测试的痛点,本质上是"UI的易变性"和"测试的稳定性"之间的矛盾。

层次化UI定位,就是用"业务语义的稳定性"来对抗"UI实现的易变性"。

只要业务没变,不管你UI怎么改,测试都稳如老狗。

再配合BDD,不仅测试稳定,还能让产品、测试、开发都看懂测试,团队协作效率直接拉满。