您还未登录! 登录 | 注册 | 帮助  

您的位置: 首页 > 软件测试技术 > 单元测试 > 正文

iOS单元测试和UI测试全面解析

发表于:2017-03-28 作者:朱先忠 编译 来源:51cto
编写测试可不是一项迷人的工作;然而,由于测试可以避免使你的宝贝应用程序变成一块充斥错误的大垃圾场,所以编写测试又是一项非常有必要做的工作。如果你正在阅读本文,那么你应当已经知道你应该为您的代码和用户界面编写测试,只是不确定如何在Xcode中编写测试。

iOS单元测试和UI测试全面解析

也许你已经开发出一个能够工作的应用程序,只是还没有对它进行测试;另一方面,当您扩展该应用程序时,你又想对其任何的更改进行测试。也许你已经写了一些测试,但尚不能确定它们是否是正确的测试。或者,你现在正在开发您的应用程序,并且想随着工作的进展对之进行测试。

本教程将向您全面展示如何使用Xcode中的测试导航器来测试应用程序的模型和异步方法,以及如何通过使用代理(注stub,有的文章译作“存根”)和模拟(mock)来模仿与库或系统对象的交互,如何测试用户界面和性能,以及如何使用代码覆盖工具。随着文章的展开,你会不断熟悉一些与测试相关的术语,到文章结尾时你会沉着地把依赖关系注入到你的被测系统(SUT,system under test)中!

测试,测试……

测试什么?

在写任何测试之前,首先要明确最基本的问题︰你需要测试什么?如果你的目标是扩展一款现有的应用程序,那么您应该首先为您计划更改的任何组件编写测试。

更一般的情况下,你的测试应包括如下一些内容︰

  • 核心功能︰模型类和方法及其与控制器的交互
  • 最常见的用户界面工作流
  • 边界条件
  • 错误修复

当务之急

首字母缩略词FIRST描述了一套简明有效的单元测试标准。这些标准是︰

  • Fast(快速)︰测试的运行速度应该很快,这样一来人们就不会介意运行它们。
  • Independent/Isolated(独立/分离)︰一个测试不应因另一个测试而进行安装或拆卸。
  • Repeatable(可重复)︰每次运行测试时,您应该获得相同的结果。值得注意的是,外部数据提供者和并发问题可能会导致程序的间歇性故障。
  • Self-validating(自我验证)︰测试应该能够完全自动化进行;输出应该要么是“pass”(即“通过”)要么是“fail”(即“失败”),而不是提供给程序员一个解释性的日志文件。
  • Timely(及时)︰理想情况下,应该只是在你编写生产代码之前编写测试。

遵循上述FIRST原则进行测试能够确保您的测试明确而有用,而不致使之成为您的应用程序中的路障。

开始

首先,请从网址https://koenig-media.raywenderlich.com/uploads/2016/12/Starters.zip处下载、解压缩、打开并观察本文提供的两个初始示例工程BullsEye和HalfTunes。

注意,工程BullsEye基于文章https://www.raywenderlich.com/store/ios-apprentice中提供的一个样本程序。我已经把游戏逻辑提取到一个BullsEyeGame类中,并相应地添加了另一种游戏风格。

在游戏的右下角提供了一个分段的控制器组件,供用户选择游戏风格︰或者是Slide类型,允许玩家移动滑块组件以尽可能接近目标值;或者是Type类型,允许玩家猜测滑块到达的位置。控件相应的动作代码中还会将用户选择的游戏风格存储为该用户的默认设置。

另一个示例工程HalfTunes则来自于我们的另一个教程NSURLSession(https://www.raywenderlich.com/110458/nsurlsession-tutorial-getting-started),现已被更新到Swift 3版本。用户可以使用iTunes API查询歌曲,然后下载并播放对应的歌曲片段。

下面,让我们正式开始测试!

Xcode中的单元测试

创建单元测试目标

Xcode中的测试导航器(Test Navigator)为进行程序测试提供了最容易使用的方式;你可以使用它创建测试目标并在你的程序上运行测试。

现在,请打开工程BullsEye并按下组合键Command+5来打开它的测试导航器。

然后,点击左下方的+按钮;之后,从菜单中选择“New Unit Test Target…”命令,如图所示。

在此,请直接使用默认的名称BullsEyeTests。当测试包出现在测试导航器中时,单击它,从而在编辑器中打开它。如果BullsEyeTests不会自动出现,你可以单击其他导航器,然后再返回到当前测试导航器即可。

注意到,模板导入了XCTest并定义了XCTestCase的一个子类BullsEyeTests,同时提供了setup()方法,tearDown()方法,还有系统默认的示例测试方法。

归纳起来,共有三种办法可以运行测试类:

1. 使用命令Product\Test或者Command-U;这将会运行所有的测试类。

2. 使用测试导航器中的箭头命令。

3. 也可以点击代码左边缘上的钻石按钮。

另外,您还可以通过单击测试导航器中或代码左边缘上的钻石按钮运行单个测试方法。

建议你尝试上面不同的方式来运行测试,从而感受一下需要多长时间以及运行测试看起来的样子。当前的样本测试并不做任何事,所以它们的运行速度会非常快!

当所有测试都成功时,钻石按钮会变绿,并在上面显示对号标记。你可以单击testPerformanceExample()方法最后面的灰色钻石按钮来打开性能结果(Performance Result)小窗进行观察,参考下图。

现在,我们并不需要函数testPerformanceExample();所以,把它删除即可。

使用XCTAssert测试模型

首先,您将使用XCTAssert来测试BullsEye模型的一个核心功能︰一个BullsEyeGame对象能否正确计算出一个回合的得分?

为此,请在文件BullsEyeTests.swift中紧贴着导入语句下方添加下面这一行代码︰


  1. @testable import BullsEye 

这一行代码使单元测试能够访问到BullsEye中的类和方法。

接下来,请在BullsEyeTests类的顶部添加下面的属性:


  1. var gameUnderTest: BullsEyeGame! 

然后,在setup()方法中在调用超类语句的下面启动一个新的BullsEyeGame对象:


  1. gameUnderTest = BullsEyeGame() 
  2.  
  3. gameUnderTest.startNewGame() 

上面的代码将创建一个类级的SUT(System Under Test,测试系统)对象。这样一来,测试类中的所有测试都可以访问该SUT对象的属性和方法。

在这里,你还可以调用游戏的startNewGame方法——此方法只创建一个targetValue值。您的很多测试都将使用这个targetValue值,来测试程序能够正确计算出游戏中的得分。

最后,切记在tearDown()方法中在调用超类前释放掉你的SUT对象︰


  1. gameUnderTest = nil 

【注意】一种值得推荐的测试做法是在方法setup()中创建SUT对象并在tearDown()方法中释放它,以确保每个测试都对应一个彻底的清理。更多的有关细节讨论,请参考Jon Reid的帖子http://qualitycoding.org/teardown/

现在,你已经准备好编写你的第一个测试了!

请使用如下代码替换工程中的方法testExample():


  1. // XCTAssert to test model 
  2. func testScoreIsComputed() { 
  3.   // 1. given 
  4.   let guess = gameUnderTest.targetValue + 5 
  5.   
  6.   // 2. when 
  7.   _ = gameUnderTest.check(guess: guess) 
  8.   
  9.   // 3. then 
  10.   XCTAssertEqual(gameUnderTest.scoreRound, 95, "Score computed from guess is wrong") 

测试方法的名称总是以test开头,后面跟着的是对它要测试的内容的说明。

一个推荐的做法是把测试方法格式化成given、when和then等几部分︰

1. 在given部分中,设置所需的任何值。在此示例中,您创建一个猜测值,以便可以指定它与targetValue值区别多大。

2. 在when部分中,执行被测试代码——调用方法gameUnderTest.check(_:)。

3. 在then部分中,断言你期望的结果(在现在情况下,gameUnderTest.scoreRound的值是100-5):如果测试失败则打印对应的消息。

现在,你可以单击测试导航器或者代码左边的钻石图标按钮运行测试。你会注意到应用程序将进行构建并运行起来,最后钻石图标将更改为一个绿色的对号标记!

【注意】若要查看XCTestAssertions的完整列表,你可以在按下Command键的同时单击代码中的XCTAssertEqual打开文件XCTestAssertions.h。此外,你还可以参考苹果官方网站提供的按类别提供的断言列表

(https://developer.apple.com/library/prerelease/content/documentation/DeveloperTools/Conceptual/testing_with_xcode/chapters/04-writing_tests.html#//apple_ref/doc/uid/TP40014132-CH4-SW35)。

另外,上述测试中的Given-When-Then结构来源于行为驱动测试(Behavior Driven Development,简称BDD)中的易于理解的行业术语。其实,你还可以使用另外一些命名系统,例如Arrange-Act-Assert和Assemble-Activate-Assert,等等。

调试一个测试

在BullsEyeGame工程中,我故意放置了一个错误。现在,我们进行测试,以便找到这个错误。为了观察此错误导致的问题,请把testScoreIsComputed重新命名为testScoreIsComputedWhenGuessGTTarget,然后复制、粘贴并编辑它,从而创建另一个方法testScoreIsComputedWhenGuessLTTarget。

在该测试中,在given部分把targetValue减去5,其他保持不变。详见下列代码:


  1. func testScoreIsComputedWhenGuessLTTarget() { 
  2.   // 1. given 
  3.   let guess = gameUnderTest.targetValue - 5 
  4.   
  5.   // 2. when 
  6.   _ = gameUnderTest.check(guess: guess) 
  7.   
  8.   // 3. then 
  9.   XCTAssertEqual(gameUnderTest.scoreRound, 95, "Score computed from guess is wrong") 

注意到:猜测值和targetValue值之间的区别仍然是5,因此分数应仍为95。

在断点导航器中,添加一个测试失败(Test Failure)断点;当一个测试方法发出一个失败的断言时这将停止测试运行。

现在运行你的测试:它应该在XCTAssertEqual一行停止,并出示一个测试错误。

然后,你可以在调试控制台上观察gameUnderTest和guess的输出结果:

你应该注意到:guess的值是-5,但scoreRound的值是105,而不是95!

为了进一步分析,你可以使用通常的调试过程︰在when语句上设置一个断点,也在BullsEyeGame.swift文件上设置一个断点——即在其中的方法check(_:)上设置。然后,再次运行测试,并以逐过程调试方式(即step-over)调试let语句来检查应用程序中的不同值。

现在的问题是,差值是一个负数;所以,得分是100-(-5)。解决方法是使用差异的绝对值即可。为此,在方法check(_:)中取消正确代码前面的注释,并删除不正确的代码即可。

删除上面设置的两个断点并再一次运行测试,以确认上面代码行现在已顺利通过。

使用XCTestExpectation测试异步操作

到目前为止,你已经学会了如何测试模型和调试测试失败。接下来,让我们继续学习如何使用XCTestExpectation来测试网络相关的操作。

首先,请打开HalfTunes项目。你会注意到,它使用URLSession来查询iTunes API和下载歌曲样本。假设您想修改它,以便使用AlamoFire进行网络操作。为了查看是否出现任何中断情况,您应为网络操作编写测试,并在更改代码之前和之后运行它们。

URLSession方法是异步执行的︰它们会马上返回,但只有运行一段时间后才真正完成。为了测试异步方法,你应使用XCTestExpectation使你的测试等待异步操作完成。

值得注意的是,异步测试通常很慢,所以你应该把它们与你另外的一些运行速度更快的单元测试分开。

从菜单“+”下选择并运行命令“New Unit Test Target…”,然后把目标命名为HalfTunesSlowTests。然后,在import语句的下面导入HalfTunes程序:


  1. @testable import HalfTunes 

在此类中的所有测试都将使用默认会话把请求发送到苹果公司的服务器。所以,我们在方法setup()中声明并创建一个sessionUnderTest对象,然后在方法tearDown()中释放它:


  1. var sessionUnderTest: URLSession! 
  2. override func setUp() { 
  3.   super.setUp() 
  4.   sessionUnderTest = URLSession(configuration: URLSessionConfiguration.default) 
  5. override func tearDown() { 
  6.   sessionUnderTest = nil 
  7.   super.tearDown() 

接下来,使用TestExample()函数来替换您的异步测试︰


  1. //异步测试时:成功测试很快,失败测试却比较慢 
  2. func testValidCallToiTunesGetsHTTPStatusCode200() { 
  3.   // given 
  4.   let url = URL(string: "https://itunes.apple.com/search?media=music&entity=song&term=abba") 
  5.   // 1 
  6.   let promise = expectation(description: "Status code: 200") 
  7.   
  8.   // when 
  9.   let dataTask = sessionUnderTest.dataTask(with: url!) { data, response, error in 
  10.     // then 
  11.     if let error = error { 
  12.       XCTFail("Error: \(error.localizedDescription)") 
  13.       return 
  14.     } else if let statusCode = (response as? HTTPURLResponse)?.statusCode { 
  15.       if statusCode == 200 { 
  16.         // 2 
  17.         promise.fulfill() 
  18.       } else { 
  19.         XCTFail("Status code: \(statusCode)") 
  20.       } 
  21.     } 
  22.   } 
  23.   dataTask.resume() 
  24.   // 3 
  25.   waitForExpectations(timeout: 5, handler: nil) 

上面这个测试的目的是检查发送到iTunes的有效的查询是否能够返回状态码200。显然,其中大部分代码与你在上面应用程序中所写的一样,只是增加了如下几行︰

1.expectation(_:)返回一个XCTestExpectation对象;此对象存储在变量promise中。此对象的其他常用名字是expectation和future。另外,description参数描述了你期望发生的事情。

2.为了匹配description参数,您需要在异步方法的完成处理程序的成功条件闭包中调用promise.fulfill()。

3.waitForExpectations(_:handler:)的作用是保持所有测试在运行中,直到所有的期望得以实现,或者timeout值指定的时间间隔结束——无论两者哪一种早发生都行。

现在,再来运行该测试。如果你已经连接到互联网,则当应用程序在模拟器中加载后成功测试大约花费一秒钟时间。

使测试失败更快一些

测试失败会导致不少问题,但它未必花费很多时间。现在,我们来解决如何快速确定是否您的测试失败的问题。

为了修改一下您的测试,从而导致异步操作时失败,你只需要从下面的URL中删除“itunes”一词后面的s字母即可:


  1. let url = URL(string: "https://itune.apple.com/search?media=music&entity=song&term=abba") 

运行上述测试时︰它会失败,而且此测试会花费所有指定的超时间隔时间!这是因为它的期望是请求成功——正是在这个位置调用了promise.fulfill()方法。既然请求失败,那么测试仅当在超过指定时限时才结束。

你可以使这个测试失败更快一些——这只要通过改变它的期望值即可达到︰不是等待请求成功,而只需要等到异步方法的完成处理程序触发即可。只要应用程序接收到来自服务器端的响应(或者是成功或者是失败)这种情况就会发生;但是,这的确符合预期结果。然后,您的测试可以检查请求是否成功。

为了查看这是如何工作的,您要创建一个新的测试。首先,修复此测试——这可以通过撤消上面的url更改操作轻松完成,然后将下面的测试添加到您的类中︰


  1. // Asynchronous test: faster fail 
  2. func testCallToiTunesCompletes() { 
  3.   // given 
  4.   let url = URL(string: "https://itune.apple.com/search?media=music&entity=song&term=abba") 
  5.   // 1 
  6.   let promise = expectation(description: "Completion handler invoked") 
  7.   var statusCode: Int? 
  8.   var responseError: Error? 
  9.   
  10.   // when 
  11.   let dataTask = sessionUnderTest.dataTask(with: url!) { data, response, error in 
  12.     statusCode = (response as? HTTPURLResponse)?.statusCode 
  13.     responseError = error 
  14.     // 2 
  15.     promise.fulfill() 
  16.   } 
  17.   dataTask.resume() 
  18.   // 3 
  19.   waitForExpectations(timeout: 5, handler: nil) 
  20.   
  21.   // then 
  22.   XCTAssertNil(responseError) 
  23.   XCTAssertEqual(statusCode, 200) 

上面代码中最关键的一点是,只需输入完成处理程序实现的期望——这需要大约一秒钟即会发生。如果请求失败,那么断言也会失败。

现在再来运行上面的测试︰它现在大约需要一秒钟即会失败;它的失败是因为请求失败了,而不是因为测试运行超时。

修复上面的url,然后再一次运行测试,以确认它现在能够成功通过测试。

伪造对象和交互

异步测试能够给你信心——你的代码会为一个异步API提供正确的输入。你可能也想测试您的代码能够正常工作——当它从URLSession接收输入时,或当它正确更新了UserDefaults或者CloudKit数据库时。

大多数应用程序都会与系统或库对象(你不能控制这些对象)进行交互,而与这些对象的交互测试很可能是极其缓慢的,而且不可重复的——这正违反了文章开始时FIRST原则中的两条。相反,你可以伪造这些交互——通过从代理(stub)中获取输入或更新模拟对象(Mock Object)来实现。

当您的代码依赖于一个系统或库中的对象时,通过上面伪造的办法可以创建一个假的对象来实现那一部分功能并把这种伪造注入到您的代码中。乔恩·里德的依赖性注入技术文章(https://www.objc.io/issues/15-testing/dependency-injection/)中就介绍了好几种方法来达到这一目的。

从代理(stub)中伪造输入

在本节中的测试中,你将要检查应用程序的updateSearchResults(_:)方法能够正确解析由会话下载的数据——通过检查属性searchResults.count的值是正确的来实现。SUT是视图控制器;你要使用代理(stub)技术来伪装一个会话和一些预先下载的数据。

为此,从“+”菜单下选择命令“New Unit Test Target…”并命名它为HalfTunesFakeTests。然后,在import语句的下面导入HalfTunes程序:


  1. @testable import HalfTunes 

接下来,声明SUT,并在setup()方法中创建它,且在tearDown()方法中对之进行释放:


  1. var controllerUnderTest: SearchViewController! 
  2.   
  3. override func setUp() { 
  4.   super.setUp() 
  5.   controllerUnderTest = UIStoryboard(name: "Main",  
  6.       bundle: nil).instantiateInitialViewController() as! SearchViewController! 
  7.   
  8. override func tearDown() { 
  9.   controllerUnderTest = nil 
  10.   super.tearDown() 

【注】SUT(被测系统)是视图控制器,因为HalfTunes工程中拥有大量的视图控制器问题——所有的工作都是在文件searchviewcontroller.swift中完成的。“将网络代码移动到单独的模块”(详见文章http://williamboles.me/networking-with-nsoperation-as-your-wingman/)将会减少这一问题,而且也使测试更为容易。

接下来,您将需要一些样本JSON数据,供您的伪造的会话提供给你的测试使用。只需要做一少部分工作即可;因此,请限制一下您的来自iTunes的下载结果——在URL字符串的后面添加一个限制串&limit=3:

https://itunes.apple.com/search?media=music&entity=song&term=abba&limit=3

复制此URL并把它粘贴到浏览器中。这将下载一个名为1.txt或类似的文件。你可以预览一下它,以便确认这是一个JSON格式的文件,然后重命名它为abbaData.json,并把该文件添加到HalfTunesFakeTests组中。

HalfTunes项目包含了支持文件DHURLSessionMock.swift。这个文件中定义了一个简单的协议——DHURLSession,其提供的方法(代理)用于使用一个URL或URLRequest来创建一个数据任务。它还定义了符合该协议的URLSessionMock对象,该对象中提供的初始化器可以让你使用你选择的数据、响应和误差等来创造一个模拟URLSession对象。

现在,我们来构建伪造的数据和响应,并创建伪造的会话对象;这些都实现于方法setup()中,相应的代码位于创建SUT对象的语句之后:


  1. let testBundle = Bundle(for: type(of: self)) 
  2. let path = testBundle.path(forResource: "abbaData", ofType: "json") 
  3. let data = try? Data(contentsOf: URL(fileURLWithPath: path!), options: .alwaysMapped) 
  4.   
  5. let url = URL(string: "https://itunes.apple.com/search?media=music&entity=song&term=abba") 
  6. let urlResponse = HTTPURLResponse(url: url!, statusCode: 200, httpVersion: nil, headerFields: nil) 
  7.   
  8. let sessionMock = URLSessionMock(data: data, response: urlResponse, error: nil) 
  9. At the end of setup(), inject the fake session into the app as a property of the SUT: 
  10.  
  11. controllerUnderTest.defaultSession = sessionMock 

【注意】您将直接在您的测试中使用伪造的会话,但是这将向你展示如何注入这种伪造的会话;这样一来,你进一步的测试可以调用使用视图控制器defaultSession属性的SUT方法。

现在,您可以编写测试来检查是否调用updateSearchResults(_:)方法能够解析伪造的数据。为此,请把TestExample()方法替换为以下内容︰


  1. //使用DHURLSession协议和代理伪造URLSession 
  2. func test_UpdateSearchResults_ParsesData() { 
  3.   // given 
  4.   let promise = expectation(description: "Status code: 200") 
  5.   
  6.   // when 
  7.   XCTAssertEqual(controllerUnderTest?.searchResults.count, 0, "searchResults should be empty before the data task runs") 
  8.   let url = URL(string: "https://itunes.apple.com/search?media=music&entity=song&term=abba") 
  9.   let dataTask = controllerUnderTest?.defaultSession.dataTask(with: url!) { 
  10.     data, response, error in 
  11.     // if HTTP request is successful, call updateSearchResults(_:) which parses the response data into Tracks 
  12.     if let error = error { 
  13.       print(error.localizedDescription) 
  14.     } else if let httpResponse = response as? HTTPURLResponse { 
  15.       if httpResponse.statusCode == 200 { 
  16.         promise.fulfill() 
  17.         self.controllerUnderTest?.updateSearchResults(data) 
  18.       } 
  19.     } 
  20.   } 
  21.   dataTask?.resume() 
  22.   waitForExpectations(timeout: 5, handler: nil) 
  23.   
  24.   // then 
  25.   XCTAssertEqual(controllerUnderTest?.searchResults.count, 3, "Didn't parse 3 items from fake response") 

注意,你仍然要以异步方式来编写这个测试,因为代理(stub)假装自己是一个异步的方法。

上面代码中,when断言的作用是:在数据任务运行之前searchResults的值应当是空的——这应该是真实情况,因为您在setup()方法中创建了一个全新的SUT。

伪造的数据包含了提供给三个跟踪(Track)对象使用的JSON数据;所以,then断言的作用是:视图控制器的searchResults数组应当包含三项。

再次运行该测试。这次应该成功,而且速度很快,因为不存在任何真实的网络连接!

伪造对模拟对象的更新

以前的测试使用代理从假对象提供输入。接下来,你可以使用一个模拟对象来测试你的代码可以正确更新UserDefaults。

重新打开BullsEye项目。注意到,该应用程序提供了两种游戏风格:用户可以选择移动滑块来匹配目标值或从滑块位置猜测目标值。借助于界面右下角的分段控制开关可以切换游戏风格并更新用户默认的游戏风格。

你要编写的下一个测试将检查应用程序能够正确地更新用户默认的游戏风格数据。

在测试导航器中,点击命令“New Unit Test Target…”,并命名为BullsEyeMockTests。然后,在导入语句下面添加以下内容:


  1. @testable import BullsEye 
  2.   
  3. class MockUserDefaults: UserDefaults { 
  4.   var gameStyleChanged = 0 
  5.   override func set(_ value: Int, forKey defaultName: String) { 
  6.     if defaultName == "gameStyle" { 
  7.       gameStyleChanged += 1 
  8.     } 
  9.   } 

注意到,上面的MockUserDefaults类重载了set(_:forKey:)方法以便把gameStyleChanged标志的值加1。通常你会看到类似的测试中是设置一个布尔变量,但是在此我们使用一个整数值加1,这可以进一步增加你的灵活控制——例如你的测试可以检查该方法仅被正确地调用一次。

在BullsEyeMockTests类中声明SUT对象和模拟对象:


  1. var controllerUnderTest: ViewController! 
  2. var mockUserDefaults: MockUserDefaults! 

在方法setup()中,创建SUT对象和模拟对象,然后把此模拟对象注入为该SUT的一个属性:


  1. controllerUnderTest = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController() as! ViewController! 
  2. mockUserDefaults = MockUserDefaults(suiteName: "testing")! 
  3. controllerUnderTest.defaults = mockUserDefaults 
  4. Release the SUT and the mock object in tearDown(): 
  5. controllerUnderTest = nil 
  6. mockUserDefaults = nil 
  7. Replace testExample() with this: 
  8. // Mock to test interaction with UserDefaults 
  9. func testGameStyleCanBeChanged() { 
  10.   // given 
  11.   let segmentedControl = UISegmentedControl() 
  12.   
  13.   // when 
  14.   XCTAssertEqual(mockUserDefaults.gameStyleChanged, 0, "gameStyleChanged should be 0 before sendActions") 
  15.   segmentedControl.addTarget(controllerUnderTest,  
  16.       action: #selector(ViewController.chooseGameStyle(_:)), for: .valueChanged) 
  17.   segmentedControl.sendActions(for: .valueChanged) 
  18.   
  19.   // then 
  20.   XCTAssertEqual(mockUserDefaults.gameStyleChanged, 1, "gameStyle user default wasn't changed") 

上述代码中的when断言的作用是:gameStyleChanged标志的值为0——在测试方法触发分段控制开关之前。因此,如果then断言也为真,那么将意味着方法set(_:forKey:)仅被正确地调用一次。

现在再次运行测试;应当可以成功。

在Xcode中进行UI测试

Xcode 7中引入了对UI测试的支持,使您可以通过记录与UI的交互来创建UI测试。UI测试的工作方式是:通过查询来查找一个应用程序的UI对象,进而合成事件,然后将这些事件发送给这些对象。其提供的API使您可以检查一个用户界面对象的属性和状态,以便把它们与预期的状态进行比较。

现在,让我们在BullsEye项目的测试导航器中添加一个新的UI测试目标。确保要被测试的目标是BullsEye,然后接受默认名称BullsEyeUITests。

然后,在BullsEyeUITests类的顶部添加如下属性︰


  1. var app: XCUIApplication! 

在方法setup()中,用以下代码替换XCUIApplication().launch()语句︰


  1. app = XCUIApplication() 
  2.  
  3. app.launch() 

把testExample()的名字更改为testGameStyleSwitch()。

然后,在testGameStyleSwitch()中按下回车键创建一个新的空行,并点击编辑器窗口底部的红色的Record按钮,如图所示。

当应用程序出现在模拟器中时,点击控制游戏风格开关的滑动块及顶部标签。然后,单击Xcode中的Record按钮即可停止录制。

现在,你在方法testGameStyleSwitch()中拥有以下三行代码︰


  1. let app = XCUIApplication() 
  2.  
  3. app.buttons["Slide"].tap() 
  4.  
  5. app.staticTexts["Get as close as you can to: "].tap() 

如果还有其他的语句,则删除它们。

第一行代码的作用是复制你在setup()方法中创建的属性;因为你还不需要点击任何东西,所以也把这第一行删除,还要删除第2行与第3行末尾的“.tap()”。打开["Slide"]邻近的小菜单并选择segmentedControls.buttons["Slide"]。

于是,你有了如下的代码:


  1. app.segmentedControls.buttons["Slide"] 
  2.  
  3. app.staticTexts["Get as close as you can to: "] 

进一步修改上述代码,以便创建测试的given部分:


  1. // given 
  2.  
  3. let slideButton = app.segmentedControls.buttons["Slide"] 
  4.  
  5. let typeButton = app.segmentedControls.buttons["Type"] 
  6.  
  7. let slideLabel = app.staticTexts["Get as close as you can to: "] 
  8.  
  9. let typeLabel = app.staticTexts["Guess where the slider is: "] 

现在,你有了两个按钮和两个可能的顶部标签的名称,再添加以下内容︰


  1. // then 
  2.  
  3. if slideButton.isSelected { 
  4.  
  5. XCTAssertTrue(slideLabel.exists) 
  6.  
  7. XCTAssertFalse(typeLabel.exists) 
  8.  
  9. typeButton.tap() 
  10.  
  11. XCTAssertTrue(typeLabel.exists) 
  12.  
  13. XCTAssertFalse(slideLabel.exists) 
  14.  
  15. } else if typeButton.isSelected { 
  16.  
  17. XCTAssertTrue(typeLabel.exists) 
  18.  
  19. XCTAssertFalse(slideLabel.exists) 
  20.  
  21. slideButton.tap() 
  22.  
  23. XCTAssertTrue(slideLabel.exists) 
  24.  
  25. XCTAssertFalse(typeLabel.exists) 
  26.  

这段代码将会检测当选中或者点击每个按钮时是否存在正确的标签。现在,运行测试——结果是所有断言应该都成功。

性能测试

根据苹果公司官方文档

(https://developer.apple.com/library/prerelease/content/documentation/DeveloperTools/Conceptual/testing_with_xcode/chapters/04-writing_tests.html#//apple_ref/doc/uid/TP40014132-CH4-SW8)描述:一个性能测试需要使用你想要评估的一个代码块,并运行此代码块10次,期间收集平均执行时间和运行的标准偏差值。这些个别测量的平均值成为测试运行的一个值,然后把该值与一个基准值进行比较来评估成功或失败。

写一个性能测试还是非常简单的︰你只需要把你想要测试的代码放到measure()方法的闭包中即可。

为了实际体验一下,请重新打开HalfTunes项目,然后在HalfTunesFakeTests类中使用下面的测试,从而替换掉系统默认生成的testPerformanceExample()方法︰


  1. // Performance  
  2. func test_StartDownload_Performance() { 
  3.   let track = Track(name: "Waterloo", artist: "ABBA",  
  4.       previewUrl: "http://a821.phobos.apple.com/us/r30/Music/d7/ba/ce/mzm.vsyjlsff.aac.p.m4a") 
  5.   measure { 
  6.     self.controllerUnderTest?.startDownload(track) 
  7.   } 

现在,请运行上面的测试,然后单击measure()闭包末尾的图标来观看统计信息。

单击“Set Baseline”(设置基准值)按钮,然后再次运行性能测试并查看结果——结果有可能比基准值更好或更糟。你可以点击Edit(编辑)按钮帮助您将基准值重置为这个新的结果。

基准值在每个设备配置时存储起来,所以你可以让同一测试执行在若干台不同的设备上,并使每台设备保持一个不同的基准值——这要取决于处理器速度、内存等的具体配置情况。

任何时候只要你更改一个应用程序,都有可能影响正在测试的方法的性能;你可以再次运行性能测试来观察当前值与基准值比较的结果。

代码覆盖

代码覆盖工具能够告诉你应用程序中的哪些代码实际上被您的测试运行过;这样一来,你就可以知道应用程序代码的哪些部分还没有被测试。

【注意】在启用代码覆盖功能时你是否应该运行性能测试呢?苹果公司的文档(https://developer.apple.com/library/prerelease/content/documentation/DeveloperTools/Conceptual/testing_with_xcode/chapters/07-code_coverage.html#//apple_ref/doc/uid/TP40014132-CH15-SW1)中是这样描述的︰代码覆盖数据集合会导致性能的下降……以线性方式影响代码的执行;因此,当启用代码覆盖功能时程序的性能将会因不同的测试运行而有所差异。但是,当你对你的测试中的例程要求极其严格时你应该认真考虑是否要启用代码覆盖支持。

为了启用代码覆盖功能,你可以编辑一下你预先计划的测试(Test)操作并勾选“Code Coverage”复选框︰

运行您的所有测试(按下组合键Command+U),然后打开报告导航器(按下组合键Command+8)。按执行时间先后选择(By Time,见下图)列表中最上面的一项,然后再选择“Coverage”(覆盖)选项卡。

你可以单击如下图展开的三角形图标来观察SearchViewController.swift文件中的函数列表︰

你可以把鼠标悬停在updateSearchResults(_:)方法附近的蓝色的Coverage(覆盖率)条上观察到对应的覆盖率为71.88%。

单击该函数对应的箭头按钮来打开源文件,并定位到该函数。当你的鼠标移到右边栏中的覆盖率注释上时,代码段将突出显示为绿色或红色︰

覆盖率注释上的信息显示出一个测试中命中每个代码段的次数。注意,没有被调用到的代码段部分突出显示为红色。正如你所期望的,for循环运行3次,但没有一次是沿着错误路径执行的。为了提高此函数的代码覆盖率,你可以复制abbaData.json,然后修改它,使其会导致不同的错误——例如,将“results”更改为“result”来测试执行到打印语句print("Results key not found in dictionary")的情况。

100%覆盖?

争取实现100%的代码覆盖率你可知道应该付出怎样的努力吗?如果你使用谷歌搜索引擎搜索“100% unit test coverage”的话,你会搜索到有赞同的也有反对的等多种观点,以及围绕100%覆盖率的大量争论。其中,持反对看法的认为最后的10-15%并不重要——不值得为之付出努力;而持赞同看法的认为最后的10-15%极其重要——因为它很难测试。再使用谷歌搜索引擎搜索“hard to unit test bad design”可以找到颇有说服力的论据——无法验证的代码是一种更深层次的设计问题(https://www.toptal.com/qa/how-to-write-testable-code-and-why-it-matters)。进一步的思考可能导致的结论是测试驱动开发(http://qualitycoding.org/tdd-sample-archives/)是软件开发过程中必须要走的路。

总结

本文中已经向你提供了为你的iOS工程编写测试的多种工具。我希望你能够通过本教程的学习树立起足够的信心来测试一切!

你可以从地址https://koenig-media.raywenderlich.com/uploads/2016/12/Finished-3.zip处下载本文中的完整的示例工程源码。

最后,下面提供的一些资源可以供你作进一步学习测试使用:

  • 既然通过本文学习你已经学会了为你的项目编写测试,那么你下一步要了解的应当是自动化测试相关的主题。为此,你可以首先学习苹果官方的基于Xcode Server和xcodebuild的自动测试过程(Automating the Test Process,https://developer.apple.com/library/content/documentation/DeveloperTools/Conceptual/testing_with_xcode/chapters/08-automation.html#//apple_ref/doc/uid/TP40014132-CH7-SW1),以及发表在Wikipedia上的相关连载文章(https://en.wikipedia.org/wiki/Continuous_delivery),来源于ThoughtWorks网站(https://www.thoughtworks.com/continuous-delivery)上的一位资深专家的文章。
  • 使用Swift Playgrounds进行测试驱动开发(http://initwithstyle.net/2015/11/tdd-in-swift-playgrounds/)。你可以在Playgrounds环境下使用XCTestObservationCenter来运行XCTestCase单元测试。你可以在Playgrounds中开发你的工程代码并进行测试,然后把二者都转换成你的应用程序。
  • 来自CMD+U协会(http://www.cmduconf.com/)的教程告诉你如何使用PivotalCoreKit(https://github.com/pivotal/PivotalCoreKit)来测试watchOS应用程序。
  • 如果你已经编写了一个应用程序,而只是没有为它编写测试,你可以参阅Michael Feathers的图书《Working Effectively with Legacy Code》(https://www.amazon.com/Working-Effectively-Legacy-Michael-Feathers/dp/0131177052/ref=sr_1_1?s=books&ie=UTF8&qid=1481511568&sr=1-1),因为不包含测试的代码往往都是遗留下来的代码!
  • Jon Reid的高质量编码示例编程文章(http://qualitycoding.org/tdd-sample-archives/)也是你学习测试驱动开发的极好去处。