我們不是信奉測試,但它應該幫助我們加快開發進度,並且讓事情變得更有趣。
讓事情保持簡單
測試簡單的事情很簡單,同樣,測試複雜的事會很複雜。就像我們在其他文章中指出的那樣,讓事情保持簡單小巧總是好的。除此之外,它還有利於我們測試。這是件雙贏的事。讓我們來看看測試驅動開發(簡稱 TDD),有些人喜歡它,有些人則不喜歡。我們在這裡不深入討論,只是如果用 TDD,你得在寫程式碼之前先寫好測試。如果有什麼疑問,可以去看看 Wikipedia 上的文章。同時,我們也認為重構和測試可以很好地結合在一起。
測試 UI 部分通常很麻煩,因為它們包含太多活動部件。通常,view controller 需要和大量的 model 和 view 類互動。為了使 view controller 便於測試,我們要讓任務儘量分離。
幸好,我們在更輕量的 view controller 這篇文章中的闡述的技術可以讓測試更加簡單。通常,如果你發現有些地方很難做測試,這就說明你的設計出了問題,你應該重構它。你可以重新參考更輕量的 view controller 這篇文章來獲得一些幫助。總的目標就是有清晰的關注點分離。每個類只做一件事,並且做好。這樣就可以讓你只測試這件事。
記住:測試越多,回報的增長趨勢越慢。首先你應該做簡單的測試。當你覺得滿意時,再加入更多複雜的測試。
Mocking
當你把一個整體拆分成小零件(即更小的類)時,我們可以在每個類中進行測試。但由於我們測試的類會和其他類互動,這裡我們用一個所謂的 mock
或 stub
來繞開它。把 mock
物件看成是一個佔位符,我們測試的類會跟這個佔位符互動,而不是真正的那個物件。這樣,我們就可以針對性地測試,並且保證不依賴於應用程式的其他部分。
在示例程式中,我們有個包含陣列的 data source 需要測試。這個 data source 會在某個時候從 table view 中取出(dequeue)一個 cell。在測試過程中,還沒有 table view,但是我們傳遞一個 mock
table view,這樣即使沒有 table view,也可以測試 data source,就像下面你即將看到的。起初可能有點難以理解,多看幾次後,你就能體會到它的強大和簡單。
Objective-C 中有個用來 mocking 的強大工具叫做 OCMock。它是一個非常成熟的專案,充分利用了 Objective-C 執行時強大的能力和靈活性。它使用了一些很酷的技巧,讓通過 mock 物件來測試變得更加有趣。
本文後面有 data source 測試的例子,它更加詳細地展示了這些技術如何工作在一起。
SenTestKit
我們將要使用的另一個工具是一個測試框架,開發者工具的一部分:Sente 的 SenTestingKit。這個上古神器從 1997 年起就伴隨在 Objective-C 開發者左右,比第一款 iPhone 釋出還早 10 年。現在,它已經整合到 Xcode 中了。SenTestingKit 會執行你的測試。通過 SenTestingKit,你將測試組織在類中。你需要給每一個你想測試的類建立一個測試類,類名以 Testing
結尾,它反應了這個類是幹什麼的。
這些測試類的方法會做具體的測試工作。方法名必須以 test
開頭來作為觸發一個測試執行的條件。還有特殊的 -setUp
和 -tearDown
方法,你可以過載它們來設定各個測試。記住,你的測試類就是個類而已:只要對你有幫助,隨便在裡面加 properties 和輔助方法。
做測試時,為測試類建立基類是個不錯的模式。把通用的邏輯放到基類裡面,可以讓測試更簡單和集中。可以通過示例程式中的例子來看看這樣帶來的好處。我們沒有使用 Xcode 的測試模板,為了讓事情簡單有效,我們只建立了單獨的 .m
檔案。通過把類名改成以 Tests
結尾,類名可以反映出我們在對什麼做測試。
與 Xcode 整合
測試會被 build 成一個 bundle,其中包含一個動態庫和你選擇的資原始檔。如果你要測試某些資原始檔,你得把它們加到測試的 target 中,Xcode 就會將它們打包到一個 bundle 中。接著你可以通過 NSBundle 來定位這些資原始檔,示例專案實現了一個 -URLForResource:withExtension:
方法來方便的使用它。
Xcode 中的每個 scheme
定義了相應的測試 bundle 是哪個。通過 ⌘-R 執行程式,⌘-U 執行測試。
測試的執行依附於程式的執行,當程式執行時,測試 bundle 將被注入(injected
)。測試時,你可能不想讓你的程式做太多的事,那樣會對測試造成干擾。可以把下面的程式碼加到 app delegate 中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
static BOOL isRunningTests(void) __attribute__((const)); - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { if (isRunningTests()) { return YES; } // // Normal logic goes here // return YES; } static BOOL isRunningTests(void) { NSDictionary* environment = [[NSProcessInfo processInfo] environment]; NSString* injectBundle = environment[@"XCInjectBundle"]; return [[injectBundle pathExtension] isEqualToString:@"octest"]; } |
編輯 Scheme 給了你極大的靈活性。你可以在測試之前或之後執行指令碼,也可以有多個測試 bundle。這對大型專案來說很有用。最重要的是,可以開啟或關閉個別測試,這對除錯測試非常有用,只是要記得把它們重新全部開啟。
還要記住你可以為測試程式碼下斷點,當測試執行時,偵錯程式會在斷點處停下來。
測試 Data Source
好了,讓我們開始吧。我們已經通過拆分 view controller 讓測試工作變得更輕鬆了。現在我們要測試 ArrayDataSource
。首先我們新建一個空的,基本的測試類。我們把介面和實現都放到一個檔案裡;也沒有哪個地方需要包含 @interface
,放到一個檔案會顯得更加漂亮和整潔。
1 2 3 4 5 6 7 8 9 10 11 |
#import "PhotoDataTestCase.h" @interface ArrayDataSourceTest : PhotoDataTestCase @end @implementation ArrayDataSourceTest - (void)testNothing; { STAssertTrue(YES, @""); } @end |
這個類沒做什麼事,只是展示了基本的設定。當我們執行這個測試時,-testNothing
方法將會執行。特別地,STAssert
巨集將會做瑣碎的檢查。注意,字首 ST
源自於 SenTestingKit
。這些巨集和 Xcode 整合,會把失敗顯示到 Issues navigator 中。
第一個測試
我們現在把 testNothing
替換成一個簡單、真正的測試:
1 2 3 4 5 6 7 8 9 |
- (void)testInitializing; { STAssertNil([[ArrayDataSource alloc] init], @"Should not be allowed."); TableViewCellConfigureBlock block = ^(UITableViewCell *a, id b){}; id obj1 = [[ArrayDataSource alloc] initWithItems:@[] cellIdentifier:@"foo" configureCellBlock:block]; STAssertNotNil(obj1, @""); } |
實踐 Mocking
接著,我們想測試 ArrayDataSource
實現的方法:
1 2 |
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath; |
為此,我們建立一個測試方法:
1 |
- (void)testCellConfiguration; |
首先,建立一個 data source:
1 2 3 4 5 6 7 8 9 |
__block UITableViewCell *configuredCell = nil; __block id configuredObject = nil; TableViewCellConfigureBlock block = ^(UITableViewCell *a, id b){ configuredCell = a; configuredObject = b; }; ArrayDataSource *dataSource = [[ArrayDataSource alloc] initWithItems:@[@"a", @"b"] cellIdentifier:@"foo" configureCellBlock:block]; |
注意,configureCellBlock
除了儲存物件以外什麼都沒做,這可以讓我們可以更簡單地測試它。
然後,我們為 table view 建立一個 mock 物件:
1 |
id mockTableView = [OCMockObject mockForClass:[UITableView class]]; |
Data source 將在傳進來的 table view 上呼叫 -dequeueReusableCellWithIdentifier:forIndexPath:
方法。我們將告訴 mock object 當它收到這個訊息時要做什麼。首先建立一個 cell,然後設定 mock。
1 2 3 4 5 |
UITableViewCell *cell = [[UITableViewCell alloc] init]; NSIndexPath* indexPath = [NSIndexPath indexPathForRow:0 inSection:0]; [[[mockTableView expect] andReturn:cell] dequeueReusableCellWithIdentifier:@"foo" forIndexPath:indexPath]; |
第一次看到它可能會覺得有點迷惑。我們在這裡所做的,是讓 mock 記錄特定的呼叫。Mock 不是一個真正的 table view;我們只是假裝它是。-expect
方法允許我們設定一個 mock,讓它知道當這個方法呼叫時要做什麼。
另外,-expect
方法也告訴 mock 這個呼叫必須發生。當我們稍後在 mock 上呼叫 -verify
時,如果那個方法沒有被呼叫過,測試就會失敗。相應地,-stub
方法也用來設定 mock 物件,但它不關心方法是否被呼叫過。
現在,我們要觸發程式碼執行。我們就呼叫我們希望測試的方法。
1 2 3 |
NSIndexPath* indexPath = [NSIndexPath indexPathForRow:0 inSection:0]; id result = [dataSource tableView:mockTableView cellForRowAtIndexPath:indexPath]; |
然後我們測試是否一切正常:
1 2 3 4 |
STAssertEquals(result, cell, @"Should return the dummy cell."); STAssertEquals(configuredCell, cell, @"This should have been passed to the block."); STAssertEqualObjects(configuredObject, @"a", @"This should have been passed to the block."); [mockTableView verify]; |
STAssert
巨集測試值的相等性。注意,前兩個測試,我們通過比較指標來完成;我們不想使用 -isEqual:
。我們實際希望測試的是 result
,cell
和 configuredCell
都是同一個物件。第三個測試要用 -isEqual:
,最後我們呼叫 mock 的 -verify
方法。
注意,在示例程式中,我們是這樣設定 mock 的:
1 |
id mockTableView = [self autoVerifiedMockForClass:[UITableView class]]; |
這是我們測試基類中的一個方便的封裝,它會在測試最後自動呼叫 -verify
方法。
測試 UITableViewController
下面,我們轉向 PhotosViewController。它是個 UITableViewController
的子類,它使用了我們剛才測試過的 data source。View controller 剩下的程式碼已經相當簡單了。
我們想測試點選 cell 後把我們帶到詳情頁面,即一個 PhotoViewController
的例項被 push 到 navigation controller 裡面。我們再次使用 mocking 來讓測試儘可能不依賴於其他部分。
首先我們建立一個 UINavigationController
的 mock:
1 |
id mockNavController = [OCMockObject mockForClass:[UINavigationController class]]; |
接下來,我們要使用 partial mocking。我們希望 PhotosViewController
例項的 navigationController
返回 mockNavController
。我們不能直接設定 navigation controller,所以我們簡單地對 PhotosViewController
例項 stub 這個方法,讓它返回 mockNavController
就可以了。
1 2 3 |
PhotosViewController *photosViewController = [[PhotosViewController alloc] init]; id photosViewControllerMock = [OCMockObject partialMockForObject:photosViewController]; [[[photosViewControllerMock stub] andReturn:mockNavController] navigationController]; |
現在,任何時候對 photosViewController
呼叫 -navigationController
方法,都會返回 mockNavController
。這是個強大的技巧,OCMock 有這種本領。
現在,我們要告訴 navigation controller mock 我們呼叫的期望,即,一個 photo 不為 nil 的 detail view controller。
1 2 3 4 5 6 |
UIViewController* viewController = [OCMArg checkWithBlock:^BOOL(id obj) { PhotoViewController *vc = obj; return ([vc isKindOfClass:[PhotoViewController class]] && (vc.photo != nil)); }]; [[mockNavController expect] pushViewController:viewController animated:YES]; |
現在,我們觸發 view 載入,並且模擬一行被點選:
1 2 3 4 5 |
UIView *view = photosViewController.view; STAssertNotNil(view, @""); NSIndexPath* indexPath = [NSIndexPath indexPathForRow:0 inSection:0]; [photosViewController tableView:photosViewController.tableView didSelectRowAtIndexPath:indexPath]; |
最後我們驗證 mocks 上期望的方法被呼叫過:
1 2 |
[mockNavController verify]; [photosViewControllerMock verify]; |
現在我們有了一個測試,用來測試和 navigation controller 的互動,以及正確 view controller 的建立。
又一次地,我們在示例程式中使用了便捷的方法:
1 2 |
- (id)autoVerifiedMockForClass:(Class)aClass; - (id)autoVerifiedPartialMockForObject:(id)object; |
於是,我們不需要記住呼叫 -verify
。
進一步探索
就像你從上面看到的那樣,partial mocking 非常強大。如果你看看 -[PhotosViewController setupTableView]
方法的原始碼,你就會看到它是如何從 app delegate 中取出 model 物件的。
1 |
NSArray *photos = [AppDelegate sharedDelegate].store.sortedPhotos; |
上面的測試依賴於這行程式碼。打破這種依賴的一種方式是再次使用 partial mocking,讓 app delegate 返回預定義的資料,就像這樣:
1 2 3 4 |
id storeMock; // assume we've set this up id appDelegate = [AppDelegate sharedDelegate] id appDelegateMock = [OCMockObject partialMockForObject:appDelegate]; [[[appDelegateMock stub] andReturn:storeMock] store]; |
現在,無論 [AppDelegate sharedDelegate].store
時候呼叫過,它也會返回 storeMock
。可以把它發揮到極致。確保讓你的測試儘可能保持簡單,除非確實有複雜的需要。
牢記的事
Partial mocks 會修改 mocking 的物件,並且在 mocks 的生存期一直有效。你可以通過提前呼叫 [aMock stopMocking]
來停止這種行為。大多數時候,你希望 partial mock 在整個測試期間都保持有效。確保在測試方法最後放置 [aMock verify]
。否則 ARC 會過早 dealloc 這個 mock。而且不管怎樣,你都希望加上 -verify
。
測試 NIB 載入
PhotoCell
設定在一個 NIB 中,我們可以寫一個簡單的測試來檢查 outlets 設定得是否正確。我們來回顧一下 PhotoCell
類:
1 2 3 4 5 6 7 8 |
@interface PhotoCell : UITableViewCell + (UINib *)nib; @property (weak, nonatomic) IBOutlet UILabel* photoTitleLabel; @property (weak, nonatomic) IBOutlet UILabel* photoDateLabel; @end |
我們的簡單測試的實現看上去是這樣:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
@implementation PhotoCellTests - (void)testNibLoading; { UINib *nib = [PhotoCell nib]; STAssertNotNil(nib, @""); NSArray *a = [nib instantiateWithOwner:nil options:@{}]; STAssertEquals([a count], (NSUInteger) 1, @""); PhotoCell *cell = a[0]; STAssertTrue([cell isMemberOfClass:[PhotoCell class]], @""); // Check that outlets are set up correctly: STAssertNotNil(cell.photoTitleLabel, @""); STAssertNotNil(cell.photoDateLabel, @""); } @end |
非常基礎,但是它能工作。
值得一提的是,當有什麼發生變動時,測試和相應的類或 nib 需要同時更新。這是事實。你需要把它和 outlets 變化的可能性做權衡。如果你用了 .xib
檔案,你可能要注意了,這是經常發生的事。
關於 Class 和 Injection
我們已經從與 Xcode 整合得知,測試 bundle 會注入到應用程式中。省略注入的如何工作的細節(它本身是個巨大的話題),簡單地說:注入是把待注入的 bundle(我們的測試 bundle)中的 Objective-C 類新增到執行的應用程式中。這很好,因為這樣允許我們執行測試了。
還有一件事會很讓人迷惑,那就是如果我們同時把一個類新增到應用程式和測試 bundle中。如果在上面的示例程式中,(偶然)把 PhotoCell
類新增到測試 bundle 和應用程式,然後在測試 bundle 中呼叫 [PhotoCell class]
會返回一個不同的指標(你應用程式中的那個類)。於是我們的測試將會失敗:
1 |
STAssertTrue([cell isMemberOfClass:[PhotoCell class]], @""); |
再一次宣告:注入很複雜。你應該避免:不要把應用程式中的 .m
檔案新增到測試 target 中。否則你會得到預想不到的行為。
額外的思考
如果你使用一個持續整合的解決方案,讓你的測試啟動和執行是一個好主意。詳細的描述超過了本文的範圍。這些指令碼通過 RunUnitTests
指令碼觸發。還有個 TEST_AFTER_BUILD
環境變數。
一個有趣的選擇是建立單獨的測試 bundle 來自動化效能測試。你可以在測試方法裡做任何你想做的。定時呼叫一些方法並使用 STAssert
來檢查它們是否在特定閾值裡面是一種選擇。
擴充套件閱讀
- Test-driven development
- OCMock
- Xcode Unit Testing Guide
- Book: Test Driven Development: By Example
- Blog: Quality Coding
- Blog: iOS Unit Testing
- Blog: Secure Mac Programing
Daniel Eggert, 2013 年 6 月