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