我們不是迷信測試,但它應(yīng)該幫助我們加快開發(fā)進(jìn)度,并且讓事情變得更有趣。
測試簡單的事情很簡單,同樣,測試復(fù)雜的事會很復(fù)雜。就像我們在其他文章中指出的那樣,讓事情保持簡單小巧總是好的。除此之外,它還有利于我們測試。這是件雙贏的事。讓我們來看看測試驅(qū)動開發(fā)(簡稱 TDD),有些人喜歡它,有些人則不喜歡。我們在這里不深入討論,只是如果用 TDD,你得在寫代碼之前先寫好測試。如果你好奇的話,可以去找 Wikipedia 上的文章看看。同時,我們也認(rèn)為重構(gòu)和測試可以很好地結(jié)合在一起。
測試 UI 部分通常很麻煩,因為它們包含太多活動部件。通常,view controller 需要和大量的 model 和 view 類交互。為了使 view controller 便于測試,我們要讓任務(wù)盡量分離。
幸好,我們在更輕量的 view controller 這篇文章中的闡述的技術(shù)可以讓測試更加簡單。通常,如果你發(fā)現(xiàn)有些地方很難做測試,這就說明你的設(shè)計出了問題,你應(yīng)該重構(gòu)它。你可以重新參考更輕量的 view controller 這篇文章來獲得一些幫助??偟哪繕?biāo)就是有清晰的關(guān)注點分離。每個類只做一件事,并且做好。這樣就可以讓你只測試這件事。
記住:測試越多,回報的增長趨勢越慢。首先你應(yīng)該做簡單的測試。當(dāng)你覺得滿意時,再加入更多復(fù)雜的測試。
當(dāng)你把一個整體拆分成小零件(比如更小的類)時,我們可以針對每個小的類來進(jìn)行測試。但由于我們測試的類會和其他類交互,這里我們用一個所謂的 mock
或 stub
來繞開它。把 mock
對象看成是一個占位符,我們測試的類會跟這個占位符交互,而不是真正的那個對象。這樣,我們就可以針對性地測試,并且保證不依賴于應(yīng)用程序的其他部分。
在示例程序中,我們有個包含數(shù)組的 data source 需要測試。這個 data source 會在某個時候從 table view 中取出(dequeue)一個 cell。在測試過程中,還沒有 table view,但是我們傳遞一個 mock
的 table view,這樣即使沒有 table view,也可以測試 data source,就像下面你即將看到的。起初可能有點難以理解,多看幾次后,你就能體會到它的強(qiáng)大和簡單。
Objective-C 中有個用來 mocking 的強(qiáng)大工具叫做 OCMock。它是一個非常成熟的項目,充分利用了 Objective-C 運行時強(qiáng)大的能力和靈活性。它使用了一些很酷的技巧,讓通過 mock 對象來測試變得更加有趣。
本文后面有 data source 測試的例子,它更加詳細(xì)地展示了這些技術(shù)如何工作在一起。
編者注 這一節(jié)有一些過時了。在 Xcode 5 中 SenTestingKit 已經(jīng)被 XCTest 完全取代,不過兩者使用上沒有太多區(qū)別,我們可以通過 Xcode 的
Edit
->Refactor
->Convert to XCTest
選項來切換到新的測試框架
我們將要使用的另一個工具是一個測試框架,開發(fā)者工具的一部分:Sente 的 SenTestingKit。這個上古神器從 1997 年起就伴隨在 Objective-C 開發(fā)者左右,比第一款 iPhone 發(fā)布還早 10 年。現(xiàn)在,它已經(jīng)集成到 Xcode 中了。SenTestingKit 會運行你的測試。通過 SenTestingKit,你將測試組織在類中。你需要給每一個你想測試的類創(chuàng)建一個測試類,類名以 Tests
結(jié)尾,它反應(yīng)了這個類是干什么的。
這些測試類里的方法會做具體的測試工作。方法名必須以 test
開頭來作為觸發(fā)一個測試運行的條件。還有特殊的 -setUp
和 -tearDown
方法,你可以重載它們來設(shè)置各個測試。記住,你的測試類就是個類而已:只要對你有幫助,可以按需求在里面加 properties 和輔助方法。
做測試時,為測試類創(chuàng)建基類是個不錯的模式。把通用的邏輯放到基類里面,可以讓測試更簡單和集中??梢酝ㄟ^示例程序中的例子來看看這樣帶來的好處。我們沒有使用 Xcode 的測試模板,為了讓事情簡單有效,我們只創(chuàng)建了單獨的 .m
文件。通過把類名改成以 Tests
結(jié)尾,類名可以反映出我們在對什么做測試。
編者注 Xcode 5 中 默認(rèn)的測試模板也不再會自動創(chuàng)建
.h
文件了
測試會被 build 成一個 bundle,其中包含一個動態(tài)庫和你選擇的資源文件。如果你要測試某些資源文件,你得把它們加到測試的 target 中,Xcode 就會將它們打包到一個 bundle 中。接著你可以通過 NSBundle 來定位這些資源文件,示例項目實現(xiàn)了一個 -URLForResource:withExtension:
方法來方便的使用它。
Xcode 中的每個 scheme
定義了相應(yīng)的測試 bundle 是哪個。通過 ?-R 運行程序,?-U 運行測試。
測試的運行依附于程序的運行,當(dāng)程序運行時,測試 bundle 將被注入(injected
)。測試時,你可能不想讓你的程序做太多的事,那樣會對測試造成干擾。可以把下面的代碼加到 app delegate 中:
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。這對大型項目來說很有用。最重要的是,可以打開或關(guān)閉個別測試,這對調(diào)試測試非常有用,只是要記得之后再把它們重新全部打開。
還要記住你可以為測試代碼下斷點,當(dāng)測試執(zhí)行時,調(diào)試器會在斷點處停下來。
好了,讓我們開始吧。我們已經(jīng)通過拆分 view controller 讓測試工作變得更輕松了?,F(xiàn)在我們要測試 ArrayDataSource
。首先我們新建一個空的,基本的測試類。我們把接口和實現(xiàn)都放到一個文件里;也沒有哪個地方需要包含 @interface
,放到一個文件會顯得更加漂亮和整潔。
#import "PhotoDataTestCase.h"
@interface ArrayDataSourceTest : PhotoDataTestCase
@end
@implementation ArrayDataSourceTest
- (void)testNothing;
{
STAssertTrue(YES, @"");
}
@end
這個類沒做什么事,只是展示了基本的設(shè)置。當(dāng)我們運行這個測試時,-testNothing
方法將會運行。特別地,STAssert
宏將會做瑣碎的檢查。注意,前綴 ST
源自于 SenTestingKit
。這些宏和 Xcode 集成,會把失敗顯示到側(cè)邊面板的 Issues 導(dǎo)航欄中。
我們現(xiàn)在把 testNothing
替換成一個簡單、真正的測試:
- (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, @"");
}
接著,我們想測試 ArrayDataSource
實現(xiàn)的方法:
- (UITableViewCell *)tableView:(UITableView *)tableView
cellForRowAtIndexPath:(NSIndexPath *)indexPath;
為此,我們創(chuàng)建一個測試方法:
- (void)testCellConfiguration;
首先,創(chuàng)建一個 data source:
__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
除了存儲對象以外什么都沒做,這可以讓我們可以更簡單地測試它。
然后,我們?yōu)?table view 創(chuàng)建一個 mock 對象:
id mockTableView = [OCMockObject mockForClass:[UITableView class]];
Data source 將在傳進(jìn)來的 table view 上調(diào)用 -dequeueReusableCellWithIdentifier:forIndexPath:
方法。我們將告訴 mock object 當(dāng)它收到這個消息時要做什么。首先創(chuàng)建一個 cell,然后設(shè)置 mock。
UITableViewCell *cell = [[UITableViewCell alloc] init];
NSIndexPath* indexPath = [NSIndexPath indexPathForRow:0 inSection:0];
[[[mockTableView expect] andReturn:cell]
dequeueReusableCellWithIdentifier:@"foo"
forIndexPath:indexPath];
第一次看到它可能會覺得有點迷惑。我們在這里所做的,是讓 mock 記錄特定的調(diào)用。Mock 不是一個真正的 table view;我們只是假裝它是。-expect
方法允許我們設(shè)置一個 mock,讓它知道當(dāng)這個方法調(diào)用時要做什么。
另外,-expect
方法也告訴 mock 這個調(diào)用必須發(fā)生。當(dāng)我們稍后在 mock 上調(diào)用 -verify
時,如果那個方法沒有被調(diào)用過,測試就會失敗。相應(yīng)地,-stub
方法也用來設(shè)置 mock 對象,但它不關(guān)心方法是否被調(diào)用過。
現(xiàn)在,我們要觸發(fā)代碼運行。我們就調(diào)用我們希望測試的方法。
NSIndexPath* indexPath = [NSIndexPath indexPathForRow:0 inSection:0];
id result = [dataSource tableView:mockTableView
cellForRowAtIndexPath:indexPath];
然后我們測試是否一切正常:
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:
,最后我們調(diào)用 mock 的 -verify
方法。
注意,在示例程序中,我們是這樣設(shè)置 mock 的:
id mockTableView = [self autoVerifiedMockForClass:[UITableView class]];
這是我們測試基類中的一個方便的封裝,它會在測試最后自動調(diào)用 -verify
方法。
下面,我們轉(zhuǎn)向 PhotosViewController
。它是個 UITableViewController
的子類,它使用了我們剛才測試過的 data source。View controller 剩下的代碼已經(jīng)相當(dāng)簡單了。
我們想測試點擊 cell 后把我們帶到詳情頁面,即一個 PhotoViewController
的實例被 push 到 navigation controller 里面。我們再次使用 mocking 來讓測試盡可能不依賴于其他部分。
首先我們創(chuàng)建一個 UINavigationController
的 mock:
id mockNavController = [OCMockObject mockForClass:[UINavigationController class]];
接下來,我們要使用部分 mocking。我們希望 PhotosViewController
實例的 navigationController
返回 mockNavController
。我們不能直接設(shè)置 navigation controller,所以我們簡單地用 stub 來替換掉 PhotosViewController
實例這個方法,讓它返回 mockNavController
就可以了。
PhotosViewController *photosViewController = [[PhotosViewController alloc] init];
id photosViewControllerMock = [OCMockObject partialMockForObject:photosViewController];
[[[photosViewControllerMock stub] andReturn:mockNavController] navigationController];
現(xiàn)在,任何時候?qū)?photosViewController
調(diào)用 -navigationController
方法,都會返回 mockNavController
。這是個強(qiáng)大的技巧,OCMock 就有這樣的本領(lǐng)。
接下來,我們要告訴 navigation controller mock 我們調(diào)用的期望,即,一個 photo 不為 nil 的 detail view controller。
UIViewController* viewController = [OCMArg checkWithBlock:^BOOL(id obj) {
PhotoViewController *vc = obj;
return ([vc isKindOfClass:[PhotoViewController class]] &&
(vc.photo != nil));
}];
[[mockNavController expect] pushViewController:viewController animated:YES];
現(xiàn)在,我們觸發(fā) view 加載,并且模擬一行被點擊:
UIView *view = photosViewController.view;
STAssertNotNil(view, @"");
NSIndexPath* indexPath = [NSIndexPath indexPathForRow:0 inSection:0];
[photosViewController tableView:photosViewController.tableView
didSelectRowAtIndexPath:indexPath];
最后我們驗證 mocks 上期望的方法被調(diào)用過:
[mockNavController verify];
[photosViewControllerMock verify];
現(xiàn)在我們有了一個測試,用來測試和 navigation controller 的交互,以及正確 view controller 的創(chuàng)建。
又一次地,我們在示例程序中使用了便捷的方法:
- (id)autoVerifiedMockForClass:(Class)aClass;
- (id)autoVerifiedPartialMockForObject:(id)object;
于是,我們不需要記住調(diào)用 -verify
。
就像你從上面看到的那樣,部分 mocking 非常強(qiáng)大。如果你看看 -[PhotosViewController setupTableView]
方法的源碼,你就會看到它是如何從 app delegate 中取出 model 對象的。
NSArray *photos = [AppDelegate sharedDelegate].store.sortedPhotos;
上面的測試依賴于這行代碼。打破這種依賴的一種方式是再次使用 部分 mocking,讓 app delegate 返回預(yù)定義的數(shù)據(jù),就像這樣:
id storeMock; // 假設(shè)我們已經(jīng)設(shè)置過了
id appDelegate = [AppDelegate sharedDelegate]
id appDelegateMock = [OCMockObject partialMockForObject:appDelegate];
[[[appDelegateMock stub] andReturn:storeMock] store];
現(xiàn)在,無論何時調(diào)用 [AppDelegate sharedDelegate].store
,它將返回 storeMock
。將這個技術(shù)使用好的話,可以確保讓你的測試恰到好處地在保持簡單和應(yīng)對復(fù)雜之間找到平衡。
部分 mock 技術(shù)將會在 mocks 的存在期間替換并保持被 mocking 的對象,并且一直有效。你可以通過提前調(diào)用 [aMock stopMocking]
來終于這種行為。大多數(shù)時候,你希望 部分 mock 在整個測試期間都保持有效。如果要提前終止,請確保在測試方法最后放置 [aMock verify]
。否則 ARC 會過早釋放這個 mock,這樣你就不能 -verify
了,這不太可能是你想要的結(jié)果。
PhotoCell
設(shè)置在一個 NIB 中,我們可以寫一個簡單的測試來檢查 outlets 設(shè)置得是否正確。我們來回顧一下 PhotoCell
類:
@interface PhotoCell : UITableViewCell
+ (UINib *)nib;
@property (weak, nonatomic) IBOutlet UILabel* photoTitleLabel;
@property (weak, nonatomic) IBOutlet UILabel* photoDateLabel;
@end
我們的簡單測試的實現(xiàn)看上去是這樣:
@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]], @"");
// 檢查 outlet 是否正確設(shè)置
STAssertNotNil(cell.photoTitleLabel, @"");
STAssertNotNil(cell.photoDateLabel, @"");
}
@end
非?;A(chǔ),但是能出色完成工作。
值得一提的是,當(dāng)有發(fā)生改變時,我們需要同時更新測試以及相應(yīng)的類或 nib 。這是事實。你需要考慮改變類或者 nib 文件時可能會打破原有的 outlets 連接。如果你用了 .xib
文件,你可能要注意了,這是經(jīng)常發(fā)生的事。
我們已經(jīng)從與 Xcode 集成得知,測試 bundle 會注入到應(yīng)用程序中。省略注入的如何工作的細(xì)節(jié)(它本身是個巨大的話題),簡單地說:注入是把待注入的 bundle(我們的測試 bundle)中的 Objective-C 類添加到運行的應(yīng)用程序中。這很好,因為這樣允許我們運行測試了。
還有一件事會很讓人迷惑,那就是如果我們同時把一個類添加到應(yīng)用程序和測試 bundle中。如果在上面的示例程序中,我們(不小心)把 PhotoCell
類同時添加到測試 bundle 和應(yīng)用程序里的話,在測試 bundle 中調(diào)用 [PhotoCell class]
會返回一個不同的指針(你應(yīng)用程序中的那個類)。于是我們的測試將會失?。?/p>
STAssertTrue([cell isMemberOfClass:[PhotoCell class]], @"");
再一次聲明:注入很復(fù)雜。你應(yīng)該確認(rèn)的是:不要把應(yīng)用程序中的 .m
文件添加到測試 target 中。否則你會得到預(yù)想不到的行為。
如果你使用一個持續(xù)集成 (CI) 的解決方案,讓你的測試啟動和運行是一個好主意。詳細(xì)的描述超過了本文的范圍。這些腳本通過 RunUnitTests
腳本觸發(fā)。還有個 TEST_AFTER_BUILD
環(huán)境變量。
另一種有趣的選擇是創(chuàng)建單獨的測試 bundle 來自動化性能測試。你可以在測試方法里做任何你想做的。定時調(diào)用一些方法并使用 STAssert
來檢查它們是否在特定閾值里面是其中一種選擇。