作者:【友盟+】高级无线开发工程师 吴玉强、王飞
Tip:纯技术男撰稿,4000字,速读5分钟,消化2小时,开始脑暴吧~
为了确保 SDK 线上运行的稳定性,我们需要在开发后进行 SDK 测试,而为了提高测试效率,而且在拓展新项目的同时能兼顾已有项目的稳定性,在有限的资源内解放测试人员到更紧急的项目中来,就需要一个自动化工具来完成工作。
【友盟+】首创自动化工具,能够自动传不同参数、抓取输出数据,并自动验证数据准确性,输出结果,保障项目顺利稳定发布。
相对 App 的测试方案,市面上已经有非常多且成熟的 UI 级别的自动化测试框架,却鲜有针对 SDK 提供的自动化测试方案,原因是 SDK 属于为 App 提供服务的“插件”。一个 App 可接入一到多个 SDK 在内,而在项目中模块化是非常普遍的架构,所以 SDK 是针对细分功能提供服务的组件,有的提供数据服务、地图服务或节省开发成本的组件等等,这只能 SDK 开发者根据功能自行完成测试。
本篇说明的 SDK 测试方案是针对数据服务的 SDK 功能覆盖,皆包含 SDK 的 API、网络数据及缓存相关的逻辑测试,即非UI的纯数据逻辑的覆盖。
本篇是自动化测试基础上的延伸,相对安卓系统可以便利的通过 adb 指令控制如App安装、卸载、退出应用等“系统”级操作,iOS 在控制 App 层面上只能通过一些间接的手段完成上面几点需求,为了易于维护,在控制器中以有限状态机模式进行了构造,以便于后续增加更多的操作状态和测试用例。
整个测试流程就如下面描述的有向图,以 Pytest 驱动客户端执行任务,然后将客户端输出的请求数据进行截取处理,而后验证是否通过测试用例。
![]()
Android可以使用adb命令与app进行数据上的通信,如发送广播,启动Activity等。同时也可以使用shell命令对配置文件进行修改,再进行gradle编译,实现对app级别参数的修改,从而完成不同参数对app程序影响的验证。
![]()
iOS由于系统特性,无法如安卓系统灵活运用系统命令来操作 App 或 SDK,所以以一个 Socket 连接 Server 端进行通信。
另外在 iOS 系统上又可利用 Runtime 的特性,将传输的字符串转化为 API 调用,这样做的好处是将 Socket 模块和 Runtime 解析模块编入应用中就无需再次打包,只需 Python 端编好代码和测试 case,所有的功能调用都由两端约定的协议解析执行即可。
![]()
对于集成SDK的app,如果需要在App运行时,触发一个行为,可以通过广播来实现。可以根据action name完成对行为类型的分类,根据caseid完成对行为的区分。如下图所示:
![]()
根据上图示例如下:
os.system( "adbshell am broadcast -a com.umeng.auto.track --es param \"" + str(es) + "\" --ei caseId " + bytes(ei))
其中com.umeng.auto.track为广播的action name用以区分类别;
ei为一个int数,相当于图中的caseid;
es为参数内容,参数的协议可以自由定义,建议使用json类型,方便对不同类型的数据进行处理
这样,我们只需要在广播中以ei写一个switch语句,执行不同的行为,如果测试不同参数的效果,可以使用es传递内容。
如果使用广播,是没办法绑定生命周期,即如果SDK需要在Activity的onCreate()中进行一些类初始化操作,是没法进行控制的。所以对于这种情况就需要使用adb命令中的启动Activity命令,基本流程与广播类似,但是caseid的处理在onCreate()中:
![]()
根据上图示例如下:
os.system("adb shell am start -n " + self.pkgname + "/." + activity + " --es param \"" + str(es) + "\" --ei caseId " + bytes(ei))
其中pkgname为包名,activity为activity的名字es为需要传入的内容,ei为一个int数,即caseId。与广播方式类似,只是将switch放到了onCreate中,根据ei和es进行相应的操作。
以上说的两种方式几乎可以涵盖SDK测试的部分case,但是对于部分SDK,初始化需要在程序一启动的Application中执行,这时上面的两种方式显然满足不了需求。
这时有两套方案可以应对。如下图所示:
![]()
二次编译
如上图所示,左边的部分,我们可以通过修改Java文件完成对Appliction中内容的修改,如在Application中会有一些静态常量,使用python修改java文件中的常量,并重新运行:
defchangeConstant(self, source,des):
path =os.path.join(os.path.dirname(sys.path[0]), 'autotestAndroid')
gradle_path= os.path.join(path,'app','src','main','java','deep','autotest','utils','Constant.java')
print'-----gradle_path----',gradle_path
if os.path.exists(gradle_path):
build_file = open(gradle_path, 'r+')
lines =build_file.readlines()
for i in range(len(lines)):
line = lines[i]
if' '+source in line:
arr = line.split('=')
line = arr[0]+ '='+des+";\n"
lines[i] = line
build_file = open(gradle_path, 'w+')
build_file.writelines(lines)
p =buildprocess.CompileProcess(path)
p.start()
else:
print'nonono='+ gradle_path
使用这种方式的好处是:
• 可以直接修改Application中的常量,如AppKey等,不用管是否执行了Application的onCreate()
• 不用考虑外设情况
• 同样适配对AndroidManifest.xml的测试
缺点是:
• 需要绑定工程路径
• 文件内容类型较多,容易出错,代码不具备通用性,有一定的二次开发难度
• 需使用gradle重新编译,如工程较大,耗时较长
配置文件:
除了上述方法,也可以在Application中读取一个SD卡配置文件,根据配置文件的协议进行对应的操作。每次只需更改配置文件的内容,并通过adb push放入SD卡指定路径中,然后重启App即可。
这样做的好处是:
• 配置文件的协议可以随意定义,更灵活
• 配置文件可以使用json格式,修改更简单
• 只需推到SD卡,耗时更少
• 不需要绑定工程路径
缺点是:
• 只能在Application的onCreate之后进行,局限性较大。
• 依赖外设SD卡
• AndroidManifest的测试无法使用。
如「iOS端测试框架」所见,此时进行通信只有一个应用,这个应用就是我们用来测试 SDK 的 Demo,通过这个宿主我们可以触发 SDK 提供的任何 API,通过 iOS runtime 我们可以触发 SDK 的类方法、实例方法甚至是私有 API,但这写都只局限于一个应用“沙盒”内,如上面说到的安装、卸载及 App 退出和切到后台就无能为力了,所以我们引入了另一个 Demo(Watch Demo),通过两个 Demo 的协同操作满足“沙盒”之外的需求。
![]()
两个 App 互相唤醒和通信
![]()
如上面提到的,所有功能调用都基于约定的协议来执行的,协议的设计也是不断新增的测试需求改造的。
最初 Server 端与客户端以测试用例的 case id 来区分需要触发的事件,后来 case id 所代表的含义太多,而且客户端也是以运行时不断调用 Server 端发送指令的形式表现执行的具体功能,所以转为一条执行序列更加灵活及方便扩展。
一个测试用例可分为多条执行序列,执行序列内的协议包含了需要进行的方法调用或事件的处理。
以 Dplus 为例,如下数据包含了部分操作的执行序列:
"operations":{
"$umeng_cloudayc_op9": {
"arguments": {
"param": [
"$umeng_cloudayc_op*"
]
},
"type": "class",
"class":"DplusMobClick",
"method": "track:"
},
"$umeng_cloudayc_op5": {
"arguments": {
"param": []
},
"next":"$umeng_cloudayc_op9",
"type": "class",
"class":"DplusMobClick",
"method":"clearSuperProperties"
}
},
"type":"invoke",
"description":"401",
"first":"$umeng_cloudayc_op5"
由于是针对 SDK API 测试的协议,所以协议内的格式以调用的类名、方法名及参数为主,再加上部分细节参数加以说明,如 type 是 class 则调用类方法,是 instance 是示例方法。
需要注意的是,这个队列的结构是个字典,以标识前缀 $umeng_cloudayc_op 作为一个子事件的 key,value 则是其执行参数。而且可以看到在参数 param 的 value 里也有和子事件的 key 类似的值,这里的设计也是为了满足部分嵌套调用的需求。
举例来说,如此时需要通过一个接口验证之前缓存的数据是否发送正常,就要分三步:
第一存储数据;
第二将数据读出;
第三将第二步的结果作为参数传入最后调用的接口即可。
这样既能满足各种嵌套逻辑,又能实现远程构造客户端系统的实体对象作为参数进行接口调用。
回到上面的字典的结构,实际上在之前的协议格式使用的是数组作为执行序列的封装格式,不过在实际应用中无法满足灵活的要求,就如上面所说的组合的调用逻辑,有部分子事件是被动调用的,通过在其他事件内的参数检测来触发调用,如果是数组则无法控制这个执行序列的依赖关系。采用字典后,增加启动字段,在后续关联的子事件内,都会说明下一个执行的子事件,如果某个子事件是作为另外子事件的参数,则不会有 next 字段,因为它是被动触发的,不在执行队列之内。
在这个业务协议开发过程中,不断的根据测试需求进行改造、添加,从一开始的单一应用调用接口,到后面的多应用切换、前后台切换以及应用断开和重连,需要多套控制流程,在具体实现时,分散到了各个业务逻辑中,每增加一个控制都要兼容考虑是否会影响到其他模块,而且作为一个自动化测试“框架”,提前梳理好核心部分的流程会让之后更易于开发和维护,所以就引入了有限状态机的概念进行构造。
![]()
有限状态机(Finite-state machine)可用于模拟很多事物逻辑,顾名思义,它是一个有限的状态的处理逻辑,有下面几个特征:
状态数是有限的;
在当前时刻只有一种状态存在;
一个状态在满足某个条件后会切换到另一状态。
而有限状态机整体可以归纳为四个要素:现态、条件、动作以及次态。
-现态:指当前时刻所表现的状态;
-条件:又称为事件,即当前状态在满足这个条件后会触发一个动作,从而进行状态装换;
-动作:即在现态满足条件后需触发的一系列操作,动作完成后即状态进行迁移。动作也可以忽略,在某些情况下,现态满足条件后,也无需执行任何动作就切换到新的状态。
-次态:是相对现态而言,表示了条件满足后迁移的状态,次态也可以与现态相同。
根据业务逻辑的特性及复杂程度,合适的使用有限状态机,可以使得逻辑表达清晰、封装及维护都很直观和方便。当一个业务包含的状态越多,就越适合使用优先状态机进行封装处理。
有限状态机应用非常广泛,如电子电路、编译器及网络协议 TCP 协议状态机等;需要注意的是要区分“动作”和“状态”,如果将“动作”也视为“状态”会导致编写状态机时产生问题。
将业务逻辑应用到有限状态机,前提是需要熟悉对应的业务,并将其中的状态、动作和条件等抽离出来,然后再做进一步的划分和关联,构造出一个完整的有向图。
在自动化测试中,有如下几个关键词:
启动测试、监听、主App连接、守护App连接、接口调用、进入后台、进入前台、应用退出、崩溃、断开连接、重连等。
在日常开发中,如果遇到上面的”事件”,可能就顺其自然的开始写判断、写调用,可能不自觉的就写出了一个“有限状态机”,不过不会那么严格的区分什么是动作什么是状态,只要满足最后的结果就能达成目的。
但现在我们有意识的利用有限状态机进行划分,分离出状态和动作以及状态迁移的条件。看上面的关键字,好像都是一个个“动作”,仔细看“监听(中)”又可能是一个状态,但实际上我们还得需要结合业务的理解再抽象出一些状态,如“进入后台”,则是跳转到了守护 App,当前是控制守护 App 的状态;若是“进入前台”则守护 App 跳转到了“主App”,是控制主 App 的状态。
如下图就用刚才抽象出的关键词构造了一个简单的有限状态机:
![]()
按图说明:
-如架构图描述的,需要主App和守护App同时连接才可执行测试;
-在连接完成后,状态直接迁移到等待测试指令的状态,没有任何动作;
-有些组合状态可以合成一个状态,如运行守护App状态时可能主App断开连接,也可能保持连接,所以区分为两态分别管理;
-当自动化测试框架启动后,除了监听两个App同时连接,其他状态都是在已有App连接完成的前提下进行的,所以大部分时间是在执行测试case调用及App切换的。
![]()