本文翻譯自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天