译文: iOS Unit Testing and UI Testing Tutorial

2019-02-14 17:45:13 浏览数 (1)

原文: iOS Unit Testing and UI Testing Tutorial,作者:Audrey Tam。更新于2017年3月13日。以下为正文:

本教程讲解如何往iOS apps中添加「单元测试/unit tests」、「UI测试/UI tests」,以及如何检查「代码的覆盖率/code coverage」。

Version:

Swift 3,iOS 10,Xcode 8

很多开发者觉得写测试没什么卵用,但是,如果没有「测试」,你原本牛逼闪闪的app,很容易变成一坨翔,所以,「测试」是必不可少的。如果你正在看这篇教程,那么恭喜您,你是一个有追求的人,一个脱离了低级趣味的人(该处译者自由发挥),您起码知道应该要写测试了,只是暂时还不知道怎么写而已。

或者你有正在开发的app,但还没写测试,你希望可以在扩展app的时候,对修改的部分进行测试。也可能你已经写了一部分测试,但不确定写得对不对。又或者你随时想对正在开发的app进行测试。

这篇教程,演示了如何利用Xcode的test navigator来测试app的「模型/model」和「异步方法/asynchronous methods」;如何利用stubs、mocks模拟和library、system进行交互;如何测试UI、性能;以及如何使用「代码覆盖工具/code coverage tool」。学习过程中,会接触到一些装逼术语,学习完本宝典后,你就是大神了!

Testing, Testing...

What to Test?/测什么?

开始写测试之前,有一件非常重要的事情:究竟要测什么?如果目的是扩展(修改)现有的app,那么首先要为即将要修改的部分写测试。

测试通常包括:

  • 核心功能/Core functionality:模型类和方法,以及他们和控制器的交互
  • 常见的UI工作流
  • 边界条件/Boundary conditions
  • Bug修复

First Things FIRST: Best Practices for Testing

FIRST是「Fast,Independent,Repeatable,Self-validating,Timely」的缩写,描述了一套有效、简明的单元测试标准:

  • Fast/高效:你写的测试可以很快完成——只有这样大家才不介意去跑测试代码。
  • Independent/Isolated:测试不应该彼此依赖、拆解
  • Repeatable:每次跑测试,得到的结果都应该一致。当然,外部数据和并发问题(concurrency issues)可能偶尔导致测试结果会不一样。
  • Self-validating:测试应完全自动化;测试结果应该是「pass」或者「fail」,而不需要程序员从一堆日志(log)文件中推测测试结果。
  • Timely:理想情况下,在写生产代码前,就应该写好测试。(译者:这里说的是理想情况下,所以有很多情况也是先写功能,再写测试的?)

遵循FIRST原则,可以保证写出来的测试简单、实用,不至于成为你的累赘。

Getting Started

下载、解压、打开starter projects 中的BullsEye 和 HalfTune项目。

BullsEye是iOS Apprentice中的一个简单的app(一个游戏app——译者);项目已经把游戏逻辑解耦到BullsEyeGame这个类了,并且加了另外一种游戏模式。

(运行BullsEye——译者)在右下角,可以看到,有一个Segmented Control控件,可以让使用者选择游戏风格:

  • Slide模式,将slider滑动到尽可能靠近预先设定的目标值,
  • Type模式,猜测slider滑动条目前的值,将结果输入顶部TextField中。

用户选择的游戏模式,app也会保存作为默认值(重启app,默认游戏模式是使用者上次选择的模式——译者)

HalfTunes是NSURLsession Tutorial中的一个app,更新到Swift 3了(事实上已经更新到Swift 4了——译者)。在这个app,调用了iTunes 的API来查询歌曲,还可以下载、播放歌曲的片段。

坐稳了,开车!

Unit Testing in Xcode

创建一个Unit Test Target

Xcode Test Navigator提供了使用测试的简便方式;下面会利用它来创建test target,并且把测试跑起来。

打开BullsEye,按快捷键Command-6,打开test navigator。

点击左下角的 按钮,选择菜单中的New Unit Test Target...

image

使用默认的名字:BullsEyeTests。看到test bundle时,点击打开。如果BullsEyeTest没有出现,单击切换到其他navigators,再返回test navagator。

image

可以看到模版文件导入了XCTest,定义了一个XCTestCase的子类:BullsEyeTests,还有setup()tearDown()和一些example test 方法。

有三种跑测试的方法:

  1. 点击菜单Product Test,或者快捷键Command-U。这种方式会将所有的test类都跑一边。
  2. 点击test navigator中的小箭头按钮。
  3. 点击gutter中的菱形按钮。(就是显示代码行数旁边的按钮——译者)

image

通过点击test navigator或者gutter中的按钮,可以跑单独一个测试方法。

试一下用上面不同的方法跑一下测试,直观感受一下。因为现在这些测试什么都没做,所以很快就跑完了。

所有测试跑完之后,菱形按钮变成绿色,并呈现勾选状态。点击testPerformanceExample()方法下面的灰色菱形按钮,打开Performance Result:

image

现在不需要testPerformanceExample()这个方法,暂时删掉。

用XCTAssert测试Models

首先,下面要用XCTAssert 来测试BullsEye model的核心功能:BullEyeGame对象计算的分数是否正确?

来到BullsEyeTests.swift,在import语句下,添加如下代码:

代码语言:javascript复制
@testable import BullsEye

这句代码给了unit test 权限访问BullsEye中的类、方法。

BullsEyesTests类的顶部,添加以下属性:

代码语言:javascript复制
var gameUnderTest: BullsEyeGame!

setup()方法内,super.setUp() 之后,创建一个新的BullsEyeGame对象:

代码语言:javascript复制
gameUnderTest = BullsEyeGame()
gameUnderTest.startNewGame()

上面创建了一个class 层级的SUT(System Under Test)对象,所以在这个测试类里的所有测试都可以访问SUT对象里的属性和方法。

也可以调用startNewGame方法——这个方法创建targetValue。很多测试会用到targetValue——用来测试游戏是否正确计算「分数」。

tearDown()方法内要释放(release) SUT对象。

代码语言:javascript复制
gameUnderTest = nil

注意:setup()创建、在tearDown()释放 SUT对象,是一个好习惯。可以确保每个测试都是在干净的环境中进行。更多资料,可以查看Jon Reid 关于此主题的帖子。

现在开始写第一个测试!

用以下代码替换整个testExample()

代码语言:javascript复制
// XCTAssert to test model
func testScoreIsComputed() {
  // 1. given
  let guess = gameUnderTest.targetValue   5
  
  // 2. when
  _ = gameUnderTest.check(guess: guess)
  
  // 3. then
  XCTAssertEqual(gameUnderTest.scoreRound, 95, "Score computed from guess is wrong")
}

测试方法的方法名一般都以test开头,后面跟的是要测什么东西。

把测试分解成givenwhenthen三部分,是一个好习惯:

  1. given部分,设置所需要的值:上面的例子,创建了一个guess值,可以设定与targetValue的差异。
  2. when部分,执行代码进行测试:调用gameUnderTest.check(_:)方法。
  3. then部分,assert(断言)所期望的结果(在这个例子,gameUnderTest.scoreRound是100 - 5),如果测试结果失败,打印一条消息。

点击菱形按钮跑测试。app就会跑起来,菱形按钮也会变成绿色勾选状态。

Note:如果要看XCTestAssertions的完整列表,在代码中按Command键同时点击XCTAssertEqual 打开XCTestAssertions.h,或者到这里看: Apple’s Assertions Listed by Category.

image

Note:Given-When-Then 结构起源于Behavior Driven Development(BDD/行为驱动开发),而Given-When-Then 这个名字更通俗易懂。也可以用Arrange-Act-Assert,或者Assembl-Activate-Assert。

Debugging a Test

我们在BullsEyeGame故意内置了一个bug,现在就来找一下这个bug。

testScoreIsComputed重命名为testScoreIsComputedWhenGuessGTTarget ,然后再复制-粘贴一个,创建testScoreIsComputedWhenGuessLTTarget

testScoreIsComputedWhenGuessLTTarget()这个测试中,given部分的targetValue减去5。其他保持不变。

代码语言:javascript复制
func testScoreIsComputedWhenGuessLTTarget() {
  // 1. given
  let guess = gameUnderTest.targetValue - 5
  
  // 2. when
  _ = gameUnderTest.check(guess: guess)
  
  // 3. then
  XCTAssertEqual(gameUnderTest.scoreRound, 95, "Score computed from guess is wrong")
}

guesstargetValue之间相差还是5,所以score仍然是95。

打开breakpoint navigator,添加一个Test Failure Breakpoint;当测试方法发出失败的assertion(断言)时,测试就会停在这里。

image

把测试跑起来:测试失败,应该会停在XCTAssertEqual这行。

打开debug console,检查gameUnderTestguess的值:

image

guess的值是targetValue - 5 ,但是scoreRound是105,并不是期待中的95!

为了进一步找到问题点,使用平常的debug方式:在when语句中设置断点,在BullsEyeGame.swift中的check(_:)方法内,创建difference的地方也设置一个断点。然后再跑一次,逐步执行,来到let difference语句,查看difference的值:

image

问题出在difference的值是负数,所以score的值变成100 - (-5);可以对diffenecne取绝对值来修复这个问题。在check(_:)方法中,取消注释正确的那行,并删除有问题的那行。

删掉两个断点,再重新跑测试,这次没有问题了。

用XCTestExpectation测试异步操作

上面已经学会如何测试models,如何在测试失败时debug,现在继续学习使用XCTestExpectation来测试网络操作(network operations)。

打开HalfTunes项目:这个app用URLSession来查询iTunes API 并下载歌曲片段。假设你要改成用AlamoFire来进行网络操作。要确认这个改写过程是否有纰漏,应该写测试来验证这些修改的代码,在修改前、修改后都要跑测试。

URLSession方法是异步的:马上返回,但要等一段时间才真正完成。要测试异步方法,可以用XCTestExpectation,它可以让测试等到异步操作完成。

异步测试一般比较慢,所要要和unit tests 分开。

也是在 号菜单中选择New Unit Test Target…并命名为HalfTunesSlowTests。在import下面导入HalfTunes app:

代码语言:javascript复制
@testable import HalfTunes

这个类中的测试会用默认session向苹果服务器发送请求,声明sessionUnderTest变量,并在setup()中创建该对象、在tearDown():中释放:

代码语言:javascript复制
var sessionUnderTest: URLSession!

override func setUp() {
  super.setUp()
  sessionUnderTest = URLSession(configuration: URLSessionConfiguration.default)
}

override func tearDown() {
  sessionUnderTest = nil
  super.tearDown()
}

用下面异步测试的代码替换原来的testExample()

代码语言:javascript复制
// Asynchronous test: success fast, failure slow
func testValidCallToiTunesGetsHTTPStatusCode200() {
  // given
  let url = URL(string: "https://itunes.apple.com/search?media=music&entity=song&term=abba")
  // 1
  let promise = expectation(description: "Status code: 200")
  
  // when
  let dataTask = sessionUnderTest.dataTask(with: url!) { data, response, error in
    // then
    if let error = error {
      XCTFail("Error: (error.localizedDescription)")
      return
    } else if let statusCode = (response as? HTTPURLResponse)?.statusCode {
      if statusCode == 200 {
        // 2
        promise.fulfill()
      } else {
        XCTFail("Status code: (statusCode)")
      }
    }
  }
  dataTask.resume()
  // 3
  waitForExpectations(timeout: 5, handler: nil)
}

这个测试检查发送有效查询到iTunes,返回200状态码的情况。大多数测试代码和在app中实际写的一样,下面这些是额外添加的:

  1. expectation(_:)返回一个XCTestExpectation对象,并赋值保存为promise。通常也可以用expectationfuture来命名。description参数描述了你期望发生的结果。
  2. 为了匹配description,在异步方法回调成功时,调用promise.fulfill()
  3. waitForExpectations(_:handler:)确保测试一直运行,直到达成所有期望的结果(expectations),或者 timeout 超时结束,以先触发者为准。

把测试跑起来,如果是连着网络的,app在模拟器加载后,测试大概几秒就能完成。

Fail Faster

测试失败大家都不愿意看到,不过不可能百分百保证每次测试都能通过。下面介绍快速识别测试是否失败,省下来的时间,就可以刷抖音刷朋友圈了:]

为了模拟测试失败,删除URL中「itunes」中的「s」:

代码语言:javascript复制
let url = URL(string: "https://itune.apple.com/search?media=music&entity=song&term=abba")

再跑一次:如我们所愿,测试失败了,但是它跑完timeout的时间(5秒——译者)才提示失败!这是因为我们之前写的代码,要等到「请求」成功后,才会调用promise.fulfill()。不过这次的「请求」是失败的,所以只能等timeout超时后才能结束测试。

通过修改expectation,可以让「测试失败」的结果更早呈现:原来需要等到「请求」成功,现在只需等到异步方法回调即可(无论回调成功或错误——译者)。换言之,一旦app收到服务器的响应(无论是OK 或者error),就可以提示开发者了。在这之后,再进一步确认「请求」是否成功。

为了了解其中的工作原理,再创建一个测试。首先,把之前URL删除的「s」补回来,然后在类中添加如下测试:

代码语言:javascript复制
// Asynchronous test: faster fail
func testCallToiTunesCompletes() {
  // given
  let url = URL(string: "https://itune.apple.com/search?media=music&entity=song&term=abba")
  // 1
  let promise = expectation(description: "Completion handler invoked")
  var statusCode: Int?
  var responseError: Error?
  
  // when
  let dataTask = sessionUnderTest.dataTask(with: url!) { data, response, error in
    statusCode = (response as? HTTPURLResponse)?.statusCode
    responseError = error
    // 2
    promise.fulfill()
  }
  dataTask.resume()
  // 3
  waitForExpectations(timeout: 5, handler: nil)
  
  // then
  XCTAssertNil(responseError)
  XCTAssertEqual(statusCode, 200)
}

这里的关键,就是在异步方法回调后,马上执行promise.fulfill(),这只耗费很少时间。如果「请求」失败,then中的assertions(断言)会抛出失败。

再跑一次测试,现在就会马上显示测试失败了,这是因为「请求(request)」失败了,而不是因为timeout超时导致失败。

修复url,重新跑测试,确认现在测试能通过。

Faking Objects and Interactions

异步测试给了你信心——你的代码会生成正确的输入(input)给异步的API(比如AlamoFire——译者)。你可能还需要测试当接收到URLSession的输入时,你的代码是否可以正确工作,又或者当UserDefaults、CloudKit更新时,是否还能正常工作。

很多apps和系统(system)或者库(library)的对象交互(interact)——这些对象是不在你掌控之下的——要测试这些交互会很慢而且不可重复(unrepeatable),这违反了FIRST的两条原则(第一、第三条——译者)。为了避免此类问题,可以伪造交互获得输入(input)——通过从stubs,或者更新mock对象。(就是喂假数据——译者)

当你的代码依赖到系统或库对象,就可以用这种伪造的方式——创建一个假对象喂入数据来进行这一部分的测试。Dependency Injection by Jon Reid 中描述了几种可行的方法。

image

来自Stub的假数据

接下来的测试,会检查updateSearchResults(_:)方法是否正确地解析了下载到的数据,检查searchResults.count是否正确。SUT对象是view controller,这里会利用stubs和一些预先准备好的数据伪造session。

菜单中选择New Unit Test Target…,命名为HalfTunesFakeTests。在import语句下面,导入HalfTunes app:

代码语言:javascript复制
@testable import HalfTunes

声明SUT对象,并在setup()中创建、在tearDown()中释放:

代码语言:javascript复制
var controllerUnderTest: SearchViewController!

override func setUp() {
  super.setUp()
  controllerUnderTest = UIStoryboard(name: "Main", 
      bundle: nil).instantiateInitialViewController() as! SearchViewController!
}

override func tearDown() {
  controllerUnderTest = nil
  super.tearDown()
}

Note:这里的SUT是view controller,因为HalfTunes项目有一个很大的问题——现在所有事情都在SearchViewController.swift完成。在Moving the networking code into separate modults 中会解决这个问题,并且让测试工作得到简化。(应该是说要将网络请求这部分功能解耦出来——译者)

接下来,伪造的session需要一些简单的JSON数据,以喂给测试。因为只需要几组数据,所以在URL字符串后面拼接上&limit=3来进行限制:

代码语言:javascript复制
https://itunes.apple.com/search?media=music&entity=song&term=abba&limit=3

将这个URL复制粘贴到浏览器中。会下载到一个名为1.txt或类似的文件。打开确认这是一个JSON文件,然后重命名为abbaData.json,最后把它拖到HalfTunesFakeTests组中。

Supporting Files中已经有一个叫做DHURLSessionMock.swift的文件。这个文件定义了一个简单的协议DHURLSession,里面有方法(stubs)可以创建一个基于URL或者URLRequest的data task。也定义了遵守该协议的URLSessionMock类,可以让你基于选择的数据、response和error创建一个mock 类型的 URLSesison对象。

接下来设置假资料和response,并在setup()中创建伪造的session对象(在创建STU下面):

代码语言:javascript复制
let testBundle = Bundle(for: type(of: self))
let path = testBundle.path(forResource: "abbaData", ofType: "json")
let data = try? Data(contentsOf: URL(fileURLWithPath: path!), options: .alwaysMapped)

let url = URL(string: "https://itunes.apple.com/search?media=music&entity=song&term=abba")
let urlResponse = HTTPURLResponse(url: url!, statusCode: 200, httpVersion: nil, headerFields: nil)

let sessionMock = URLSessionMock(data: data, response: urlResponse, error: nil)

setup()方法的最后,把伪造的session当作SUT的属性注入(inject)到app中:

代码语言:javascript复制
controllerUnderTest.defaultSession = sessionMock

Note:在测试中会直接使用伪造的session,这里只是展示如何注入,后续就可以调用SUT方法,使用view controller的defalutSession属性。

现在就可以写测试确认updateSearchResult(_:)方法是否能正确解析假数据。用以下代码替换testExample()

代码语言:javascript复制
// Fake URLSession with DHURLSession protocol and stubs
func test_UpdateSearchResults_ParsesData() {
  // given
  let promise = expectation(description: "Status code: 200")
  
  // when
  XCTAssertEqual(controllerUnderTest?.searchResults.count, 0, "searchResults should be empty before the data task runs")
  let url = URL(string: "https://itunes.apple.com/search?media=music&entity=song&term=abba")
  let dataTask = controllerUnderTest?.defaultSession.dataTask(with: url!) {
    data, response, error in
    // if HTTP request is successful, call updateSearchResults(_:) which parses the response data into Tracks
    if let error = error {
      print(error.localizedDescription)
    } else if let httpResponse = response as? HTTPURLResponse {
      if httpResponse.statusCode == 200 {
        promise.fulfill()
        self.controllerUnderTest?.updateSearchResults(data)
      }
    }
  }
  dataTask?.resume()
  waitForExpectations(timeout: 5, handler: nil)
  
  // then
  XCTAssertEqual(controllerUnderTest?.searchResults.count, 3, "Didn't parse 3 items from fake response")
}

这里还是必须写成异步测试,因为stub是假设为异步方法的。

when的断言是「searchResults should be empty before the data task runs」——这是很明显的,因为我们在setup()中创建的是一个全新的SUT。

假数据包含了三个Track对象的JSON数据,所以then的断言是「the view controller’s searchResults array contains three items」。

测试跑起来。应该很快就跑完了,因为这不是真正和服务器交互。

Fake Update to Mock Object

上面的测试,利用stub从假对象中提供假资料(input)。接下来,会用mock对象测试你的代码是否能正确更新UserDefaults

重新打开BullsEye项目。这个app有两种游戏模式:使用者移动slider接近目标值,或者通过slider的位置猜测目标值。右下角的segmented control用于切换游戏模式,并且更新gameStyle这个使用者默认选项。

下一个测试就是检查app是否正确更新了gameStyle这个默认值。

在test navigator,点击New Unit Test Target…,命名为BullsEyeMockTests,在import后添加如下代码:

代码语言:javascript复制
@testable import BullsEye

class MockUserDefaults: UserDefaults {
  var gameStyleChanged = 0
  override func set(_ value: Int, forKey defaultName: String) {
    if defaultName == "gameStyle" {
      gameStyleChanged  = 1
    }
  }
}

MockUserDefaults重写了set(_:forKey)方法,记录了gameStyleChanged更改次数。在类似的测试中也会设置一个Bool变量,不过这里用一个Int记录次数更具弹性——比如,测试可以精确地记录方法的每次调用。

BullsEyeMockTests中声明SUT和mock:

代码语言:javascript复制
var controllerUnderTest: ViewController!
var mockUserDefaults: MockUserDefaults!

setup()方法中,创建一个SUT和mock对象,然后注入mock对象——作为SUT的属性:

代码语言:javascript复制
controllerUnderTest = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController() as! ViewController!
mockUserDefaults = MockUserDefaults(suiteName: "testing")!
controllerUnderTest.defaults = mockUserDefaults

tearDown()中释放SUT和mock对象:

代码语言:javascript复制
controllerUnderTest = nil
mockUserDefaults = nil

用如下代码替换testExample()

代码语言:javascript复制
// Mock to test interaction with UserDefaults
func testGameStyleCanBeChanged() {
  // given
  let segmentedControl = UISegmentedControl()
  
  // when
  XCTAssertEqual(mockUserDefaults.gameStyleChanged, 0, "gameStyleChanged should be 0 before sendActions")
  segmentedControl.addTarget(controllerUnderTest, 
      action: #selector(ViewController.chooseGameStyle(_:)), for: .valueChanged)
  segmentedControl.sendActions(for: .valueChanged)
  
  // then
  XCTAssertEqual(mockUserDefaults.gameStyleChanged, 1, "gameStyle user default wasn't changed")
}

When的assertion(断言)是「gameStyleChanged should be 0 before sendActions」,因此在「点击」segmented control之前,gameStyleChanged是0。所以如果then assertion(断言)还是true的话,表示 set(_:forKey:) 方法只被调用了一次。

测试跑起来;正常来说是没问题的。

UI Testing in Xcode

Xcode 7开始有了UI 测试,可以创建一个「UI 测试」记录和UI的交互。「UI测试」的工作原理——查询app的UI对象、合成事件,然后将他们发送到这些对象。这个API允许开发者仔细检查UI对象的属性、状态,以便将他们与预期状态进行比较。

BullsEye项目的test navigator,添加一个新的UI Test Target。检查确认Target to be Tested选择的是BullsEye,然后用默认的名称BullsEyeUITests

BullsEyeUITests类的顶部添加一个属性:

代码语言:javascript复制
var app: XCUIApplication!

setup()中,用如下代码替换XCUIApplication().launch()

代码语言:javascript复制
app = XCUIApplication()
app.launch()

testExample()的名字改为testGameStyleSwitch()

testGameStyleSwitch()中另起一行,然后点击deitor窗口底部的红色Record按钮:

image

当app出现在模拟器后,点击游戏模式切换开关的Slider segment,还有顶部的label。然后点击Xcode Record按钮停止记录。

现在testGameStyleSwitch()中就会有如下三行代码:(根据游戏模式的不同,Text文本内容有所不同——译者)

代码语言:javascript复制
let app = XCUIApplication()
app.buttons["Slide"].tap()
app.staticTexts["Get as close as you can to: "].tap()

如果还出现了其他代码,把其他代码删掉。

第一行复制了在setup()中创建的属性,后面不需要点击任何东西了,所以删除第一行,还有第二行、第三行后面的.tap()

打开["Slide"]右边的一个小下拉菜单,选择segmentedControls.buttons["Slide"]

现在的代码变成这样:

代码语言:javascript复制
app.segmentedControls.buttons["Slide"]
app.staticTexts["Get as close as you can to: "]

修改一下,创建一个given,如下:

代码语言:javascript复制
// given
let slideButton = app.segmentedControls.buttons["Slide"]
let typeButton = app.segmentedControls.buttons["Type"]
let slideLabel = app.staticTexts["Get as close as you can to: "]
let typeLabel = app.staticTexts["Guess where the slider is: "]

现在两个按钮和两个顶部labels都有名称了,继续添加如下代码:

代码语言:javascript复制
// then
if slideButton.isSelected {
  XCTAssertTrue(slideLabel.exists)
  XCTAssertFalse(typeLabel.exists)
  
  typeButton.tap()
  XCTAssertTrue(typeLabel.exists)
  XCTAssertFalse(slideLabel.exists)
} else if typeButton.isSelected {
  XCTAssertTrue(typeLabel.exists)
  XCTAssertFalse(slideLabel.exists)
  
  slideButton.tap()
  XCTAssertTrue(slideLabel.exists)
  XCTAssertFalse(typeLabel.exists)
}

这个测试,检查点击、选择了不同按钮之后,label是否正确存在(显示)。把测试跑起来,应该可以看到所有断言(assertions)都成功了。

性能测试

苹果官方文档是这样定义的:性能测试,会将需要测试的代码块运行十次,收集平均执行时间和运行的标准偏差(standard deviation for the runs)。有了这个平均值,就可以以此值为基准,进行性能评估。

写性能测试很简单:只需要把需要测试的代码放到measure()方法的闭包(closure)中。

重新打开HalfTunes项目,在HalfTunesFakeTeststestPerformanceExample()用下面代码代替:

代码语言:javascript复制
// Performance 
func test_StartDownload_Performance() {
  let track = Track(name: "Waterloo", artist: "ABBA", 
      previewUrl: "http://a821.phobos.apple.com/us/r30/Music/d7/ba/ce/mzm.vsyjlsff.aac.p.m4a")
  measure {
    self.controllerUnderTest?.startDownload(track)
  }
}

跑起来,然后点击出现在measure()闭包尾部的图标,查看统计信息。

image

点击Set BaseLine,再次执行performance test——结果可能比baseline更好或者更差。Edit按钮可以将最新的值重设为baseline。

每台设备的configuration都保存了baseline相关信息,因此可以在不同的设备上执行相同的测试,不同设备的处理器速度、内存等各不相同,它们会维护不同的baseline。

App的每次修改,都有可能影响到性能,可以再次运行性能测试,和baseline比较一下。

Code Coverage

Code coverage工具,可以帮忙检查哪些代码已经跑过测试,哪些代码还没测试。

Note:当code coverage打开时,是否应该跑性能测试?苹果官方文档时这样说的:Code coverage 数据收集会导致性能的损耗……以线性方式影响代码的执行,因此code coverage启用时,对性能影响还是可以接受的( performance results remain comparable from test run to test run when it is enabled)。但是,当你需要精确评估性能的时候,应该考虑是否在测试中启用code coveage。

要启用code coverage,编辑scheme的Test,并勾选Code Coverage复选框(Xcode 9 是在Options中勾选——译者):

image

把所有测试都跑起来(Command-U),然后打开reports navigator(Command-8)。选择By Time,选中列表中最上面一个,再选择Coverage这个tab(Xcode 9 点击左边的{} Coverage):

image

点击SearchViewController.swift左边的三角形,查看方法列表:

image

将鼠标悬停在updateSearchResults(_:)方法旁的蓝色Coverage bar上,可以看到覆盖率是71.88%。

点击方法右边的箭头按钮,打开这个方法的源文件,找到这个方法。鼠标悬停在右侧边栏的coverage annotations,这部分代码就会高亮成绿色或者红色。

image

coverage annotations还显示了每部分代码在一次测试中的执行次数;没有被执行的部分高亮为红色。如你所愿,for循环跑了3次,而错误的分支,没有被执行。如果要提高这个方法的覆盖率,可以复制一份abbaData.json,修改其中的内容,就可以导致不同的错误——比如,将把key "results"改为"result",跑测试的时候,就会执行print("Results key not found in dictionary")这个分支。

100% Coverage?

应该追求100%的代码覆盖率吗?搜索一下「100% unit test coverage」,网上有一大波争论和相反意见,以及关于「100% unit test coverage」定义本身的争论。反对派说最后的10-15%是不值得去测试的。赞成派认为正因为这部分难于测试,所以最后的10-15%是非常重要的。搜索一下「hard to unit test bad design」,可以找到有说服力的论据—— untestable code is a sign of deeper design problems——难以(不能)测试的代码,往往意味着这个设计本身是有问题的。如果再深究的话,将会延伸到Test Driven Development(测试驱动开发)。

Where to Go From Here

到此为止,我们可以利用很多有用的工具为项目进行测试了。希望看完这个关于iOS Unit Testing 和 UI Testing 的教程后,你可以胸有成竹地去测试所有东西。

这里是已经完成的项目。

下面是一些补充教程:

  • 现在你可以为项目写测试了,那么下一步当然就是自动化了:Continuous Integration(持续集成) 和 Continuous Delivery(持续交付)。可以从Apple Xcode Server和xcodebulid的Automating the Test Process开始了解,还有Wikipedia’s continuous delivery article这篇文章,借鉴了ThoughtWorks一文。
  • TDD in Swift Playgrounds 使用了XCTestObservationCenter来在Playgrounds中跑XCTestCase单元测试。这样可以在Playgrounds上开发和测试,然后再转到app中。
  • CMD U Conference 中的文章Watch Apps: How Do We Test Them? ,演示了如何用 PivotalCoreKit来测试watchOS app。
  • 如果已经写好了app,但还没有写测试,可以参考 Working Effectively with Legacy Code by Michael Feathers一文,记住,没有通过测试的代码,不是好代码。
  • Jon Reid的Quality Coding app archives,是一个学习更多关于 Test Driven Development的好地方。

如果有关于这个教程的任何疑问或建议,请加入论坛在下面进行讨论。

0 人点赞