App版本迭代速度非常快,每次发版本前都需要回归一些核心测试用例,人工回归枯燥且重复劳动。自动化UI Test虽然不能完全代替人工,但能帮助分担大部分测例。能让机器干的就不要让人来干了,从自动化、UI Test两个方面来讲下怎么实现自动化UI Test。
UI Test
有什么用
UI testing gives you the ability to find and interact with the UI of your app in order to validate the properties and state of the UI elements.
官方文档说的很简洁明了,UI Test能帮助我们去验证一些UI元素的属性和状态。
怎么做
UI tests rests upon two core technologies: the XCTest framework and Accessibility.
- XCTest provides the framework for UI testing capabilities, integrated with Xcode. Creating and using UI testing expands upon what you know about using XCTest and creating unit tests. You create a UI test target, and you create UI test classes and UI test methods as a part of your project. You use XCTest assertions to validate that expected outcomes are true. You also get continuous integration via Xcode Server and xcodebuild. XCTest is fully compatible with both Objective-C and Swift.
- Accessibility is the core technology that allows disabled users the same rich experience for iOS and OS X that other users receive. It includes a rich set of semantic data about the UI that users can use can use to guide them through using your app. Accessibility is integrated with both UIKit and AppKit and has APIs that allow you to fine-tune behaviors and what is exposed for external use. UI testing uses that data to perform its functions.
UI Test主要借助XCTest和Accessibility两个东西,其中XCTest框架帮我做了大部分事情,我们只要往testExample
这个方法里填空就能将整个流程跑起来,每个以test
开头的方法都会被当成一个测例。
class EBTest: XCTestCase {
override func setUp() {
super.setUp()
// Put setup code here. This method is called before the invocation of each test method in the class.
// In UI tests it is usually best to stop immediately when a failure occurs.
continueAfterFailure = false
// UI tests must launch the application that they test. Doing this in setup will make sure it happens for each test method.
XCUIApplication().launch()
// In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
}
override func tearDown() {
// Put teardown code here. This method is called after the invocation of each test method in the class.
super.tearDown()
}
func testExample() {
// Use recording to get started writing UI tests.
// Use XCTAssert and related functions to verify your tests produce the correct results.
}
}
Accessibility这个东西的作用放到下面讲。
难点
获取指定元素
获取按钮
// 通过按钮title获取
app.buttons["立即购买"]
// 通过图片资源名称获取
// btn navigationbar back可以通过Accessibility Inspector这个工具查看
app.buttons["btn navigationbar back"]
获取文本
// 直接获取
app.staticTexts["豪华午餐"]
// 通过NSPredicate匹配获取
let predicate = NSPredicate(format:"label BEGINSWITH %@", "距离双12")
app.staticTexts.element(matching:predicate)
上面两种方式只能获取到文本和按钮,但是无法获取UIImageView、UITableViewCell这类控件,那怎么获取到这类元素呢?一种方式是通过下标,但这种方式非常不稳定,很容易出现问题。
app.tables.element(boundBy: 0).cells.element(boundBy: 0)
另一种方式就是通过Accessibility,我们可以为一个元素设置accessibilityIdentifier
属性,这样就能获取到这个元素了。
// 生成button时设置accessibilityIdentifier
- (UIButton *)buildButtonWithTitle:(NSString *)title identifier:(NSString *)identifier handler:(void (^)())handler {
UIButton *button = [UIButton buttonWithType:UIButtonTypeSystem];
[button setTitle:title forState:UIControlStateNormal];
button.frame = [self buildFrame];
button.accessibilityIdentifier = identifier;
[self.view addSubview:button];
[button bk_addEventHandler:^(id sender) {
handler();
} forControlEvents:UIControlEventTouchUpInside];
return button;
}
// 通过设置的accessibilityIdentifier来获取这个按钮
app.buttons.matching(identifier: "EnterGoodsDetailNormal").element.tap()
但是这样这种方式对业务的侵入太严重了,在没有一个合适方案的情况下,可以考虑下面这种在壳工程中通过hook来设置accessibilityIdentifier。
- (void)hook {
static NSArray *hookArray = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
hookArray = @[ @"SomeClass", @"AnotherClass" ];
SEL originalSelector = @selector(accessibilityIdentifier);
SEL swizzledSelector = @selector(eb_accessibilityIdentifier);
[hookArray enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
[EBHookUtil eb_swizzleInstanceMethod:NSClassFromString(obj) swClass:[EBAppDelegate class] oriSel:originalSelector swSel:swizzledSelector];
}];
});
}
- (NSString *)eb_accessibilityIdentifier {
return NSStringFromClass([self class]);
}
小结
我们看到的App界面由文字和图片组成,而UI Test只识别文字,但是有一个特殊的地方,如果图片在按钮上,那么这个按钮所在区域也会被识别,默认identifier就是图片的名称。
借助Accessibility,可以突破上面描述的限制,你可以为某一个控件的accessibilityIdentifier属性赋值,这样,UI Test就能通过这个值获取到相应的控件。Accessibility本身是为有障碍人士设计的,当你的手摸到那个控件时,iPhone会通过语音告诉你这是什么东西。
可惜的是目前还没特别好的方法让Accessibility和业务本身解耦。
忽略后端因素
后端因素主要指网络和数据,接口返回时机不可控,依赖于网络和服务器,但Test Case需要等到接口返回,并且App布局完成后才能开始,因此,大部分测例开始前,可以加入类似下面这样的代码,来判断App是否渲染完成。
expectation(for: NSPredicate(format:"count > 0"), evaluatedWith: app.tables, handler: nil)
waitForExpectations(timeout: 3, handler: nil)
另一个便是数据了,接口返回的数据变化不可控,但Test Case却是固定的。解决这个问题我想到了下面两个方案:
- Mock数据
- Test Case开始前,通过调用后端接口,造出相同的数据
Mock数据
上图中,点击每个按钮后,都会hook获取数据的方法,将url替换成对应的mock数据url,这些工作都在壳工程中完成,不会对业务代码产生侵入性。
造出相同数据
// 在setUp中设置launchArguments
let app = XCUIApplication()
app.launchArguments = ["cart_testcase_start"]
// 在application:didFinishLaunchingWithOptions:中监测启动
NSArray *args = [NSProcessInfo processInfo].arguments;
for (int i = 1; i < args.count; ++i) {
// 检测到购物车相关测例即将开始,开始创造前置条件
if ([args[i] isEqualToString:@"cart_testcase_start"]) {
// 加入购物车
...
}
}
小结
上述方案已经能满足大部分Test Case的需求了,但局限性依旧存在,比如UI Test本身无法识别图片,这就意味着无法绕过图形验证码,另外就是短信验证码这类(Android貌似可以做到)。其他测例,理论上只要App内能完成的,Test Case就能覆盖到,但这就涉及到成本问题了,在壳工程内写肯定比在主工程中简单。
一些优化
- 类似内存泄露等通用检测,可以统一处理,不必每个测例都写一遍
- 测例开始后,每隔一段时间,XCTest框架会去扫描一遍App,动画的存在有时候会扰乱你获取界面元素,因此最好关闭动画
func customSetUp() -> XCUIApplication {
super.setUp()
continueAfterFailure = true
let app = XCUIApplication()
// 在AppDelegate启动方法中监测animationsEnable,然后设置下关闭动画
app.launchEnvironment = ["animationsEnable": "NO"]
memoryLeak()
return app
}
// 这里在工程中用了MLeakFinder,所以只要监测弹窗即可
func memoryLeak() {
addUIInterruptionMonitor(withDescription: "Memory Leak, Big Brother") { (alert) -> Bool in
if alert.staticTexts["Memory Leak"].exists ||
alert.staticTexts["Retain Cycle"].exists ||
alert.staticTexts["Object Deallocated"].exists {
// 拼接造成内存泄露的原因
var msg = ""
let title = alert.staticTexts.element(boundBy: 0)
if title.exists {
msg += "标题:" + title.label
}
let reason = alert.staticTexts.element(boundBy: 1)
if reason.exists {
msg += " 原因:" + reason.label
}
XCTFail("Memory Leak, Big Brother " + msg)
alert.buttons["OK"].tap()
return true
}
return false
}
}
自动化
在自动化方面,主要借助Gitlab CI,具体怎么配置Gitlab CI就不在这里展开了,参考官方文档
先来看看最后的流程:
step1:触发自动化流程,git commit -m "班车自动化UI Test" & git push
step2:触发流程后,会在gitlab相应项目中生成一个build,等待build结束
step3:点击build查看详情,通过下图可以看到这次build失败,原因是Detail的5个测例没有通过
step4:在gitlab中显示的日志量比较少,是因为gitlab对日志量做了限制,所以在gitlab中的日志都是经过筛选的关键信息,具体错误原因通过查看服务器日志,下图日志说明了因为内存泄露导致了对应测例失败
step5:build失败,邮件通知,交由相应组件负责人处理
再来看看.gitlab-ci.yml文件,这个文件是Gitlab CI的配置文件,CI要做什么都可以在这个文件里面描述
stages:
- test
before_script:
- cd Example
- pod update
ui_test:
stage: test
script:
# 这里先build一下,防止log日志过多,防止gitlab ci build log exceeded limit of 4194304 bytes.
- xcodebuild -workspace IntegrationTesting.xcworkspace -scheme IntegrationTesting-Example -destination 'platform=iOS Simulator,name=iPhone 7,OS=10.1' build >/dev/null 2>&1
- xcodebuild -workspace IntegrationTesting.xcworkspace -scheme IntegrationTesting-Example -destination 'platform=iOS Simulator,name=iPhone 7,OS=10.1' test | tee Log/`date +%Y%m%d_%H%M%S`.log | grep -A 5 'error:'
结束
这套方案应该算是比较初级的,自动化平台也建议大家用Jenkins,Gitlab CI有点弱,另外,大家有什么好点子可以多多交流,像Accessibility怎么和业务解耦之类的。
注
壳工程:主工程包含了所有需要的Pods,壳工程值能运行Pods的环境,可以包含一个或多个Pods