本文翻譯自Unit Testing Turorial:Mocking Objects
這是文章的下半截.
- Writing Mocks
模擬對象能夠使你在應(yīng)用中測試當(dāng)某些事情發(fā)生時(shí)方法是否調(diào)用或者屬性是否被設(shè)定.例如,在PeopleListViewController的viewDidLoad()中,table view設(shè)置屬性dataProvider.
你將編寫一個(gè)測試來檢查它是否發(fā)生.
- 測試的準(zhǔn)備
首先,項(xiàng)目做測試前你需要做些充分準(zhǔn)備.
選擇項(xiàng)目的導(dǎo)航欄,在Birthdays對象下的Build Settings中搜索Defines Module,將其設(shè)置為Yes,如下圖:
在BirthdaysTest文件夾里以Test Case Class為模板添加名為PeopleListViewControllerTests的Swift文件.
如果Xcode讓你選擇是否創(chuàng)建橋接文件,選No.這是Xcode的一個(gè)bug.
打開新創(chuàng)建的PeopleListViewControllerTests.swift文件.確保你在其他導(dǎo)入文件下面導(dǎo)入了Birthdays,效果如下:
import UIKit
import XCTest
import Birthdays
刪除下面的兩個(gè)方法:
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.
}
}
你現(xiàn)在需要一個(gè)PeopleListViewController實(shí)例來進(jìn)行測試.
在PeopleListViewControllerTests的開頭添加如下的代碼:
var viewController: PeopleListViewController!
替換setUp()里的代碼:
override func setUp() {
super.setUp()
viewController = UIStoryboard(name: "Main", bundle: nil).instantiateViewControllerWithIdentifier("PeopleListViewController") as! PeopleListViewController
}
這個(gè)方法用main storyboard來創(chuàng)建一個(gè)PeopleListViewController的實(shí)例并把它賦給viewController.
點(diǎn)擊Product\Test;Xcode會運(yùn)行項(xiàng)目中已有的所有測試方法.雖然現(xiàn)在你并沒有任何測試代碼,但它能夠確保目前為止一切都是正常的.不一會,Xcode會報(bào)告所有測試都是成功的.
你現(xiàn)在可以創(chuàng)建你的第一個(gè)mock了.
- 編寫你的首個(gè)Mock
你正在使用Core Data,在PeopleListViewControllerTests.swift里面導(dǎo)入:
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()
}
}
這看起來像個(gè)比較復(fù)雜的mock類.然而,這僅是最基礎(chǔ)的需要,設(shè)定一個(gè)PeopleListViewController中dataProvider屬性的模擬類實(shí)例.你的模擬類也遵從PeopleListDataProviderProtocol和UITableViewDataSource協(xié)議.
點(diǎn)擊Product\Test;你的項(xiàng)目會再次運(yùn)行且你的0個(gè)測試函數(shù)會有0個(gè)失敗.但這并不意味著通過率100%. :] 但現(xiàn)在你已經(jīng)為你的第一單元測試做好了準(zhǔn)備.
單元測試中好的做法是將其分為given,when和then三個(gè)部分.'Given'設(shè)置測試環(huán)境條件;'when'運(yùn)行你想測試的代碼;'then'檢查是否得到預(yù)期的結(jié)果.
你的測試將在viewDidload()運(yùn)行之后檢查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")
}
下面為以上代碼的做的事情:
- 創(chuàng)建實(shí)例MockDataProvider并把其設(shè)為view controller的dataProvider屬性.
- 在測試之前通過斷言設(shè)置tableView屬性為nil.
- 訪問view來觸發(fā)viewDidLoad().
- 通過斷言設(shè)置測試類中的tableview屬性不為nil并設(shè)置view controller中的tableView.
再次點(diǎn)擊Product\Test;測試完成后,選擇test導(dǎo)航(或者按快捷鍵Cmd+5).你將看到如下結(jié)果:
通過綠色的對號可以看到你的一個(gè)模擬測試通過啦! :]
- Testing addPerson(_:)
接下來要通過調(diào)用data provider中的addPerson(_:)來測試下通訊錄選擇.
在MockDataProvider類中增加如下屬性:
var addPersonGotCalled = false
修改addPerson(_:):
func addPerson(personInfo: PersonInfo) { addPersonGotCalled = true }
此時(shí),當(dāng)你調(diào)用addPerson(_:)時(shí),會在實(shí)例MockDataProvider中設(shè)置addPersonGotCalled為true.
在進(jìn)行測試之前你需要導(dǎo)入AddressBookUI框架.
在PeopleListViewControllerTests.swift導(dǎo)入:
import AddressBookUI
現(xiàn)在添加如下測試代碼:
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設(shè)置為你的模擬data provider實(shí)例.
- 繼而通過ABPersonCreate()創(chuàng)建通訊錄.
- 手動調(diào)用代理方法peoplePickerNavigationController(_:didSelectPerson:).通常,手動調(diào)用代理方法是個(gè)code smell,但對測試來講也還好啦.
- 最后通過data provider模擬設(shè)置為true查看addPersonGotCalled來斷言addPerson(_:).
點(diǎn)擊測試—你將會全部通過.測試是很簡單的事情吧戈稿!
但稍等,怎樣知道測試正是你想要測試的內(nèi)容呢望浩?
- Testing Your Tests
一個(gè)檢測測試真正使一些事情生效的方法是移出這個(gè)生效的測試實(shí)體.
在PeopleListViewController.swift中的peoplePickerNavigationController(_:didSelectPerson:)下面注釋掉:
dataProvider?.addPerson(person)
運(yùn)行測試;你最后寫的測試將會失敗.好了—你現(xiàn)在知道你的測試方法真正測試了一些東西了.這是個(gè)測試你的測試代碼的好方法;你應(yīng)該測試你最復(fù)雜的測試方法來確保他們工作正常.
取消注釋使代碼保持原來的狀態(tài);再次運(yùn)行測試來確保一切正常.
- Mocking Apple Framework Classes
你也許用過單例,例如NSNotificationCenter.defaultCenter()和NSUserDefaults.standardUserDefaults().但你如何來測試一個(gè)notification是否真正發(fā)送或者一個(gè)default被設(shè)置了?蘋果不允許你測試這些類的狀態(tài).
你可以添加一個(gè)想要的notifications觀察測試類.但這也許會使你的測試變得非常慢且實(shí)現(xiàn)這些類變得不可靠.Notification還可能從你的其他代碼處被觸發(fā),使測試變得不再是單獨(dú)的行為了.
想要打破這些限制,你可以在這些單例的地方使用mocks.
運(yùn)行程序;在人員列表中和切換姓和名的分類中添加John Appleseed和David Taylor.你會發(fā)現(xiàn)通訊錄的列表是按順序排列的.
代碼中是通過PeopleListViewController.swift中的changeSort()來實(shí)現(xiàn)的.
@IBAction func changeSorting(sender: UISegmentedControl) {
userDefaults.setInteger(sender.selectedSegmentIndex, forKey: "sort")
dataProvider?.fetch()
}
通過user defaults存儲的sort key來進(jìn)行選擇并調(diào)用data provider的方法fetch(). fetch()會讀取你存儲在user default中的新的排序關(guān)鍵字并且刷新通訊錄列表,在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中解析數(shù)據(jù).為了改變列表的順序,fetch()創(chuàng)建一個(gè)排序后的數(shù)組并把它賦給取數(shù)據(jù)的請求中來獲取結(jié)果.然后將數(shù)據(jù)傳到列表中進(jìn)行刷新.
你現(xiàn)在增加了一個(gè)測試用戶選擇存儲在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的子類;它有一個(gè)默認(rèn)為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")
}
下面是以上代碼的釋義:
- 你首先創(chuàng)建一個(gè)MockUserDefaults的實(shí)例賦給viewController中的userDefaults;這種做法叫做dependency injection.
- 然后創(chuàng)建一個(gè)UISegmentedControl的實(shí)例,為這個(gè)view controller添加.ValueChanged值來控制事件的發(fā)生.
- 最后模擬類的user defaults中的斷言setImage(_:forKey:)被調(diào)用.
運(yùn)行你的測試代碼—將會全部通過.
如果你的應(yīng)用有非常復(fù)雜的API或框架,但你只想測試其中一個(gè)非常小的特性時(shí),如何做呢存筏?
"face"該登場了! :]
- 編寫Fakes
Fakes像一個(gè)它偽造的全功能的類.利用它可以當(dāng)做替代類或者處理測試中過于復(fù)雜的結(jié)構(gòu)體.
在例子中,你并不想在測試時(shí)給真實(shí)的Core Data數(shù)據(jù)庫中添加或讀取數(shù)據(jù).因此,你要fake Core Data數(shù)據(jù)存儲.
添加新的測試類PeopleListDataProviderTests.
在新類中刪除下面的示例測試:
func testExample() {
// ...
}
func testPerformanceExample() {
// ...
}
在類中導(dǎo)入:
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() 在內(nèi)存中創(chuàng)建一個(gè)管理對象.通常Core Data存儲在設(shè)配的文件系統(tǒng)中.但對于這些測試,你存儲在設(shè)配的內(nèi)存中.
- 繼而創(chuàng)建一個(gè)PeopleListDataProvider的實(shí)例和將存儲在內(nèi)存中的管理對象設(shè)置為它的managedObjectContext.意味著你的新data provider將會和真實(shí)情況效果一樣,但不會在應(yīng)用中真實(shí)的添加刪除對象.
在PeopleListDataProviderTests中添加下面兩個(gè)屬性:
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 的設(shè)置并創(chuàng)建將在測試中用到的PersonInfo.
但測試結(jié)束時(shí),你將清除這些數(shù)據(jù)對象.
將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設(shè)為nil用來清除內(nèi)存中存儲的數(shù)據(jù).這是要做的基本工作.你需要在進(jìn)行下個(gè)測試之前有個(gè)干凈的存儲空間.
現(xiàn)在開始編寫真正的測試文件了!在你的測試類中添加:
func testThatStoreIsSetUp() {
XCTAssertNotNil(store, "no persistent store")
}
這將測試存儲的是否為nil.將檢查存儲沒有被創(chuàng)建的失敗情況.
運(yùn)行你的測試,一切正常.
下面將測試數(shù)據(jù)是否是想要的行數(shù).
在測試類中添加如下測試:
func testOnePersonInThePersistantStoreResultsInOneRow() {
dataProvider.addPerson(testRecord)
XCTAssertEqual(tableView.dataSource!.tableView(tableView, numberOfRowsInSection: 0), 1,
"After adding one person number of rows is not 1")
}
首先,在測試存儲中添加一個(gè)通訊錄,然后斷言行數(shù)是否等于1.運(yùn)行測試,將會測試成功.
通過創(chuàng)建一個(gè)fake "persistent"存儲來避免寫入磁盤,能夠快速測試并使你的磁盤保持干凈,同時(shí)能夠使你運(yùn)行程序時(shí)更加自信,一切都如設(shè)想般運(yùn)行.
實(shí)際的測試中,你還可以測試多個(gè)sections和rows,主要取決你對項(xiàng)目的想要達(dá)到的自信程度.
如果你曾經(jīng)在一個(gè)項(xiàng)目的多個(gè)團(tuán)隊(duì)里,將會知道并不是項(xiàng)目的所有部分都會在同一時(shí)間準(zhǔn)備好,但你仍需要測試你的代碼.在服務(wù)器還沒準(zhǔn)備好時(shí),你怎么才能測試你的代碼呢?
Stubs登場了! :]
- 編寫Stubs
Stubs假設(shè)一個(gè)方法對象的返回值.你將用stubs來測試在web 服務(wù)器還沒有完成的情況下的你的代碼.
Web組要為你的項(xiàng)目建設(shè)一個(gè)和app功能相同的網(wǎng)站.用戶通過該網(wǎng)站注冊的賬號可以同步到app端.但Web組甚至還沒有開始,你卻已經(jīng)接近完成了.這時(shí)候你需要寫個(gè)stub來模擬服務(wù)器.
本章將專注兩種測試方法:一種是解析通訊錄添加到網(wǎng)站,另一種是添加一個(gè)聯(lián)系人后從你的app中發(fā)送到網(wǎng)站.真實(shí)情況你也許還要添加一些登錄機(jī)制和錯(cuò)誤處理,但這超過了本教程的范圍.
打開APICommunicatorProtocol.swift;這個(gè)協(xié)議聲明了從服務(wù)端獲取通訊錄和發(fā)送通訊錄到服務(wù)器的兩個(gè)方法.
你將要傳遞Person實(shí)例,但這需要你使用另一種對象管理.將使用struct.
打開APICommunicator.swift.APICommunicator遵從APICommunicatorProtocol,但現(xiàn)在剛好能夠?qū)崿F(xiàn)編譯器happy.
你將創(chuàng)建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是個(gè)結(jié)構(gòu)體,模擬實(shí)現(xiàn)的卻是個(gè)類.這種情況最好用一個(gè)類,因?yàn)闇y試需要的是可變的數(shù)據(jù).在類中會比結(jié)構(gòu)體中好實(shí)現(xiàn).
- getPeople()返回存儲在allPersonInfo的內(nèi)容.與從服務(wù)器獲取下載解析數(shù)據(jù)不同的是你通過簡單的數(shù)組來存儲通訊錄信息.
- postPerson(_:)設(shè)置postPersonGotCalled為true.
你已經(jīng)用不到20行的代碼創(chuàng)建好了你的"web API"! :]
現(xiàn)在你需要測試你的模擬API來確保從API返回的所有通訊錄數(shù)據(jù)通過調(diào)用addPerson()方法添加到了設(shè)置的數(shù)據(jù)存儲中.
在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")
}
下面是以上的代碼釋義:
- 首先設(shè)置在測試中用的模擬對象mockDataProvider和mockCommunicator.
- 然后通過設(shè)置一些模擬的通訊錄數(shù)據(jù)并調(diào)用fetchPeopleFromAPI()來假設(shè)一個(gè)網(wǎng)絡(luò)請求.
- 最后測試addPerson(_:)是否被調(diào)用.
運(yùn)行,一切正常.
Girl學(xué)iOS100天 第26天