Unit Testing Tutorial:Mocking Objects(Part2)

本文翻译自Unit Testing Turorial:Mocking Objects
这是文章的下半截.

  • Writing Mocks

模拟对象能够使你在应用中测试当某些事情发生时方法是否调用或者属性是否被设定.例如,在PeopleListViewController的viewDidLoad()中,table view设置属性dataProvider.
你将编写一个测试来检查它是否发生.

  • 测试的准备

首先,项目做测试前你需要做些充分准备.
选择项目的导航栏,在Birthdays对象下的Build Settings中搜索Defines Module,将其设置为Yes,如下图:

Screen-Shot-2015-03-28-at-17.40.12-700x195.png

在BirthdaysTest文件夹里以Test Case Class为模板添加名为PeopleListViewControllerTests的Swift文件.

如果Xcode让你选择是否创建桥接文件,选No.这是Xcode的一个bug.

打开新创建的PeopleListViewControllerTests.swift文件.确保你在其他导入文件下面导入了Birthdays,效果如下:

import UIKit
import XCTest
import Birthdays

删除下面的两个方法:

func testExample() {
  // This is an example of a functional test case.
  XCTAssert(true, "Pass")
}
 
func testPerformanceExample() {
  // This is an example of a performance test case.
  self.measureBlock() {
    // Put the code you want to measure the time of here.
  }
}

你现在需要一个PeopleListViewController实例来进行测试.
在PeopleListViewControllerTests的开头添加如下的代码:

var viewController: PeopleListViewController!

替换setUp()里的代码:

override func setUp() {
  super.setUp()
 
  viewController = UIStoryboard(name: "Main", bundle: nil).instantiateViewControllerWithIdentifier("PeopleListViewController") as! PeopleListViewController
}

这个方法用main storyboard来创建一个PeopleListViewController的实例并把它赋给viewController.
点击Product\Test;Xcode会运行项目中已有的所有测试方法.虽然现在你并没有任何测试代码,但它能够确保目前为止一切都是正常的.不一会,Xcode会报告所有测试都是成功的.
你现在可以创建你的第一个mock了.

  • 编写你的首个Mock

你正在使用Core Data,在PeopleListViewControllerTests.swift里面导入:

import CoreData

然后在PeopleListViewControllerTests里添加:

class MockDataProvider: NSObject, PeopleListDataProviderProtocol {
 
  var managedObjectContext: NSManagedObjectContext?
  weak var tableView: UITableView!
  func addPerson(personInfo: PersonInfo) { }
  func fetch() { }
  func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return 1 }
  func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
    return UITableViewCell()
  }
}

这看起来像个比较复杂的mock类.然而,这仅是最基础的需要,设定一个PeopleListViewController中dataProvider属性的模拟类实例.你的模拟类也遵从PeopleListDataProviderProtocol和UITableViewDataSource协议.

点击Product\Test;你的项目会再次运行且你的0个测试函数会有0个失败.但这并不意味着通过率100%. :] 但现在你已经为你的第一单元测试做好了准备.

单元测试中好的做法是将其分为given,when和then三个部分.'Given'设置测试环境条件;'when'运行你想测试的代码;'then'检查是否得到预期的结果.

你的测试将在viewDidload()运行之后检查data provider中的tableView的属性.

在PeopleListViewControllerTests添加如下测试:

func testDataProviderHasTableViewPropertySetAfterLoading() {
  // given
  // 1
  let mockDataProvider = MockDataProvider()
 
  viewController.dataProvider = mockDataProvider
 
  // when
  // 2
  XCTAssertNil(mockDataProvider.tableView, "Before loading the table view should be nil")
 
  // 3
  let _ = viewController.view
 
  // then    
  // 4
  XCTAssertTrue(mockDataProvider.tableView != nil, "The table view should be set")
  XCTAssert(mockDataProvider.tableView === viewController.tableView, 
    "The table view should be set to the table view of the data source")
}

下面为以上代码的做的事情:

  1. 创建实例MockDataProvider并把其设为view controller的dataProvider属性.
  2. 在测试之前通过断言设置tableView属性为nil.
  3. 访问view来触发viewDidLoad().
  4. 通过断言设置测试类中的tableview属性不为nil并设置view controller中的tableView.

再次点击Product\Test;测试完成后,选择test导航(或者按快捷键Cmd+5).你将看到如下结果:

Screen-Shot-2015-03-29-at-10.54.20.png

通过绿色的对号可以看到你的一个模拟测试通过啦! :]

  • Testing addPerson(_:)

接下来要通过调用data provider中的addPerson(_:)来测试下通讯录选择.
在MockDataProvider类中增加如下属性:

var addPersonGotCalled = false

修改addPerson(_:):

func addPerson(personInfo: PersonInfo) { addPersonGotCalled = true }

此时,当你调用addPerson(_:)时,会在实例MockDataProvider中设置addPersonGotCalled为true.
在进行测试之前你需要导入AddressBookUI框架.
在PeopleListViewControllerTests.swift导入:

import AddressBookUI

现在添加如下测试代码:

func testCallsAddPersonOfThePeopleDataSourceAfterAddingAPersion() {
  // given
  let mockDataSource = MockDataProvider()
 
  // 1
  viewController.dataProvider = mockDataSource
 
  // when
  // 2
  let record: ABRecord = ABPersonCreate().takeRetainedValue()
  ABRecordSetValue(record, kABPersonFirstNameProperty, "TestFirstname", nil)
  ABRecordSetValue(record, kABPersonLastNameProperty, "TestLastname", nil)
  ABRecordSetValue(record, kABPersonBirthdayProperty, NSDate(), nil)
 
  // 3
  viewController.peoplePickerNavigationController(ABPeoplePickerNavigationController(), 
    didSelectPerson: record)
 
  // then
  // 4
  XCTAssert(mockDataSource.addPersonGotCalled, "addPerson should have been called")
}

上面代码做了哪些操作呢?

  1. 首先你将view controller中的data provider设置为你的模拟data provider实例.
  2. 继而通过ABPersonCreate()创建通讯录.
  3. 手动调用代理方法peoplePickerNavigationController(_:didSelectPerson:).通常,手动调用代理方法是个code smell,但对测试来讲也还好啦.
  4. 最后通过data provider模拟设置为true查看addPersonGotCalled来断言addPerson(_:).

点击测试—你将会全部通过.测试是很简单的事情吧!
但稍等,怎样知道测试正是你想要测试的内容呢?

ragecomic_wat.png
  • Testing Your Tests

一个检测测试真正使一些事情生效的方法是移出这个生效的测试实体.

在PeopleListViewController.swift中的peoplePickerNavigationController(_:didSelectPerson:)下面注释掉:

dataProvider?.addPerson(person)

运行测试;你最后写的测试将会失败.好了—你现在知道你的测试方法真正测试了一些东西了.这是个测试你的测试代码的好方法;你应该测试你最复杂的测试方法来确保他们工作正常.

ragecomic_testsOfTests.png

取消注释使代码保持原来的状态;再次运行测试来确保一切正常.

  • Mocking Apple Framework Classes

你也许用过单例,例如NSNotificationCenter.defaultCenter()和NSUserDefaults.standardUserDefaults().但你如何来测试一个notification是否真正发送或者一个default被设置了?苹果不允许你测试这些类的状态.

你可以添加一个想要的notifications观察测试类.但这也许会使你的测试变得非常慢且实现这些类变得不可靠.Notification还可能从你的其他代码处被触发,使测试变得不再是单独的行为了.

想要打破这些限制,你可以在这些单例的地方使用mocks.

运行程序;在人员列表中和切换姓和名的分类中添加John Appleseed和David Taylor.你会发现通讯录的列表是按顺序排列的.

代码中是通过PeopleListViewController.swift中的changeSort()来实现的.

@IBAction func changeSorting(sender: UISegmentedControl) {
    userDefaults.setInteger(sender.selectedSegmentIndex, forKey: "sort")
    dataProvider?.fetch()
}

通过user defaults存储的sort key来进行选择并调用data provider的方法fetch(). fetch()会读取你存储在user default中的新的排序关键字并且刷新通讯录列表,在PeopleListDataProvider中:

public func fetch() {
  let sortKey = NSUserDefaults.standardUserDefaults().integerForKey("sort") == 0 ? "lastName" : "firstName"
 
  let sortDescriptor = NSSortDescriptor(key: sortKey, ascending: true)
  let sortDescriptors = [sortDescriptor]
 
  fetchedResultsController.fetchRequest.sortDescriptors = sortDescriptors
  var error: NSError? = nil
  if !fetchedResultsController.performFetch(&error) {
    println("error: \(error)")
  }
  tableView.reloadData()
}

PeopleListDataProvider使用NSFetchedResultsController来从Core Data中解析数据.为了改变列表的顺序,fetch()创建一个排序后的数组并把它赋给取数据的请求中来获取结果.然后将数据传到列表中进行刷新.

你现在增加了一个测试用户选择存储在NSUserDefaults中的排序.

在PeopleListViewControllerTests.swift中的MockDataProvider下面添加如下定义的类:

class MockUserDefaults: NSUserDefaults {
  var sortWasChanged = false
  override func setInteger(value: Int, forKey defaultName: String) {
    if defaultName == "sort" {
      sortWasChanged = true
    }
  }
}

MockUserDefaults为NSUserDefaults的子类;它有一个默认为false的名为sortWasChanged的布尔属性.且重写了setImage(_:forKey:)的方法来改变sortWasChanged为true.

在你测试类的最后测试方法下面添加:

func testSortingCanBeChanged() {
  // given
  // 1
  let mockUserDefaults = MockUserDefaults(suiteName: "testing")!
  viewController.userDefaults = mockUserDefaults
 
  // when
  // 2
  let segmentedControl = UISegmentedControl()
  segmentedControl.selectedSegmentIndex = 0
  segmentedControl.addTarget(viewController, action: "changeSorting:", forControlEvents: .ValueChanged)
  segmentedControl.sendActionsForControlEvents(.ValueChanged)
 
  // then
  // 3
  XCTAssertTrue(mockUserDefaults.sortWasChanged, "Sort value in user defaults should be altered")
}

下面是以上代码的释义:

  1. 你首先创建一个MockUserDefaults的实例赋给viewController中的userDefaults;这种做法叫做dependency injection.
  2. 然后创建一个UISegmentedControl的实例,为这个view controller添加.ValueChanged值来控制事件的发生.
  3. 最后模拟类的user defaults中的断言setImage(_:forKey:)被调用.

运行你的测试代码—将会全部通过.

如果你的应用有非常复杂的API或框架,但你只想测试其中一个非常小的特性时,如何做呢?

"face"该登场了! :]

  • 编写Fakes

Fakes像一个它伪造的全功能的类.利用它可以当做替代类或者处理测试中过于复杂的结构体.

在例子中,你并不想在测试时给真实的Core Data数据库中添加或读取数据.因此,你要fake Core Data数据存储.

添加新的测试类PeopleListDataProviderTests.

在新类中删除下面的示例测试:

func testExample() {
  // ...
}
 
func testPerformanceExample() {
  // ...
}

在类中导入:

import Birthdays
import CoreData

添加如下属性:

var storeCoordinator: NSPersistentStoreCoordinator!
var managedObjectContext: NSManagedObjectContext!
var managedObjectModel: NSManagedObjectModel!
var store: NSPersistentStore!
 
var dataProvider: PeopleListDataProvider!

这些属性包含了Core Data所需的大部分组件.如果对Core Data不熟,可以看看Core Data Tutorial: Getting Started
在setUp()里添加如下代码:

// 1
managedObjectModel = NSManagedObjectModel.mergedModelFromBundles(nil)
storeCoordinator = NSPersistentStoreCoordinator(managedObjectModel: managedObjectModel)
store = storeCoordinator.addPersistentStoreWithType(NSInMemoryStoreType, 
  configuration: nil, URL: nil, options: nil, error: nil)
 
managedObjectContext = NSManagedObjectContext()
managedObjectContext.persistentStoreCoordinator = storeCoordinator
 
// 2
dataProvider = PeopleListDataProvider()
dataProvider.managedObjectContext = managedObjectContext

下面是以上代码的释义:

  1. setUp() 在内存中创建一个管理对象.通常Core Data存储在设配的文件系统中.但对于这些测试,你存储在设配的内存中.
  2. 继而创建一个PeopleListDataProvider的实例和将存储在内存中的管理对象设置为它的managedObjectContext.意味着你的新data provider将会和真实情况效果一样,但不会在应用中真实的添加删除对象.

在PeopleListDataProviderTests中添加下面两个属性:

var tableView: UITableView!
var testRecord: PersonInfo!

在setUp()的底部添加如下代码:

let viewController = UIStoryboard(name: "Main", bundle: nil).instantiateViewControllerWithIdentifier("PeopleListViewController") as! PeopleListViewController
viewController.dataProvider = dataProvider
 
tableView = viewController.tableView
 
testRecord = PersonInfo(firstName: "TestFirstName", lastName: "TestLastName", birthday: NSDate())

这从storyboard的view controller中获取table view 的设置并创建将在测试中用到的PersonInfo.

但测试结束时,你将清除这些数据对象.

将tearDown()中的代码替换为:

override func tearDown() {
  managedObjectContext = nil
 
  var error: NSError? = nil
  XCTAssert(storeCoordinator.removePersistentStore(store, error: &error), 
    "couldn't remove persistent store: \(error)")
 
  super.tearDown()
}

上面代码将managedObjectContext设为nil用来清除内存中存储的数据.这是要做的基本工作.你需要在进行下个测试之前有个干净的存储空间.

现在开始编写真正的测试文件了!在你的测试类中添加:

func testThatStoreIsSetUp() {
  XCTAssertNotNil(store, "no persistent store")
}

这将测试存储的是否为nil.将检查存储没有被创建的失败情况.

运行你的测试,一切正常.

下面将测试数据是否是想要的行数.

在测试类中添加如下测试:

func testOnePersonInThePersistantStoreResultsInOneRow() {
  dataProvider.addPerson(testRecord)
 
  XCTAssertEqual(tableView.dataSource!.tableView(tableView, numberOfRowsInSection: 0), 1, 
    "After adding one person number of rows is not 1") 
}

首先,在测试存储中添加一个通讯录,然后断言行数是否等于1.运行测试,将会测试成功.

通过创建一个fake "persistent"存储来避免写入磁盘,能够快速测试并使你的磁盘保持干净,同时能够使你运行程序时更加自信,一切都如设想般运行.

实际的测试中,你还可以测试多个sections和rows,主要取决你对项目的想要达到的自信程度.

如果你曾经在一个项目的多个团队里,将会知道并不是项目的所有部分都会在同一时间准备好,但你仍需要测试你的代码.在服务器还没准备好时,你怎么才能测试你的代码呢?

Stubs登场了! :]

  • 编写Stubs

Stubs假设一个方法对象的返回值.你将用stubs来测试在web 服务器还没有完成的情况下的你的代码.

Web组要为你的项目建设一个和app功能相同的网站.用户通过该网站注册的账号可以同步到app端.但Web组甚至还没有开始,你却已经接近完成了.这时候你需要写个stub来模拟服务器.

本章将专注两种测试方法:一种是解析通讯录添加到网站,另一种是添加一个联系人后从你的app中发送到网站.真实情况你也许还要添加一些登录机制和错误处理,但这超过了本教程的范围.

打开APICommunicatorProtocol.swift;这个协议声明了从服务端获取通讯录和发送通讯录到服务器的两个方法.

你将要传递Person实例,但这需要你使用另一种对象管理.将使用struct.

打开APICommunicator.swift.APICommunicator遵从APICommunicatorProtocol,但现在刚好能够实现编译器happy.

你将创建stubs来支持view controller与APICommunicator的交互.

打开PeopleListViewControllerTests.swift并在PeopleListViewControllerTests类中添加如下类方法:

// 1
class MockAPICommunicator: APICommunicatorProtocol {
  var allPersonInfo = [PersonInfo]()
  var postPersonGotCalled = false
 
  // 2
  func getPeople() -> (NSError?, [PersonInfo]?) {
    return (nil, allPersonInfo)
  }
 
  // 3
  func postPerson(personInfo: PersonInfo) -> NSError? {
    postPersonGotCalled = true
    return nil
  }
}

需要阐明的是:

  1. 虽然APICommunicator是个结构体,模拟实现的却是个类.这种情况最好用一个类,因为测试需要的是可变的数据.在类中会比结构体中好实现.
  2. getPeople()返回存储在allPersonInfo的内容.与从服务器获取下载解析数据不同的是你通过简单的数组来存储通讯录信息.
  3. postPerson(_:)设置postPersonGotCalled为true.

你已经用不到20行的代码创建好了你的"web API"! :]

现在你需要测试你的模拟API来确保从API返回的所有通讯录数据通过调用addPerson()方法添加到了设置的数据存储中.

在PeopleListViewControllerTests中添加如下测试方法:

func testFetchingPeopleFromAPICallsAddPeople() {
  // given
  // 1
  let mockDataProvider = MockDataProvider()
  viewController.dataProvider = mockDataProvider
 
  // 2
  let mockCommunicator = MockAPICommunicator()
  mockCommunicator.allPersonInfo = [PersonInfo(firstName: "firstname", lastName: "lastname", 
    birthday: NSDate())]
  viewController.communicator = mockCommunicator
 
  // when
  viewController.fetchPeopleFromAPI()
 
  // then
  // 3
  XCTAssert(mockDataProvider.addPersonGotCalled, "addPerson should have been called")
}

下面是以上的代码释义:

  1. 首先设置在测试中用的模拟对象mockDataProvider和mockCommunicator.
  2. 然后通过设置一些模拟的通讯录数据并调用fetchPeopleFromAPI()来假设一个网络请求.
  3. 最后测试addPerson(_:)是否被调用.

运行,一切正常.

Girl学iOS100天 第26天

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

推荐阅读更多精彩内容

  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,633评论 18 139
  • 发现 关注 消息 iOS 第三方库、插件、知名博客总结 作者大灰狼的小绵羊哥哥关注 2017.06.26 09:4...
    肇东周阅读 12,066评论 4 62
  • 每天早晨上班我都会路过一个公园,从去年冬天开始我就注意到公园里每天锻炼的大妈中有一个与众不同的身影,那是一位大爷。...
    扒小怪阅读 1,550评论 0 0
  • 知道你不懶,只是想發嗲~ 戇蛋蛋派甜螺哥哥買給你吃咯,肉燥,滷肉,清茶,壽司,刺身,色拉,趕緊回家~……
    帶風走路deFENG阅读 114评论 0 1
  • 第一要推荐的就是贝亲的这款小镊子了,这是干什么的呢?没错,这个就是帮小宝宝拿鼻渣的一款神器,因为小朋友的鼻孔比较小...
    麦片麻麻阅读 201评论 0 0