iOS 测试 UI Testing in Xcode 7 revised

恒温 · 2015年09月21日 · 最后由 81—1 回复于 2016年02月04日 · 2112 次阅读
本帖已被设为精华帖!

XCode 7 正式版已经正式发布,大家可以通过 App Store 下载。XCodeGhost 事件最近闹得沸沸扬扬,第三方下载一定要慎重。

在 XCode 7 beta 的时候,我们就第一时间介绍过 UI Testing。

如今,我觉得有必要和大家再来看看这个工具,它可能依然不那么完美,可能都谈不上好用,但是这是一个明显的风向标,而且有很多小道消息也指出,XCUITesting 会慢慢取代 UIAUtomation。

本文将结合 joemasilotti 的两篇文章和 WWDC 的视频 ,有兴趣的同学一定请直接阅读和观看。

P.S 本文 UITesting 方面经过验证,其他的来自互联网,请自行实践。

Overview

事实上 UI Testing 并没有啥新意,它提供了类似于 UIAutomation 的功能,比如:

  • 查找定位元素
  • 和元素交互
  • 验证元素状态
  • 录制
  • 生成测试报告

这些在 UIAutomation 都有体现。为什么有了 UIAutomation 还推一个 UI Testing 的原因,个人的猜测是:

  1. UIAutomation 实际上是 Instruments 的一个 template,它本质不是用来做 UI 层面的验证,而是用来帮助一些性能方面的重复工作
  2. 在单元测试中进行 UI Testing,更能体现 BDD 或者 TDD 的精神
  3. 相比 UIAutomation 的 JAVASCRIPT,苹果可能更想用自己的新语言 Swift

XCTest 和 Accessibility

XCTest

UI Testing 是基于 XCTest 测试框架的。XCTest 作为 OCUnit 的替代者,目前是 iOS 单元测试框架不二之选,很多其他测试框架也基于 XCTest 封装。XCTest 有如下特点:

  • 测试用例需要继承 XCTestCase
  • 有类似 Junit 的 setup 或者 teardown 方法
  • 还算不错的 Assertions
  • 和 Xcode 深度集成
  • 可以使用 Xcode server 的持续集成。支持 Swift 和 Objective-C

那 UI Testing 在 XCTest 的基础上实际上是扩展了几个类,协议,如图:

所以本质上 UI Testing 还是 XCTest,所以写用例的时候,还是需要遵从 XCTest 的规则。

Accessibility

Accessibility 是 Apple 很早之前构建的一个框架,它能帮助一些行动不便的用户来更好地使用你的应用。它为你的 UI 提供了丰富的语义数据,这能让不同的 Accessibility 功能给行动不便的用户展现你的应用。有很多功能都是现成的,直接就能在你的应用中使用,但是你可以(也应该)使用 Accessibility 的 API 来改进 Accessibility 关于 UI 的数据。在很多场景下这都是必需的,比如对一些自定义的控件,Accessibility 就不清楚你的 API 要做什么。
—— 实战 iOS9【第二天】UI Testing

我曾经在讲解 Appium 上的 iOS 时候,提到了 Accessibility,因为 Appium 底层的 UIAutomation 是需要依靠 Accessibility 来定位元素。所以在 iOS 中做 UI 自动化测试,就仿佛盲人使用应用,如果没有可见性,什么都做不成。

同样,XCUITesting 也走了这条路。UI Testing 可以通过你的应用提供的 Accessibility 功能来与你的应用连接,这样就解决了设备大小不一的问题。如果你重新调整了 UI 中的某些元素,你也不用重写整套测试。当然实现 Accessibility 的本质不是为了使用 UI Testing,而是为了能帮助行动不便的用户更好地使用你的应用。

Demo

我是跟着 WWDC 视频走了一遍,所以希望大家也可以做一遍。我们需要准备些物料:

  1. 苹果电脑和最新系统
  2. XCode7
  3. https://developer.apple.com/library/mac/samplecode/Lister/ListerforwatchOSiOSandOSX.zip 用到的 lister 代码
  4. https://developer.apple.com/videos/wwdc/2015/?id=406 WWDC 视频 —— 使用 Safari 观看

这四个步骤最有问题的可能是第三步,虽然代码中的 README 提供了详细的方法,但是我还是想提示下同学 Bundle ID 一定要改成自己要的,而且最好把项目中所有的 Bundle ID 或者 BUNDLE_PREFIX 换成自己的,并且要统一。

Setup

当你在 Xcode 7 中创建新工程时,可以选择是否要包含 UI 测试。这会为你设置一个占位的 UI Test target,并且配置好了所需的内容。如图:

创建一个 Target,选择 UI Testing:

选择测试的 Target:

创建好之后,会生成 ListerUITests 目录,底下有 ListerUITests.swift 文件:


//
//  ListerUITests.swift
//  ListerUITests
//
//  Created by lihua zhang on 15/9/20.
//  Copyright © 2015年 Apple Inc. All rights reserved.
//

import XCTest

class ListerUITests: 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.
    }

}

我们先只需关心 testExample 这个方法。我们先用 UI Recording 来录制并生成代码:

从 gif 里面可以看出来,其实我们的录制过程并没有像 WWDC 视频中那么顺利,其中因为模拟器输入法的问题,cookie 变成了 can -_-!(打开模拟器的 Software keyboard 就可以了),我猜想苹果肯定演练了很多次。那我们生成的代码,大致是这样的:

let app = XCUIApplication()
let tablesQuery = app.tables
tablesQuery.staticTexts["Groceries"].tap()
tablesQuery.textFields["Add Item"].tap()
tablesQuery.childrenMatchingType(.Cell).elementBoundByIndex(0).childrenMatchingType(.TextField).element.typeText("cookie")
app.typeText("\r")
tablesQuery.childrenMatchingType(.Cell).elementBoundByIndex(1).childrenMatchingType(.TextField).element.tap()

let groceriesNavigationBarsQuery = app.navigationBars.matchingIdentifier("Groceries")
groceriesNavigationBarsQuery.buttons["Edit"].tap()
tablesQuery.childrenMatchingType(.Cell).elementBoundByIndex(7).buttons["Delete "].tap()
tablesQuery.buttons["Delete"].tap()
groceriesNavigationBarsQuery.buttons["Done"].tap()

其实和 UIAutomation 的录制一样惨不忍睹。那我们还是需要修改下:

  • 使用 Accessibility 来定位元素,使脚本更加可读
  • 抽取共有变量
func testExample() {

  let app = XCUIApplication()
  let tablesQuery = app.tables
  tablesQuery.staticTexts["Groceries"].tap()
  tablesQuery.textFields["Add Item"].tap()
  tablesQuery.textFields["Add Item"].typeText("Cookie")
  app.typeText("\r")

  let cells = tablesQuery.childrenMatchingType(.Cell)
  let cellQuery = cells.containingType(.TextField, identifier:"Cookie")

  let textField = cellQuery.childrenMatchingType(.TextField).element
  textField.tap()

  app.navigationBars.matchingIdentifier("Groceries").buttons["Edit"].tap()
  cellQuery.buttons["Delete "].tap()
  tablesQuery.buttons["Delete"].tap()
  app.navigationBars.matchingIdentifier("Groceries").buttons["Done"].tap()

}

小贴士:使用 Accessibility Inspector 来查看元素

某些时候,在录制时,当你点击了一个元素,你可能会注意到生成的代码看上去不太对。这通常是因为你正在交互的元素对 Accessibility 不可见。你可以使用 Xcode 的 Accessibility Inspector 来检查是不是这种情况。

当打开 Accessibility Inspector 之后 ,当你把鼠标悬停在模拟器中的元素上,就能看到鼠标指针下面元素的详细信息。这个时候,如果你按下 CMD+F7,就会锁定该元素,你可以点击每个属性进行查看。

总的来说,这个 Inspector 能帮助你定位一些元素,但是有时候会不稳定,需要重启模拟器。

从 Demo 的过程可以看出,录制的确可以帮助生成代码,对于生产力有一定的帮助,但是和大多数 UI 自动化框架一样,录制是不被推荐的,理由如下:

  1. TDD/BDD 时代测试先行,没有应用可以给你录制
  2. 录制的脚本不一定能工作,维护成本也不会低。对于一些场景比如 Webview,录制无能为力。
  3. 手动写测试用例能帮助你更好的 revisit 代码

UI Testing API

我们需要用到的 UI 方面的 API 基本是围绕这三个类进行的,断言之类依然使用 XCTest 的 API。

  • XCUIApplication
  • XCUIElement
  • XCUIElementQuery

XCUIApplication

通过 XCUIApplication 来启动应用,该类是被测应用的一个代理。我们可以这样维护被测应用:

class UITests: XCTestCase {
    let app = XCUIApplication()

    override func setUp() {
        super.setUp()

        continueAfterFailure = false
        app.launch()
    }
}

UI Testing 每次启动都会启动一个新的进程,重新启动应用。感觉和 Junit 是一样的。

XCUIElement

XCUIElement 封装了应用里的 button,textField,table,cell 等等信息。

Elements are objects encapsulating the information needed to dynamically locate a user interface element in an application. Elements are described in terms of queries. When an event API is called, the element will be resolved. If zero or multiple matches are found, an error will be raised.

我们可以通过元素的 type 和 Accessibility 或者两者结合起来定位元素,如果没有找到元素,或者多个元素返回都会报错,也就是无论用啥来定位,要进行后续操作的元素,必须有唯一性(比如,返回的是一个集合,你不能对集合直接操作,但是你可以对集合里面的单个元素操作)。

元素的层级和 UIAutomation 没有差别,层级逐阶而下。

我们可以用 debugDescription 来返回信息,类似 UIAutomation 的 logElementTree。

(lldb) po cellQuery.debugDescription
    t =   113.76s     Snapshot accessibility hierarchy for com.testerhome.Lister
    t =   114.03s     Find: Descendants matching type Table
    t =   114.03s     Find: Children matching type Cell
    t =   114.04s     Find: Elements containing elements matching type TextField with identifier 'Cookie'
"Find: Target Application 0x7fa7515615c0\n  Output: {\n    Application 0x7fa751621db0: {{0.0, 0.0}, {320.0, 568.0}}, label: \'Lister\'\n  }\n  ↪︎Find: Descendants matching type Table\n    Output: {\n      Table 0x7fa7515eb050: traits: 35192962023424, {{0.0, 0.0}, {320.0, 568.0}}\n    }\n    ↪︎Find: Children matching type Cell\n      Output: {\n        Cell 0x7fa7515eb830: traits: 8589934592, {{0.0, 64.0}, {320.0, 44.0}}\n        Cell 0x7fa7515ec720: traits: 8589934592, {{0.0, 108.0}, {320.0, 44.0}}\n        Cell 0x7fa7515ed610: traits: 8589934592, {{0.0, 152.0}, {320.0, 44.0}}\n        Cell 0x7fa7515ee520: traits: 8589934592, {{0.0, 196.0}, {320.0, 44.0}}\n        Cell 0x7fa7515ef410: traits: 8589934592, {{0.0, 240.0}, {320.0, 44.0}}\n      }\n      ↪︎Find: Elements containing elements matching type TextField with identifier \'Cookie\'\n        Output: {\n          Cell 0x7fa7515ec720: traits: 8589934592, {{0.0, 108.0}, {320.0, 44.0}}\n        }\n"

XCUIElement 提供了很多交互方法,不过根据平台还是有些区别,比如:

  • button.click() // OS X
  • button.tap() // iOS
  • textField.typeText(“Hello, World!”) // iOS & OS X

具体可以参考: http://masilotti.com/xctest-documentation/Classes/XCUIElement.html

XCUIElementQuery

Query 估计是打交道最多的,大多数时候我们通过层级关系或者特定信息来定位元素。

特定信息:

  • 元素类型:Button, Table,Cell 等
  • Accessibility:比如 label, title 等
  • predicates:部分匹配等

比如:

let tablesQuery = app.tables // 定位到 App 中 Table View
tablesQuery.staticTexts["Groceries"].tap() // 通过 Accessibility Value “Groceries” 来定位
tablesQuery.textFields["Add Item"].tap() //
app.navigationBars.matchingIdentifier("Groceries").buttons["Edit"].tap() // 通过 Accessibility Title “Edit” 来定位

  let predicate = NSPredicate(format: "label BEGINSWITH[cd] 'set your team details'")
  let label = app.staticTexts.elementMatchingPredicate(predicate)

层级关系:

  • Descendants
  • Children
  • Containment

比如:let cellQuery = cells.containingType(.TextField, identifier:"Cookie") 就是找到类型是 TextField,然后 Accessibility identifier 是 Cookie 的 cell。

注意: Descendants 和 Children 还是不同的。

let allButtons = app.descendantsMatchingType(.Button) //1
let allButtons = app.buttons //2
let childButtons = navBar.childrenMatchingType(.Button) //3

上面 1 和 2 两个表达式是相同的,但是 3 只表明下一层的 Button。

Query 可以返回匹配的元素,从一个 Query 里得到元素一般有几种方法:

  • 通过 Accessibility 属性 ,比如:table.staticTexts["Groceries"]
  • 通过 index, 比如:table.staticTexts.elementAtIndex(0)
  • 通过 element 属性,element 告诉程序,我很明确这个 Query 只有一个元素,返回吧。

当我们使用 type 做 Query 时,其实返回的应该是元素集合。我们前面说过,UI Testing 的操作必须针对单个元素,如果是集合的话,就需要再次定位,比如通过 elementBoundByIndex, 但是我感觉如果上下文能保证唯一性,就可以直接使用。

需要注意的是,Query 也可以返回 Query 对象,这个就像 JAVA 的 Build 模式一样,这样就串起了一个 Query chain,比如 let labelsInTable = app.tables.staticTexts

小贴士

层级关系和特定信息可以组合来帮助定位。

Query 只是一个 Query,只有真正需要的时候,才会执行这个 Query 然后返回结果。所以有可能你的 Query 是错误的,这个错误会在 Query 在被使用的时候才会报错。所以什么时候 Query 会被执行呢?

  • 向 Element 发起事件,比如 tap,typeText
  • 读取 Element 的属性
  • 获取 Query 的匹配结果(.count)
  • 获取 Query 所有的匹配(.allElementsBoundByAccessibilityElement)
  • UI 发生了变化,Query 会执行一遍。

UI Testing 的一些例子

以下内容来自 UI Testing Cheat Sheet and Examples

如何断言一个元素是否存在?

XCTAssert(cellQuery.element.exists)

如何等待一个元素出现?

private func waitForElementToAppear(element: XCUIElement, file: String = __FILE__, line: UInt = __LINE__) {
        let existsPredicate = NSPredicate(format: "exists == true")
        expectationForPredicate(existsPredicate, evaluatedWithObject: element, handler: nil)

        waitForExpectationsWithTimeout(5) { (error) -> Void in
            if (error != nil) {
                let message = "Failed to find \(element) after 5 seconds."
                self.recordFailureWithDescription(message, inFile: file, atLine: line, expected: true)
            }
        }
    }

如何点击?

UI Testing 提供了很多点击方法:

  • tap
  • doubleTap
  • twoFingerTap
  • tapWithNumberOfTaps:numberOfTouches:
  • pressForDuration:
  • pressForDuration:thenDragToElement:

如何输入?

对于输入框,需要进行一次点击,取得输入框的焦点,所以一般:

let textField = app.textFields["Team Name"]
textField.tap()
textField.typeText("Dig Newtons")

另外中文输入,估计就困难了。

如何处理 Alert?

app.alerts["You won!"].buttons["Awesome!"].tap()

如果系统级别的 alert,貌似目前会使应用 crash。Joe Masilotti ‏在 Xcode GM 版本的时候提了 bug,不过正式版还是没有修复。

如何拖动滚动条?

app.sliders.element.adjustToNormalizedSliderPosition(0.7)

0.7 表示如果 slider 有 10cm 长,那么滑到 7cm 的地方。

如何从 Picker view 选择?

app.pickerWheels.element.adjustToPickerWheelValue("6-2 Formation")

** 如何点击 webview 中的链接?

tap 可以在 UIWebView 和 WKWebView 中直接使用。

app.links["Volleyball"].tap()

暂时还不清楚,UI Testing 在 HTML5 应用中的能力,这个个人觉得取决于 Accessibility。

如何拖动元素?

let joeButton = app.buttons["Reorder Joe"]
let brianButton = app.buttons["Reorder Brian"]
joeButton.pressForDuration(0.5, thenDragToElement: brianButton)

总的来说:UI Testing 的东西并不比 UIAutomation 来的丰富,而且还有很多 bug。比如我之前发现,UI Testing 不能用在 UIDocumentMenuViewController 上(问了 Joe Masilotti,他说给 Apple 提个 bug )。

报告

Xcode 的报告一向都非常精美,可惜从来不能导出。报告包括:

  • Pass/fail 的情况
  • Failure 原因
  • 性能维度

何时才需要做 UI 测试?

又不得不提起那条底裤,我知道很多公司都没有底裤的。

按照科学的做法应该是: 单元测试-》集成测试-》验收测试

WWDC 的演讲里很明确的表示:

UI Testing 和单元测试的比较:

  • Complements unit testing
  • Unit testing more precisely pinpoints failures
  • UI testing covers broader aspects of functionality
  • Find the right blend of UI tests and unit tests for your project

UI Testing 可以用于:

  • Demo sequences
  • Common workflows
  • Custom views
  • Document creation, saving, and opening

个人不想评价太多,关于 UI 自动化,的确给测试圈带来了活力和逼格。但是如果真要说起 ROI,可能大规模 UI 自动化会是个噩梦。选择和权衡,是需要一个公司的开发负责人和测试负责人一起商量权衡的。

总结

本文写到这里的时候,我发现我又介绍了一个自动化工具。它并不像 WWDC 视频中介绍的那么美妙,相反有一点痛苦。从技术层面来看,没有任何新意,但是由于某种意义上,它是苹果的亲儿子,苹果对于它的发展应该会不遗余力。从 Appium 的作者 Jonathan 那边了解到,他们也会考虑切到 UI Testing 去。同时 Facebook 在 Selenium Conf 2015 上放出的 WebdriverAgent 用的是 UIAutomation 的私有 API,似乎也有在考虑 UI Testing。作为移动测试的一员,了解下它,还是非常有必要的。

Refer

感谢社区和网络上的牛人:

如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!
共收到 19 条回复 时间 点赞

好全面。。。自愧不如。。。

大赞...秒打赏...表示还没下正式版的玩。

其实玩了那么久,我觉得对于测试人员,面对多样化的 App 类型,以及大多数不配合的开发人员,还是 Appium 用着会舒服点。

UI Testing in Xcode7 目前更像是 iOS 开发者的玩具,Bug 多,录制也不顺畅,很多行为也无法进行录制,只能是自己去捕获定位写脚本,就这点就难倒了一大片测试人员。
我前段时间被 xcode7 beta 版也折磨得不轻,回头空了把正式版搞上再玩玩看。

#2 楼 @anikikun 现在的版本稳定多了,不过按照苹果的尿性,估计没有 2 年,不会太好用。

1000 块钱拿走,恒温

html 容器的东西咋定位呢?

#5 楼 @guo 我文中说了,取决于 accessibility

好长 大赞

http://stackoverflow.com/questions/32286770/uitests-xcode7-how-to-adjust-date-picker-with-multiply-pickers
这个问题大大是否有解?
masilotti 的主页中提到了 Picker with Multiple Wheels 可以用 UIPickerViewAccessibilityDelegate 制定各个 component 的名称,但是我试了对于 UIDatePicker 似乎不可行,也咩有找到 UIDatePicker 相应的 delegate,求救求救~

#8 楼 @mobilefeng 去 twitter 直接 @ masilotti 问他下。 这个我也不知道。

恒温 #10 · 2015年10月10日 Author

#8 楼 @mobilefeng 我 twitter 上问了 mas 大神了。期待回复。

看了一下,这个东东不能脱离源码搞,对么

很详细,了解了新动向,总结很有料。
因为帐号的原因,本地录制时模拟器运行报错,不过文章图文已足够。

恒温 #13 · 2015年12月02日 Author

#11 楼 @297358102 可以啊

楼主,你好,想问下对于被测 app 中出现跳转第三方 app 的(比如登录跳转到 QQ 这种),UITest 可以在第三方 app 进行点击操作吗?

恒温 #15 · 2015年12月03日 Author

#14 楼 @jinggzhao 不行。

想问下楼主,在支付宝的 react native 的框架下,UITesting 能用么;我尝试着在我们的产品中似乎抓取不到页面元素,不知道是否有解决方案~

恒温 #18 · 2015年12月14日 Author

#17 楼 @mobilefeng 没试过,不好意思。

正纠结要不要尝试,感谢分析。目标更加明朗了。

恒温 新版的 xcode 里面 automaition,没有了吗 中提及了此贴 03月12日 18:28
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册