闲话iOS探索测试实现

背景

App中经常会有crash,为了治理crash,我们尝试了各自手段,包括提交代码前的静态检测,单元测试等,但是还是会有crash被带到线上,而一旦上线,所带来的影响和修复成本都是很大的。借鉴我们组Android同事的想法,有目的有策略地对App自动化的探索(类似于智能的Monkey),探索到的页面越多,发现问题的可能性越大。将问题收集提前报出来,就可以避免带到线上。

本章我们对探索的策略不作细说,只聊聊探索在iOS的实现的大概流程。

方案确定

涉及UI测试,第一反应想到的是iOS自带的UI测试。但是iOS的UI测试是黑盒的,只能根据accessibilityIdentifier等来查找元素操作,能做的有限,显然满足不了探索的要求。而如果在App内实现探索,又处理不了系统的Alert弹窗等场景,所以为了两者兼得,我采用UI测试驱动+App内部探索的方案。

iOS的UI测试运行时会在被测试App之外生成另一个App,来驱动被测试App的行为。我们下面称我们自己的App为Host App,UI测试产生的App为Test App。

下图大致展示整个流程,我会一个个说明。

探索流程

脚本部分

当我们需要运行探索,任务的第一执行者是我们脚本。
脚本的职责有以下几个:

将探索仓库代码和主仓库集成

为了在Test App和HostApp间通信(Test App触发Host App开始和结束探索),我们需要将探索仓库中Test+Common模块和主工程的Test Target集成,将Host+Common和主Target集成。

其中Common是在Test App和Host App都会运行的公用部分,处理一些公用部分和通信部分。通信部分可以参考我的这篇iOS进程间通信

运行UI测试

通过xcodebuild命令编译并运行UI测试。
同时监测:

  1. 如果UI测试未到时间便退出,搜集本次运行信息,然后重新运行。
  2. 如果超时退出,则将之前搜集的所有运行信息整理,发出报告。

搜集报告

因为借助了iOS原生的UI测试,所有我们也希望借助系统现有的能力搜集报告。但是发现原生的UI测试有很多crash没有采集到,所以我通过自己在代码中捕获Exception和Signal,然后log出来,最后通过一次UI运行完之后,正则解析日志获取crash信息。

解析日志可以使用xcresulttool来完成,Test App进程的Host App进程的日志都能获取。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

"""
将UI测试生成的.xcresult目录导出成可分析的log
"""
def export_diagnostics_logs(self, test_result_path, log_dir):
# get logref id
tmp_contents_json = os.path.join(self.results_path, "tmp_contents.json")
self.util.run_command('xcrun xcresulttool get --path {} --format json > {}'.format(test_result_path, tmp_contents_json))
diagnostics_id = None
try:
json_dict = json.load(file(tmp_contents_json))
diagnostics_id = json_dict['actions']['_values'][0]['actionResult']['diagnosticsRef']['id']['_value']
except Exception as error:
LOG.warning("diagnostics id not found")
return False
self.util.run_command('xcrun xcresulttool export --path {} --output-path {} --id {} --type directory'.format(test_result_path, log_dir, diagnostics_id))
return True

Test App 部分

当脚本触发了UI测试,首先会运行的是Test App。
我们在Test App做下面几件事:

  1. 通过通信机制调用Host App的方法,通知Host App开始探索。
  2. 定时检测,如果发现Host App中出现了系统弹窗,则点掉弹窗。(这部分Host App做不到)
  3. 定时检测,如果发现Host App进入了后台,则重新唤醒Host App,因为这时可能点击导致跳出了App。
  4. 定时检测,如果Host App进入非运行状态,则结束本次UI测试,因为这时候Host App可能已经crash。
    总的来说,Test App主要做一个触发和处理弹窗等操作,没有探索的逻辑存在。

Host App

前面做了那么多,其实都是为了探索的流程打通,而到了Host App,核心的职责就是探索页面。

  1. 设置异常检测,当发生crash时将crash堆栈以一定的格式打印出来,分析报告时会以通用的格式解析。
  2. 获取页面所有元素,获取可操作的元素,通过策略选择选择一个元素。
  3. 通过使用UITouch实现模拟点击输入等操作,这部分可以参考KIFEarlGrey
  4. 不断重复2,3,深度遍历App页面,直至探索时间截止。

效果

截止目前为止,一小时探索有80个页面,其中还有很多问题没解决,需要持续完善。

总结

在实现整个流程的过程中,踩了不少坑,同时也对UI测试,进程通信,日志解析等方面加深了理解,踩坑使我进步。

QA

Q:为什么探索仓库和主仓库分开?
A:这是为了和主仓库解耦,探索的所有功能都不会带上线,这样做能保持主仓库的代码的干净,而且探索仓库可以随时迭代,不受主仓库制约。

Q:怎么将探索时间这个参数传入进入App。
A:通过设置工程文件的GCC_PREPROCESSOR_DEFINITIONS或者在xcodebuild时传入

1
xcodebuild test xxx GCC_PREPROCESSOR_DEFINITIONS="DEBUG=1 EXPLORE_TIME=1200"

然后在iOS代码中,使用以下方式获取,参考Stringification

1
2
3
4
5
#define EXPLORE_MACRO_NAME(f) #f
#define EXPLORE_MACRO_VALUE(f) EXPLORE_MACRO_NAME(f)

char * exploreTimeString = EXPLORE_MACRO_VALUE(EXPLORE_TIME);
NSInteger exploreTime = [[NSString stringWithFormat:@"%s", exploreTimeString] integerValue];

Q:Host App异常退出时,为什么不能在Test App多次运行Host App,而要通过脚本重新运行xcodebuild?
A:因为我们每次运行都需要采集Host App的运行日志,如果通过Test App重启Host App,那么等最后结束,我们只能拿到最后一次Host App运行的日志,会遗漏之前启动运行的页面/崩溃信息。

参考文献