写点什么

贝壳 Flutter UI 自动化测试原理与实践 - 已开源

  • 2022 年 3 月 24 日
  • 本文字数:4765 字

    阅读完需:约 16 分钟

贝壳Flutter UI 自动化测试原理与实践 - 已开源

开源地址

一、引言


Flutter 是 Google 推出的跨平台 UI 框架,一套代码,多端复用,性能上可媲美原生。Flutter 的诞生,引起业界的广泛关注,越来越多的互联网公司在 APP 中使用 Flutter 技术,像 BAT、TMD 等国内互联网公司,研究和使用都比较深入。Flutter 技术的引入,研发原来需要为 Android、iOS 编写两套代码,现在仅需编写一套,移动端研发的效率得到了大大的提升。但对于移动端测试同学而言,仍需对不同的平台做测试,工作量并没有同等比例减少。由于原有 Native 的自动化框架识别不到 Flutter 页面元素, Flutter 页面也就不能通过自动化的方式进行回归测试。 随着 Flutter 页面的增多,可通过自动化手段测试的占比随之降低, QA 人工测试的成本越来越高。那么我们如何提高 Flutter 页面的回归测试效率就迫在眉睫,于是我们开启了 Flutter UI 自动化方向的探索与研究。

二、方案调研


通过调研,目前业界自动化框架对 Flutter 的支持并不完善,不能满足我们的业务述求。基于此,我们从稳定性、业务接入成本以及兼容性等方面进行调研,对比结果如下:


代码侵入性:方案本身是否需要对业务代码进行改动

  • 脚本语言:编写测试 case 的语言(针对 QA, Python 语言为最佳)

  • key 值:可以标示唯一页面元素的值

  • KeMTC:贝壳现有的移动端测试平台,目前各个业务团队已稳定接入。


    • Flutter-driver : Flutter 官方提供的集成测试方案,有着丰富的 API,但需要在业务代码中手动设置视图的 ID,对业务代码有侵入;另外,只支持 Dart 语言编写测试脚本,不支持 Python,无法集成到 KeMTC 项目中,对于移动端测试同学增加太大的学习成本。


    • Appium-flutter-driver :是对 Flutter-driver 的封装优化。Appium 是一种跨平台 UI 自动化测试框架,借助 Appium 的能力,可以用 Python 编写测试脚本;但是 Appium-Flutter-driver 并没有解决 Flutter-driver 的需要手动设置视图 ID 的痛点。


    经过详细的调研对比:以上两种方案都无法满足业务的需求,尤其是在稳定性、接入成本方面。于是我们开启了 Flutter UI 自动化 Ke-FUT( Ke-FlutterUiTest 的简称)的自研之路。

    三、技术原理与实现

    3.1、概述

    Ke-FUT 自动化的整体设计思路与原生相似,如图所示共有两个主要的步骤:


    • 获取元素 ID: ID 是指可以标示唯一页面元素的值。自动化测试脚本通过这个值去映射到对应的页面元素进行操作。

    • 驱动视图元素:针对对应的视图完成模拟操作,其中包括点击、滑动、长按、输入等。


    贝壳内部使用 UIAutomator2(简称 U2)框架实现 Android 原生的自动化测试。在 U2 中“分析页面元素”和“驱动视图”借助 Android SDK 中的 UIAutomator 进行的。以此类比, Flutter 实现自动化测试也需要提供类似的能力。

    3.2、项目架构


    架构设计如图所示,共分为 3 层。应用层、桥接层和服务层。

    • 应用层:提供了面向测试人员的使用接口,可以低成本接入到现有的自动化测试框架中。

    • 桥接层:接受应用层的消息,调用服务层提供的服务。

    • 服务层:运行在 Flutter App 中,向上提供分析页面元素,驱动视图等能力。

    3.3、获取 ID 的原理及实现

    Ke-FUT 获取元素 ID 是借助了 Flutter VM Service 的能力,对关键方法进行修改,以达到获取元素 ID 的目的。


    VMService 和 InspectorService 是 Flutter SDK 提供的服务,其主要作用是帮助开发人员检查页面结构,从而在视图布局出现问题时快速定位原因。利用其稳定的页面结构分析和元素定位的能力,我们可以轻松的获取到元素 ID。VMService 在 Flutter 初始化时开启,我们可以通过脚本启动 InspectorService 去连接 VMService,成功连接 VMService 之后,发送“show”消息使得 Flutter 页面进入 SelectedMode 模式,当前页面元素即可被选中,具体代码如下:


     InspectorService inspectorService;///测试注册 InspectServiceFuture<void> main(List<String> args) async {//url 即是 VMService 的服务地址final String url = args[0];
    final uri = normalizeVmServiceUri(url); FrameworkCore.init(url);//连接 VMServicefinal connected = await FrameworkCore.initVmService('', explicitUri: uri, errorReporter: (message, error) {});if (connected) {//创建 InspectorService 去监听inspectorService = awaitInspectorService.create(serviceManager.service).catchError((e) {
    }, test: (e) => e is FlutterInspectorLibraryNotFound);
    } else { safe_exit(1);}//发送消息,使得 Flutter 进入 SelectedMode 模式await inspectorService.invokeServiceMethodDaemonNoGroup('show', {'enabled':true});
    }
    复制代码


    在 SelectedMode 模式下,点击元素会触发 VMService 的 WidgetInspectorService 类中的_getSelectedWidget 函数,该函数将 Element 的调试信息返回给 InspectorService。我们使用 Beike_Aspectd(贝壳 Flutter Aop 开源框架)hook 此函数,在其返回的 Json 数据中增加了 ID 字段(具体实现原理在 3.4 小节详述),从而实现 Element ID 的监听,具体代码如下:


     @Execute("package:flutter/src/widgets/widget_inspector.dart", "_WidgetInspectorService", "-_getSelectedWidget")@pragma("vm:entry-point")
    Map<String, Object> _getSelectedWidget(PointCut pointcut) { print('call _getSelectedWidget');//在 selectedMode 下当前选中的 Elementfinal Element current = WidgetInspectorService.instance.selection?.currentElement;Map<String, Object> map = pointcut.proceed();
    if (current != null) {//将 Element 映射成对应的 IDmap['autoId'] = IdGenerator.idGenerator(current);
    }
    return map;}
    复制代码


    在 InspectorService 获取到 Element ID 之后,“页面元素分析器”只需要通过 WebSocket 和 InspectorService 建立连接就能将 Element ID 展示出来,从而实现了分析页面元素并获取对应元素 ID 的过程。

    3.4、ID_generator 的原理及实现

    ID_generator 的作用是将 Flutter 中的页面元素 Element 映射成 ID 。在上一步中获取的 ID 值就是 ID_generator 映射成的,具体的过程如图:



    1. 查找 Element 路径:通过当前 Element 调用 visitAncestorElement 函数递归访问父 Element 获取的 Element 集合。

    2. 生成 Widget List: 记录 element 路径中的分岔点及分叉方向和首尾元素。记录尾元素在最后分叉中在多个同样类型的位置。

    3. 生成 Element ID:这步比较简单,使用/对 Widget List 做分割即可。


    具体代码如下:

      static String idGenerator(Element element,      {bool isRemoveInspectorWidget = true}) {    if (element == null ||        !(element is RenderObjectElement) ||        !(element.widget is RenderObjectWidget)) {      return '';    }    listNode.addAll(debugGetElementCreatorChain(element));    for (int i = 0; i < listNode.length - 1; i++) {      if ((listNode[i] is MultiChildRenderObjectElement ||              listNode[i] is SliverMultiBoxAdaptorElement) &&          listNode[i].widget is RenderObjectWidget) {        _multiToId(listNode[i], i);      }    }    //逆序处理最后分叉中存在和待处理节点相同的情况    int count = 0;    for (int i = listNode.length - 2; i > 0; i--) {      if (element.runtimeType.toString() ==          listNode[i].runtimeType.toString()) {        count++;      }      if ((listNode[i] is MultiChildRenderObjectElement ||              listNode[i] is SliverMultiBoxAdaptorElement) &&          listNode[i].widget is RenderObjectWidget) {        //到达分叉点退出        break;      }    }    if (count > 0) {      listShortName.add(element.widget.toStringShort() + "[$count]");    } else {      listShortName.add(element.widget.toStringShort());    }    //删除widget_inspector的stack导致的ID    if (isRemoveInspectorWidget) {      listShortName.removeAt(0);    }    //删除IOS中多加的一个无用的Stack    if (Platform.isIOS && listShortName.length > 1) {      listShortName.removeAt(1);    }    String id = listShortName?.join('/') ?? '';    element2Id[element] = id;    listNode.clear();    listShortName.clear();    return id;  }
    static void _multiToId(Element element, int i) { int childIndex = 0; int finalIndex = 0; listNode[i].visitChildren((var element) { if (element == listNode[i + 1]) { finalIndex = childIndex; } childIndex++; }); if (childIndex != 1) { listShortName.add('${listNode[i].widget.toStringShort()}[$finalIndex]'); } }
    复制代码


    3.5、驱动视图的原理及实现

    在获取 Element ID 之后,我们的测试脚本可以使用 Element ID 通过 Ke-FUT 驱动对应的视图元素。而驱动视图的过程实际上就是 FUTClient 和 FUTService 通信的过程。

    3.5.1、FUTService

    FUTService 运行在 Flutter App 上,通过 WebSocket 连接 FUTService 可以对 Element 进行信息获取、断言和输入等操作,具体过程如下图:


    目前支持如下 API:

    • getPositionById:通过 Element_Id 获取元素相对于屏幕左上角的绝对坐标

    • getPositionByText:通过文案获取元素相对于屏幕左上角的绝对坐标

    • setText:通过 Element_Id 找到对应的 TextField 并设置输入值

    • assertText:检测 Element_Id 对应的元素是否展示对应的文案

    • clickById:通过 Element_Id 点击元素......

    3.5.2、FUTClient

    FUTClient 通过 WebSocket 调用 FUTService 的服务, 完成驱动视图并向上提供可供测试人员编写自动化脚本的 API。



    现有的自动化测试框架可以通过实现 FUTClient 完成 Flutter App 的 UI 自动化测试。目前我们使用 Python 语言实现了 FUTClient,并将其集成到贝壳内部的 KeMTC 平台中,接入过程简便,成本低。FUTClient 实现提供如下 API:

    • click_id:通过 Element_Id 获取元素进行点击

    • click_text:点击页面的唯一 Text

    • set_text:在指定 ID 处输入 Text

    • find_element_by_text:根据 Text 进行查找

    • find_element_by_id:根据 Element_Id 进行查找

    • swip_down_element:下滑到指定位置

    • assert_exited_text:断言 Text 是否存在

    • assert_exited_id:断言 Element_Id 是否存在

    • assert_equal:断言 Element_Id 的文案与实际文案是否一致......

    四、Ke-FUT 实践

    4.1、获取元素

    1. 在 Flutter 工程根目录下,执行 flutter attach,进入 App,复制链接。

    2. 利用 flutter_devtools 工程 ,执行 dart auto_test_main.dart “复制的连接”。

    3. 打开 bk_weditor,点击 Connect Flutter,点击 show,操作界面即可看到 flutterId(如图)


    4.2、编写用例

    以贝壳租赁的调价功能为例,用例脚本如下:


    def test_puzu_change_price(self, init): # 点击调价文案self.flutter_base.click_text("调价") # 根据 ID 点击self.flutter_base.click_id(elements.price_icon, "调价") # 在指定 ID 处 输入文案self.flutter_base.set_text(elements.price_icon, "322")# 点击保存文案self.flutter_base.click_text("保存")# 断言 Toastself.base.assert_toast("调价成功")
    复制代码

    效果如下:


    五、Ke-FUT 未来展望


    目前 Ke-FUT 项目已经在贝壳租赁业务上接入,从使用上看, Ke-FUT 已经支持常见的自动化操作并且运行良好,但是整体上仍然处于起步阶段,这也是我们对 Flutter UI 自动化测试的初步尝试。后续我们将在继续对方案进行迭代,提高稳定性,优化 FUTClient 和 FUTService 通信时的异常处理逻辑,提高用例执行时的稳定性。

    发布于: 刚刚阅读数: 3
    用户头像

    还未添加个人签名 2019.03.15 加入

    "贝壳大前端技术团队企业号"作为贝壳大前端官方账号,主要致力于FE,移动端的深度技术干货分享,欢迎大家关注我们的账号

    评论

    发布
    暂无评论
    贝壳Flutter UI 自动化测试原理与实践 - 已开源_flutter_贝壳大前端技术团队_InfoQ写作平台