自动化UI Test

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这个东西的作用放到下面讲。

难点

获取指定元素

app_ui.jpg

获取按钮

// 通过按钮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数据

mock.png

上图中,点击每个按钮后,都会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结束

ui-test-build.jpg

step3:点击build查看详情,通过下图可以看到这次build失败,原因是Detail的5个测例没有通过

ui-test-build-info.jpg

step4:在gitlab中显示的日志量比较少,是因为gitlab对日志量做了限制,所以在gitlab中的日志都是经过筛选的关键信息,具体错误原因通过查看服务器日志,下图日志说明了因为内存泄露导致了对应测例失败

ui-test-log.jpg

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 143,906评论 1 305
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 61,698评论 1 257
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 95,242评论 0 213
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 41,257评论 0 182
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 49,076评论 1 260
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 38,912评论 1 178
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 30,509评论 2 274
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 29,255评论 0 168
  • 想象着我的养父在大火中拼命挣扎,窒息,最后皮肤化为焦炭。我心中就已经是抑制不住地欢快,这就叫做以其人之道,还治其人...
    爱写小说的胖达阅读 29,145评论 6 235
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 32,613评论 0 214
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 29,376评论 2 216
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 30,733评论 1 231
  • 白月光回国,霸总把我这个替身辞退。还一脸阴沉的警告我。[不要出现在思思面前, 不然我有一百种方法让你生不如死。]我...
    爱写小说的胖达阅读 24,287评论 0 32
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 27,196评论 2 213
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 31,644评论 3 211
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 25,651评论 0 9
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,059评论 0 169
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 33,644评论 2 233
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 33,789评论 2 237

推荐阅读更多精彩内容