iOS单元测试之UI测试

一、UI测试简介

1.1、什么是UITesting

2015 年,Apple 发布了 UI 自动化测试框架 XCUITest 并集成在 Xcode7 中,而 iOS/macOS UI 自动化测试依赖两个核心技术:XCUITest 和 Accessibility。

XCUITest 和 Accessibility.png

XCUITest 是集成在 Xcode 中的测试框架,若想使用 UI 测试功能,可以在创建 iOS 项目时勾选 Include Tests 选项,从而使项目具备自动化测试的能力。而 Accessibility 技术,则是 Apple 官方为视障用户提供的一整套使用 iOS/macOS App 的解决方案。

Xcode 项目创建 UITests Target 并运行测试,其编译产物 Test App 本质上是一个 Deamon 守护进程,该进程有独立的应用程序生命周期,依靠 XCUIApplication 类型进行管理。UITests 的 Test App 进程在运行时会驱动 Host App(项目的主 Target 产物),并且利用元素审查的相关 API 驱动 Host App 模拟用户行为交互,从而进行 UI 自动化测试。

对于 Accessibility 技术,开发人员需要注意的是,XCUITest 框架默认并不能将所有视图元素审查到,只会审查到可以被 VoiceOver 功能读取文字的元素。比如,UIButton 和 UILabel,这些视图对于视障用户而言可以通过语音来获知其内容,而对于 UIImageView、 UIView 这种对于视障人士并不友好的 UIKit 视图元素默认是不会审查到的,所以编码时要另行配置 Accessibility 相关属性,以保证其支持 Accessibility 从而在 UI 自动化查询的元素层级中可见。

基于 XCUITest 框架 和 Accessibility 技术的自动化测试,有利于 App 进行数据一致性校验,但 UI 一致性校验能力较弱。比如,App 可以针对某些数据请求结果或者某个元素是否存在进行校验,而视觉展示效果却仍需要人工介入。

1.2、使用UITesting

Using UI Testing:

  • Complements unit testing(补充单元测试)
  • Unit testing more precisely pinpoints failures(单元测试更精确地确定了失败)
  • UI testing covers broader aspects of functionality(UI测试覆盖了函数边界方面)
  • Find the right blend of UI tests and unit tests for your project(找到好的方式融合UI测试和单元测试)

Candidates for UI Testing(使用UI测试的情况):

  • Demo sequences(一些列Demo)
  • Common workflows(相同的工作流程)
  • Custom views(相同的视图)
  • Document creation, saving, and opening(文档的创建、保存、打开)

1.3、UI Recording

通过 UI Recording ,可以将你操作手机的行为记录下来,并且转换成代码,可以帮助你快速生成 UI 测试代码。选中 UI 测试类,你能再下方看到一个小红点,点击小红点开始录制你的交互。

UIRecording.png

在你进行交互时,Xcode 会自动转化成代码,你可以借此创建新的测试代码,也可以以此拓展已经存在的测试代码。当然它也不是十分完美,并不是总能如你所愿,还需要你做一些处理,比如说自动生成的代码过于繁琐,你可以用一些更简洁的代码实现。即使这样,UI Recording 也是非常高效的方式。点击下载Demo:ZJHUnitTestDemo

UIRecording.gif

二、UI 测试相关的类

2.1、XCUITest 框架结构

XCUITest 框架结构图.png

XCUITest 测试框架 API 主要包含:元素查询(UI Element Queries)相关类型,如 XCUIElementQuery,UI 元素(UI Elements)相关类型,如 XCUIElement,以及测试 App 生命周期类型(Application Lifecycle)类型,如 XCUIApplication。

2.2、XCUIApplication

XCUIApplication 代表整个应用,可以用来启动、结束进程,或者传入一些启动参数,最常用的功能是利用 XCUIApplication 实例来查询 UI 上的元素。

// 返回 UI 测试 Target 设置中选中的 Target Application 的实例
- (instancetype)init;

// 根据 bundleId 返回一个应用程序实例
- (instancetype)initWithBundleIdentifier:(NSString *)bundleIdentifier;

// 启动应用程序
- (void)launch;

// 将应用程序唤醒至前台,在多程序联合测试下会用到 
- (void)activate;

// 结束一个正在运行的应用程序
- (void)terminate;

2.3、XCUIElement

XCUIElement 应用程序中的 UI 控件,控件类型多样,可能是Button,Cell,Window等等。该类实例有很多模拟交互的方法,如tap模拟用户点击事件,swipe模拟滑动事件,typeText:模拟用户输入内容。在 UI 测试中我们需要找到某个空间,可以通过他们的类型来缩小范围。另外还有一种方式通过 Accessibility identifer, label, title 等等方式来定位对应的控件。通过类型加 identifier 的方式来定位的控件元素的方式,可以满足大多数场景。

XCUIElement示例.png

也可以通过代码的方式添加Accessibility identifer。

// 通过代码的方式添加Accessibility identifer
for (int i = 0; i < 5; i++) {
        UISwitch *swt = [UISwitch new];
        CGFloat pointY = 50 * i + 100;
        swt.center = CGPointMake(self.view.frame.size.width/2, pointY);
        // 添加accessibility标记
        swt.accessibilityLabel = [NSString stringWithFormat:@"swt-%d", I];
        [self.view addSubview:swt];
}
  
  
 // UI测试获取控件
 XCUIApplication *app = [[XCUIApplication alloc] init];
 [app launch];
 XCUIElement *codeSwt1 = app.switches[@"swt-1"];
 [codeSwt1 tap];

2.4、XCUIElementQuery

XCUIElementQuery 是一个用来定位控件元素的类,一般是一组符合筛选条件的元素集合。如app.buttons即返回 XCUIElementQuery 实例,是包含了当前所有的button的集合,你可以再通过 XCUIElementQuery的方法做下一步的筛选。

XCUIElementQuery示例.png

使用 NSPredicate 为查询条件增加条件

// 查找所有的 collectionView 的 cell, collectionViews 和 cells 是 XCUIElementQuery 提供的方法
XCUIElementQuery *cells = app.collectionViews.cells;

// 使用 NSPredicate 为查询条件增加条件
XCUIElementQuery *cells = [app.collectionViews.cells matchingPredicate:[NSPredicate predicateWithFormat:@"identifier LIKE '?labelPrice?'"]];

三、UI测试示例

点击下载Demo:ZJHUnitTestDemo

3.1、使用UI Recording自动生成代码

新建一个 UI 测试 Target,使用 UI Recording 自动生成代码,或者也可以直接手写。

UI测试示例UIRecoding.gif

3.2、修改UI Recording 代码

UI Recording 的代码识别不出中文,需要手动改下;还会点击两次 tag,删除一个就好。

/// 修改 UI Recording 生成的代码
- (void)testLogin2 {
    // 拿到当前application程序
    XCUIApplication *app = [[XCUIApplication alloc] init];
    // 点击 "UITestDemo" 按钮
    [app.staticTexts[@"UITestDemo"] tap];
    
    // 点击账号textField
    [[[[[[[[[app.windows childrenMatchingType:XCUIElementTypeOther].element childrenMatchingType:XCUIElementTypeOther].element childrenMatchingType:XCUIElementTypeOther].element childrenMatchingType:XCUIElementTypeOther].element childrenMatchingType:XCUIElementTypeOther].element childrenMatchingType:XCUIElementTypeOther].element childrenMatchingType:XCUIElementTypeTextField] elementBoundByIndex:0] tap];
    
    // 点击键盘 shift,切换大小写
    [app.buttons[@"shift"] tap];
    
    // 点击键盘 a
    XCUIElement *aKey = app.keys[@"a"];
    [aKey tap];
//    [aKey tap]; // 多余tag 需要注释掉
    
    // 点击密码textField
    [[[[[[[[[[[app childrenMatchingType:XCUIElementTypeWindow] elementBoundByIndex:0] childrenMatchingType:XCUIElementTypeOther].element childrenMatchingType:XCUIElementTypeOther].element childrenMatchingType:XCUIElementTypeOther].element childrenMatchingType:XCUIElementTypeOther].element childrenMatchingType:XCUIElementTypeOther].element childrenMatchingType:XCUIElementTypeOther].element childrenMatchingType:XCUIElementTypeTextField] elementBoundByIndex:1] tap];
    
    // 切换数字键盘
    XCUIElement *moreKey = app.keys[@"more"];
    [moreKey tap];
    
    // 输入1、2、3、4
    XCUIElement *key = app.keys[@"1"];
    [key tap];
    XCUIElement *key2 = app.keys[@"2"];
    [key2 tap];
    XCUIElement *key3 = app.keys[@"3"];
    [key3 tap];
    XCUIElement *key4 = app.keys[@"4"];
    [key4 tap];
    
    // 点击登录按钮
    XCUIElement *button = app.buttons[@"登录"];
    [button.staticTexts[@"登录"] tap];
    
    // 点击键盘删除按钮
    XCUIElement *deleteKey = app.keys[@"delete"];
    [deleteKey tap];
    
    // 点击登录按钮
    [button tap];
    
    // 点击返回按钮
    [app.navigationBars[@"Record List"].buttons[@"登录"] tap];
}

3.3、精简代码

UI Recording 生成的代码还不够简练,可以再次对其修改。也可以直接编写,不使用UI Recording生成的

/// 精简代码
- (void)testLogin3 {
   // 拿到当前application程序
   XCUIApplication *app = [[XCUIApplication alloc] init];
   
   // 获取 “UITestDemo” 按钮,并点击,跳转到登录页面
   [app.staticTexts[@"UITestDemo"] tap];
   
   // 拿到当前app下的textfeild的搜索器
   XCUIElementQuery *tfQuery = app.textFields;
   // 账号textField
   XCUIElement *accountTF = [tfQuery elementBoundByIndex:0];
   // 密码textField
   XCUIElement *passwordTF = [tfQuery elementBoundByIndex:1];
   
   // 拿到当前app下的button的搜索器
   XCUIElementQuery *btnQuery = app.buttons;
   // 获取登录按钮
   XCUIElement *loginBtn = btnQuery[@"登录"];

   // 模拟UI操作
   [accountTF tap]; // 点击账号textField
   [accountTF typeText:@"a"]; // 输入字母a
   [passwordTF tap];// 点击密码textField
   [passwordTF typeText:@"1234"]; // 输入字母123456
   [loginBtn tap]; // 点击登录,提示密码错误
   
   // 获取键盘的删除按钮
   XCUIElement *deleteBtn = app.keys[@"delete"];
   [deleteBtn tap]; // 点击一次删除按钮
   
   // 再次点击登录按钮
   [loginBtn tap]; // 点击登录,成功跳转

   // 获取 “Record List” navigationBar,
   XCUIElement *navBarElement = app.navigationBars[@"Record List"];
   // 获取返回按钮
   XCUIElement *backBtn = navBarElement.buttons[@"登录"];
   // 点击返回按钮
   [backBtn tap];
}

注意:如果某些UI测试失败,请禁用“连接硬件键盘”选项。

为此,请在模拟器应用程序中选择“ I / O”菜单选项,然后转到Keyboard并取消选中Connect hardware keyboard。 连接硬件键盘后,UI测试似乎无法访问模拟器中的text field

模拟器弹出键盘.png

四、UI测试拓展 Tips

4.1、等待预期

可以用expectationForPredicate:evaluatedWithObject:handler:方法监听对象属性,当满足NSPredicate条件时,expectation相当于自动fullfill`。如果一直不满足条件,会一直等待直至超时,除此之外还可以用通知和 KVO 的方式实现。

例如,列表中,新增一个cell数据后,可以监听监听app.cellscount属性,判断cell的个数是否按预期增加,代码如下:

 // 暂存当前 cell 数量
 NSInteger cellsCount = app.cells.count;
 // 设置一个预期 判断 app.cells 的 count 属性会等于 cellsCount+1, 等待直至失败,如果符合则不再等待
 NSPredicate *predicate = [NSPredicate predicateWithFormat:@"count == %d",cellsCount+1];
 [self expectationForPredicate:predicate evaluatedWithObject:app.cells handler:nil];
    
 // 执行添加操作,或者网络请求等异步操作
 [self addCellData];
 
 // 等待实现预期,这里等到10s
 [self waitForExpectationsWithTimeout:10 handler:nil];

4.2、多应用联合测试

多应用联合测试时,依赖XCUIApplication类的以下 2 个方法:

  • initWithBundleIdentifier:
  • activate

前者可以根据 BundleId 获取其他 App 的实例,让我们可以启动其他 App。后者可以让 App 从后台切换至前台,在多应用间切换。简单实现代码如下:

- (void)testExample {
    // 返回 UI 测试 Target 设置中选中的 Target Application 的实例
    XCUIApplication *app = [[XCUIApplication alloc] init];
    
    // 使用 BundleId 获得另外一个 App 实例:需要先创建另个测试app
    XCUIApplication *anotherApp = [[XCUIApplication alloc] initWithBundleIdentifier:@"zjh.ZJHUnitTestDemo2"];

    // 先启动我们的主 App
    [app launch];
    
    // 做一系列测试1
    [app.staticTexts[@"UITestDemo"] tap];
    [app.navigationBars[@"登录"].buttons[@"Home"] tap];

    sleep(2);
        
    // 启动另一个 App
    [anotherApp activate];
    
    sleep(2);
    
    // 回到我们的主 App (在 App 未启动的情况下调 activate 会让 App 启动)
    [app activate];
    
    // 做一系列测试2
    [app.staticTexts[@"UITestDemo"] tap];
    [app.navigationBars[@"登录"].buttons[@"Home"] tap];
}

4.3、截屏

在 UI 测试中有 2 种类型支持通过代码截屏,分别是XCUIElementXCUIScreen

// 获取一个截屏对象
XCUIScreenshot *screenshot = [app screenshot];

// 实例化一个附件对象 并传入截屏对象
XCTAttachment *attachment = [XCTAttachment attachmentWithScreenshot:screenshot];

// 附件的存储策略 如果选择 XCTAttachmentLifetimeDeleteOnSuccess 则测试成功的情况会被删除
attachment.lifetime = XCTAttachmentLifetimeKeepAlways;

// 设置一个名字 方便区分
attachment.name = @"MyScreenshot";

[self addAttachment:attachment];

在测试结束后,可以在 Report 导航栏中查看截图:

查看截图.png

除此之外 Xcode 提供了自动截图的功能,可以帮助我们在每一个交互操作之后自动截图。此功能会产生大量截图,需要谨慎使用,一般情况最好勾选Delete when each test succeeds,需要在 Edit Scheme -> Test -> Options 中开启。

4.4、被测试 app 如何判断正在进行 UI Test

在启动 app 时增加一个启动参数,在 app 中读取。

// 测试代码
XCUIApplication *app = [[XCUIApplication alloc] init];
app.launchEnvironment = @{@"isUITest" : @YES};
[app launch];

// app 代码
+ (BOOL)isUITesting {
    NSDictionary *environment = [[NSProcessInfo processInfo] environment];
    return [environment[@"isUITest"] boolValue];
}

五、Accessibility Inspector简介

5.1、使用 Accessibility Inspector

Accessibility Inspector 辅助功能检查器,通过辅助功能检查器,您可以识别应用程序中无法访问的部分。它提供了有关如何访问它们的反馈,并模拟画外音,以帮助您识别画外音用户的体验。观看在辅助功能检查器中完全调试的应用程序的实时演示,并了解如何利用这个强大的工具使您的应用程序更适合每个人。

前文中提到 Apple 对于视图元素会默认审查能够通过 VoiceOver 播放文字的视图元素,而对于 UIImageView、UIView 这种默认不支持 Accessibility 功能的需要配置相关特性,而开发人员在开发过程中可以通过 Accessibility Inspector 查看不同进程的 Accessibility 元素层级,该应用可以审查 iOS 和 macOS 的元素。

选择 Xcode 的图标菜单并选择 Open Developer Tool 选项,点击 Accessibility Inspector 即可开始使用。

打开Accessibility Inspector.png

当我们没有设置 isAccessibilityElement 属性时,在 Accessibility 元素层级结构中就无法看到 UIImageView 和 UIView 元素,只能看到 “t我是Button” 和“我是Label”。而当我们将 UIView 的 isAccessibilityElement 属性设置为 YES 时, UIView 元素才能在元素层级中可见,UIImageView默认还是看不见。设置代码如下:

    NSArray *nameArr = @[@"我是Button", @"我是Label", @"我是View", @"我是Image"];
        
        if (i == 0) { // 按钮
            UIButton *btn = [[UIButton alloc] initWithFrame:btnF];
            [btn setTitle:nameArr[i] forState:UIControlStateNormal];
            temView = btn;
        } else if (i == 1) { // label
            UILabel *lab = [[UILabel alloc] initWithFrame:btnF];
            lab.text = nameArr[i];
            lab.textAlignment = NSTextAlignmentCenter;
            temView = lab;
        } else if (i == 2) { // view
            UIView *view = [[UIView alloc] initWithFrame:btnF];
            view.isAccessibilityElement = YES; // 将 UIView  的 isAccessibilityElement 属性设置为 YES 
            view.accessibilityIdentifier = nameArr[I];
            temView = view;
        } else if (i == 3) { // 图片
            UIImageView *imgView = [[UIImageView alloc] initWithFrame:btnF];
            imgView.image = [UIImage imageNamed:@"avatar"];
            imgView.accessibilityIdentifier = nameArr[i];
            temView = imgView;
        }
Accessibility Inspector使用.png

5.2、Accessibility 相关属性

@property (nullable, nonatomic, copy) NSString *accessibilityLabel;

accessibilityLabel 属性可以解决绝大部分的 Accessibility 问题,当光标将焦点放在设置该属性的元素师时,它的内容可由 VoiceOver 读取的人类可读的字符串。但如果不是需要被视障用户获知的视图元素,仅用于自动化测试,就可以不用设置该属性。

@property(nullable, nonatomic, copy) NSString *accessibilityIdentifier API_AVAILABLE(ios(5.0));

accessibilityIdentifier 属性不会被 VoiceOver 诵读,而是面向开发人员的字符串,可在不希望用户操作 accessibilityLabel 的情况下使用。

@property (nonatomic) BOOL isAccessibilityElement;

如果 isAccessibilityElement 未设置为 true,那么这个视图将不会在 Accessibility 视图层次结构中可见。

  • The default value for this property is false unless the element is a standard UIKit control, in which case, the value is true. —— Apple Documentation

另外,根据 Apple 官方中的介绍 UIControl 的子类的 isAccessibilityElement 属性都默认设置为 true。

5.3、编写测试用例

- (void)testExample {
    XCUIApplication *app = [[XCUIApplication alloc] init];
    [app.staticTexts[@"Accessibility Demo"] tap];
    
    XCUIElement *button = app.buttons[@"我是Button"];
    XCTAssertTrue(button.exists);
    XCUIElement *label = app.staticTexts[@"我是Label"];
    XCTAssertTrue(label.exists);
    XCUIElement *view = app.otherElements[@"我是View"];
    XCTAssertTrue(view.exists);
    XCUIElement *imgview = app.images[@"我是Image"];
    XCTAssertTrue(imgview.exists);
}

六、三方框架KIF简介

6.1、KIF简介

KIF 的全称是Keep it functional。它是一个建立在XCTest的UI测试框架,通过accessibility来定位具体的控件,再利用私有的API来操作UI。由于是建立在XCTest上的,所以你可以完美的借助XCode的测试相关工具。

6.2、pod引入框架

pod引入框架.png
  • 必须将Target设置为Unit Test,根据GitHub官方说明。不要设置成UI Test 项目了,我这就设错了,调了大半天才找到原因

  • 查看GitHub的ReadMe,使用Cocoapod进行安装,命令如下(在Debug模式下才生效)

  • KIF一定要放到测试项目下面

    target 'ZJHKIFUnitTestDemoTests' do
        pod 'KIF', :configurations => ['Debug']
      end
    

6.3、简单使用

KIF使用示例.png

更多接口介绍,可参考:KIF API中文翻译



参考链接:
iOS 单元测试和 UI 测试快速入门:https://juejin.cn/post/6844903744170098695
iOS UI 自动化测试原理以及在 Trip.com 的应用实践:https://www.51cto.com/article/686176.html
iOS UI Testing 指北:https://nixwang.com/2018/09/30/ios-ui-testing/

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

推荐阅读更多精彩内容