1 引言
Flutter是Google推出的跨平台UI框架,一套代码,多端复用,性能上可媲美原生。Flutter的诞生,引起业界的广泛关注,越来越多的互联网公司在APP中使用Flutter技术,像bat、tmd等知名国内互联网公司,研究和使用都比较深入。
贝壳作为国内Flutter技术应用的先驱,2018年底就在Link&A+ App上线了Flutter页面,如今已有三百个Flutter页面上线应用;目前,公司共有10+款App在使用Flutter框架,占活跃App总数的80%。Flutter技术的引入,研发原来需要为Android、iOS编写两套代码,现在仅需编写一套,移动端研发的效率得到了大大的提升。
但对于移动端测试同学而言,仍需对不同的平台做测试,工作量并没有同等比例减少。由于原有Native的自动化框架识别不到Flutter页面元素,Flutter页面也就不能通过自动化的方式进行回归测试;随着Flutter页面的增多,可通过自动化手段测试的占比随之降低,QA人工测试的成本越来越高。那么我们如何提高flutter页面的回归测试效率就迫在眉睫,于是我们开启了Flutter UI自动化方向的探索与研究。
2 方案调研
通过调研,目前业界自动化框架对Flutter的支持并不完善,不能满足我们的业务述求,基于此,我们从稳定性、业务接入成本、兼容性等方面进行调研、对比,最终自研了贝壳的Flutter UI自动化框架,并在租赁业务上应用。
Flutter-driver
Flutter-driver是Flutter官方提供的集成测试方案,有着丰富的API,但需要在业务代码中手动设置视图的ID,才能进行集成测试。对业务代码有侵入,另外,只支持Dart语言编写测试脚本,不支持Python,无法集成到KeMTC项目中,对于移动端测试同学增加太大的学习成本。
Appium-flutter-driver
Appium-flutter-driver是对Flutter-driver的封装优化。Appium是一种跨平台UI自动化测试框架,借助Appium的能力,可以用Python编写测试脚本;但是Appium-flutter-driver并没有解决Flutter-driver的需要手动设置视图ID的痛点。
Airtest-图像识别
Airtest是网易开源的UI自动化测试框架,通过图像识别的方式可以达到测试Flutter页面的目的,但是需要测试同学裁取很多图片并保存,由于与已有原生框架不同,集成到KeMTC项目中也是有成本的。
经过详细的调研对比后,以上三种方案都无法满足业务的需求,尤其是在稳定性、低成本接入平台等方面。于是我们开启了Flutter UI自动化Ke-FUT(Ke-FlutterUiTest的简称)的自研之路。
3 技术原理与实现
3.1 概述
Ke-FUT自动化的整体设计思路与原生相似,如图所示共有两个主要的步骤:
贝壳内部目前使用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;
///测试注册InspectService
Future<void> main(List<String> args) async {
//url即是VMService的服务地址
final String url = args[0];
final uri = normalizeVmServiceUri(url);
FrameworkCore.init(url);
//连接VMService
final connected = await FrameworkCore.initVmService('', explicitUri: uri,
errorReporter: (message, error) {});
if (connected) {
//创建InspectorService去监听
inspectorService = await InspectorService.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。我们使用AspectD(闲鱼开源的Dart 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下当前选中的Element
final Element current =
WidgetInspectorService.instance.selection?.currentElement;
Map<String, Object> map = pointcut.proceed();
if (current != null) {
//将Element映射成对应的ID
map['autoId'] = IdGenerator.idGenerator(current);
}
return map;
}
在InspectorService获取到Element ID之后,“页面元素分析器”只需要通过WebSocket和InspectorService建立连接就能将Element ID展示出来。从而实现了分析页面元素并获取对应元素ID的过程。
3.4 ID_generator的原理及实现
ID_generator是将Flutter中的页面元素Element映射成ID的工具。在上一步中获取的ID值是Element通过ID_generator映射而成。
Element List是通过当前Element调用visitAncestorElement函数递归访问父Element获取的Element集合。
在Element映射成WidgetName的过程中将一些不会影响创建路径的Element舍弃。
class IdGenerator {
///Element List 映射成 Widget Name的过程
static void _parse(List<Element> allList) {
for (int i = 0; i < allList.length; i++) {
var desc = allList[i].toStringShort();
switch (desc) {
case 'WidgetInspector':
//忽略widget_inspector的stack
i++;
break;
case 'Icon':
idList.add(desc);
break;
......
case 'Stack':
_mutilChildren2Id(allList.sublist(i), desc);
break;
......
default:
break;
}
element2Id.addAll({allList[i]: idList?.join('/') ?? ''});
}
}
///[Stack]、[Row]、[Column]
///在[MultiChildRenderObjectElement]需要通过位置加以描述
static void _mutilChildren2Id(List<Element> chanList, String type) {
......
MultiChildRenderObjectElement multiChildRenderObjectElement = chanList[0];
int i = 0;
int finalIndex = 0;
multiChildRenderObjectElement.visitChildren((var element) {
if (element == chanList[1]) {
///child element等于其中children中的一个,获取index
finalIndex = i;
}
i++;
});
idList.add('$type[$finalIndex]');
}
}
3.5 驱动视图的原理及实现
在获取Element ID之后,我们的测试脚本可以使用Element ID通过Ke-FUT驱动对应的视图元素。而驱动视图的过程实际上就是FUTClient和FUTService通信的过程。
3.5.1 FUTService
FUTService运行在Flutter App上,通过WebSocket连接FUTService可以获取对应Element的相关信息或对Element进行断言、输入等操作。
FUTService在Flutter App启动时开启,通过WebSocket监听4567端口。连接FUTService,就能获取对应的Element的信息及驱动Element。目前支持如下API:
getPositionById:通过Element_Id获取元素相对于屏幕左上角的绝对坐标
getPositionByText:通过文案获取元素相对于屏幕左上角的绝对坐标
setText:通过Element_Id找到对应的TextField并设置输入值
assertText:检测Element_Id对应的元素是否展示对应的文案
3.5.2 FUTClient
FUTClient通过WebSocket调用FUTService的服务,完成驱动视图并向上提供可供测试人员编写自动化脚本的API。
现有的自动化测试框架可以通过实现FUTClient完成Flutter App的UI自动化测试。目前我们使用Python语言实现了FUTClient,并将其集成到贝壳内部的KeMTC平台中,接入过程简便,成本低。FUTClient实现提供如下API。
3.5.3 Click_id的实例
FUTClient实现
def click_id(self, element_id, logtext):
"""
:param element_id: 元素id
:param logtext: 打印log文案
"""
if element_id:
position_map = flutter_client.get_position_by_id(element_id)
print("ID-{}的坐标值为:{}".format(logtext, position_map))
if isinstance(position_map, dict) and position_map.get("x") != 0:
x = position_map["x"]
y = position_map["y"]
self.d.click(x, y)
logger.info("点击元素:{}".format(logtext))
time.sleep(2)
elif position_map.get("x") == 0 and position_map.get("y") == 0:
logger.info("ID:{}返回坐标值为:{}, 元素不存在! ".format(element_id, position_map))
raise AssertionError("ID:{}返回坐标值为:{}, 元素不存在! ".format(element_id, position_map))
else:
raise AssertionError("元素异常:{}".format(logtext))
# 向server端发送请求接收ID相对位置[Map]
# [id]界面元素id
def get_position_by_id(self, id: str) -> dict:
id_en = id.encode("utf-8")
base64_id = base64.b64encode(id_en)
tem_map = {'method': 'getPositionById', 'id': base64_id}
json_map = simplejson.dumps(tem_map)
# 发送请求
self.client.send(json_map.encode('utf-8'))
while True:
# 接收数据
data = self.client.recv(1024) # 读取消息
if not data:
break
position_map = simplejson.loads(data.decode('utf-8'))
return position_map
return None
FUTService实现
///通过id获取其在界面中的具体位置[Map]
///[id]界面元素id
Map id2Position(String id) {
//获取当前页面的所有的Element
initElement2IdMap();
if (element2IdMap.containsKey(id)) {
//通过ID映射成对应Element
Element element = element2IdMap[id];
final RenderObject renderObject = element.renderObject;
if (renderObject is RenderBox) {
//偏移
Offset offset = renderObject.localToGlobal(Offset(0, 0));
//当前元素大小
Size size = renderObject.size;
double x = (offset.dx + size.width / 2) * window.devicePixelRatio;
double y = (offset.dy + size.height / 2) * window.devicePixelRatio;
return {'x': x, 'y': y};
}
}
return {'x': 0, 'y': 0};
}
4 Ke-FUT实践
4.1 获取元素
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("保存")
# 断言Toast
self.base.assert_toast("调价成功")
执行用例效果:
5 Ke-FUT未来展望
目前Ke-FUT项目已经在贝壳租赁业务上接入,并覆盖多种业务。而且Ke-FUT已集成到KeMTC的Android和iOS自动化项目中。从使用上看,Ke-FUT支持常见的自动化操作并且运行良好,但是整体上仍然处于起步阶段,这也是我们对Flutter UI自动化测试的初步尝试。
后续我们会支持元素ID、文字等模糊匹配,以适应更多测试场景,同时也欢迎移动端的测试同学感兴趣可以联系我们,多多试用,提出宝贵的建议~