本文翻译自Unit Testing Turorial:Mocking Objects
这是文章的下半截.
- Writing Mocks
模拟对象能够使你在应用中测试当某些事情发生时方法是否调用或者属性是否被设定.例如,在PeopleListViewController的viewDidLoad()中,table view设置属性dataProvider.
你将编写一个测试来检查它是否发生.
- 测试的准备
首先,项目做测试前你需要做些充分准备.
选择项目的导航栏,在Birthdays对象下的Build Settings中搜索Defines Module,将其设置为Yes,如下图:
在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")
}
下面为以上代码的做的事情:
- 创建实例MockDataProvider并把其设为view controller的dataProvider属性.
- 在测试之前通过断言设置tableView属性为nil.
- 访问view来触发viewDidLoad().
- 通过断言设置测试类中的tableview属性不为nil并设置view controller中的tableView.
再次点击Product\Test;测试完成后,选择test导航(或者按快捷键Cmd+5).你将看到如下结果:
通过绿色的对号可以看到你的一个模拟测试通过啦! :]
- 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")
}
上面代码做了哪些操作呢?
- 首先你将view controller中的data provider设置为你的模拟data provider实例.
- 继而通过ABPersonCreate()创建通讯录.
- 手动调用代理方法peoplePickerNavigationController(_:didSelectPerson:).通常,手动调用代理方法是个code smell,但对测试来讲也还好啦.
- 最后通过data provider模拟设置为true查看addPersonGotCalled来断言addPerson(_:).
点击测试—你将会全部通过.测试是很简单的事情吧!
但稍等,怎样知道测试正是你想要测试的内容呢?
- Testing Your Tests
一个检测测试真正使一些事情生效的方法是移出这个生效的测试实体.
在PeopleListViewController.swift中的peoplePickerNavigationController(_:didSelectPerson:)下面注释掉:
dataProvider?.addPerson(person)
运行测试;你最后写的测试将会失败.好了—你现在知道你的测试方法真正测试了一些东西了.这是个测试你的测试代码的好方法;你应该测试你最复杂的测试方法来确保他们工作正常.
取消注释使代码保持原来的状态;再次运行测试来确保一切正常.
- 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")
}
下面是以上代码的释义:
- 你首先创建一个MockUserDefaults的实例赋给viewController中的userDefaults;这种做法叫做dependency injection.
- 然后创建一个UISegmentedControl的实例,为这个view controller添加.ValueChanged值来控制事件的发生.
- 最后模拟类的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
下面是以上代码的释义:
- setUp() 在内存中创建一个管理对象.通常Core Data存储在设配的文件系统中.但对于这些测试,你存储在设配的内存中.
- 继而创建一个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
}
}
需要阐明的是:
- 虽然APICommunicator是个结构体,模拟实现的却是个类.这种情况最好用一个类,因为测试需要的是可变的数据.在类中会比结构体中好实现.
- getPeople()返回存储在allPersonInfo的内容.与从服务器获取下载解析数据不同的是你通过简单的数组来存储通讯录信息.
- 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")
}
下面是以上的代码释义:
- 首先设置在测试中用的模拟对象mockDataProvider和mockCommunicator.
- 然后通过设置一些模拟的通讯录数据并调用fetchPeopleFromAPI()来假设一个网络请求.
- 最后测试addPerson(_:)是否被调用.
运行,一切正常.
Girl学iOS100天 第26天