在開(kāi)發(fā)高質(zhì)量應(yīng)用程序的過(guò)程中,測(cè)試是一個(gè)很重要的工具。在過(guò)去,當(dāng)并發(fā)并不是應(yīng)用程序架構(gòu)中重要組成部分的時(shí)候,測(cè)試就相對(duì)簡(jiǎn)單。隨著這幾年的發(fā)展,使用并發(fā)設(shè)計(jì)模式已愈發(fā)重要了,想要測(cè)試好并發(fā)應(yīng)用程序,已成了一個(gè)不小的挑戰(zhàn)。
測(cè)試并發(fā)代碼最主要的困難在于程序或信息流不是反映在調(diào)用堆棧上。函數(shù)并不會(huì)立即返回結(jié)果給調(diào)用者,而是通過(guò)回調(diào)函數(shù),Block,通知或者一些類(lèi)似的機(jī)制,這些使得測(cè)試變得更加困難。
然而,測(cè)試異步代碼也會(huì)帶來(lái)一些好處,比如可以揭露較差的程序設(shè)計(jì),讓最終的實(shí)現(xiàn)變得更加清晰。
首先,我們來(lái)看一個(gè)簡(jiǎn)單的同步單元測(cè)試?yán)?。兩個(gè)數(shù)求和的方法:
+ (int)add:(int)a to:(int)b {
return a + b;
}
測(cè)試這個(gè)方法很簡(jiǎn)單,只需要比較該方法返回的值是否與期望的值相同,如果不相同,則測(cè)試失敗。
- (void)testAddition {
int result = [Calculator add:2 to:2];
STAssertEquals(result, 4, nil);
}
接下來(lái),我們利用 Block 將該方法改成異步返回結(jié)果。為了模擬測(cè)試失敗,我們會(huì)在方法實(shí)現(xiàn)中故意添加一個(gè) bug。
+ (int)add:(int)a to:(int)b block:(void(^)(int))block {
[[NSOperationQueue mainQueue] addOperationWithBlock:^{
block(a - b); // 帶有bug的實(shí)現(xiàn)
}];
}
顯然這是一個(gè)人為的例子,但是它卻真實(shí)的反應(yīng)了在編程中可能經(jīng)常遇到的問(wèn)題,只不過(guò)實(shí)際過(guò)程更復(fù)雜罷了。
測(cè)試上面的方法最簡(jiǎn)單的做法就是把斷言放到 Block 中。盡管我們的方法實(shí)現(xiàn)中存在 bug,但是這種測(cè)試永遠(yuǎn)不會(huì)失敗的:
// 千萬(wàn)不要使用這些代碼!
- (void)testAdditionAsync {
[Calculator add:2 to:2 block:^(int result) {
STAssertEquals(result, 4, nil); // 永遠(yuǎn)不會(huì)被調(diào)用到
}];
}
這里的斷言為什么沒(méi)失敗呢?
XCode4 所使用的測(cè)試框架是基于 OCUnit。為了理解之前所提到的異步測(cè)試問(wèn)題,我們需要了解一下測(cè)試包中的各個(gè)部分之間的執(zhí)行順序。下圖展示了一個(gè)簡(jiǎn)化的流程。
在測(cè)試框架在主 run loop 開(kāi)始運(yùn)行之后,主要執(zhí)行了以下幾個(gè)步驟:
exit()
退出測(cè)試。這其中我們最感興趣的是單個(gè)測(cè)試是如何被調(diào)用的。在異步測(cè)試中,包含斷言的 Block 會(huì)被加到主 run loop。當(dāng)所有的測(cè)試執(zhí)行完畢后,測(cè)試框架就會(huì)退出,而 block 卻從來(lái)沒(méi)有被執(zhí)行,因此不會(huì)引起測(cè)試失敗。
當(dāng)然我們有很多種方發(fā)來(lái)解決這個(gè)問(wèn)題。但是所有的方法都必須在主 run loop 中運(yùn)行,而且在測(cè)試方法返回和比較結(jié)果之前需要處理已入隊(duì)所有操作。
Kiwi 使用探測(cè)輪詢(xún) (probe poller),它可以在測(cè)試方法中被調(diào)用。 GHUnit 編寫(xiě)了一個(gè)單獨(dú)的測(cè)試類(lèi),它必須在測(cè)試的方法內(nèi)初始化,并在結(jié)束時(shí)接收一個(gè)通知。以上兩種方式都是通過(guò)編寫(xiě)相應(yīng)的代碼來(lái)確保異步測(cè)試方法在測(cè)試結(jié)束之前都不會(huì)返回。
我們對(duì)這個(gè)問(wèn)題的解決方案是對(duì) SenTestingKit 添加一個(gè)擴(kuò)展,它在棧上使用同步執(zhí)行,并把每個(gè)部分加入到主隊(duì)列上。正如下圖所見(jiàn),在驗(yàn)證整個(gè)測(cè)試框架結(jié)果之前,報(bào)告異步測(cè)試成功或者失敗的 Block 就被加入到隊(duì)列。這種執(zhí)行順序允許我們開(kāi)啟一個(gè)測(cè)試并等待它的測(cè)試結(jié)果。
如果測(cè)試方法以 Async 結(jié)尾,框架就會(huì)認(rèn)為該方法是異步測(cè)試。此外,在異步測(cè)試中,我們必須手動(dòng)地報(bào)告測(cè)試成功,同時(shí)為了防止 Block 永遠(yuǎn)不會(huì)被調(diào)用,我們還需添加了一個(gè)超時(shí)方法。之前的錯(cuò)誤的測(cè)試方法修改后如下所示:
- (void)testAdditionAsync {
[Calculator add:2 to:2 block^(int result) {
STAssertEquals(result, 4, nil);
STSuccess(); // 通過(guò)調(diào)用這個(gè)宏來(lái)判斷是否測(cè)試成功
}];
STFailAfter(2.0, @"Timeout");
}
就像同步測(cè)試一樣,異步測(cè)試也應(yīng)該比被測(cè)試的功能簡(jiǎn)單許多。復(fù)雜的測(cè)試并不會(huì)改進(jìn)代碼的質(zhì)量,反而會(huì)給測(cè)試本身帶來(lái)更多的 Bug。在以測(cè)試驅(qū)動(dòng)開(kāi)發(fā)的情況下,簡(jiǎn)單的測(cè)試會(huì)讓我們對(duì)組件,接口以及架構(gòu)的行為有更清醒的認(rèn)識(shí)。
為了運(yùn)用到實(shí)際中,我們創(chuàng)建了一個(gè)示例框架:PinacotecaCore,它從一個(gè)虛擬的服務(wù)器獲取圖像信息??蚣苤邪粋€(gè)資源管理器,它對(duì)外提供一個(gè)可以根據(jù)圖像 Id 獲取圖像對(duì)象的接口。該接口的工作原理是資源管理器從虛擬服務(wù)器獲取圖片對(duì)象的信息,并更新到數(shù)據(jù)庫(kù)。
雖然這個(gè)示例框架只是為了演示,但在我們自己開(kāi)發(fā)的許多應(yīng)用中也使用了這種模式。
從上圖我們可以知道,示例框架有三個(gè)組件我們需要測(cè)試:
測(cè)試應(yīng)該盡量使用同步的方式進(jìn)行,而模型層就是一個(gè)很好的實(shí)例。只要不同的被托管對(duì)象上下文 (managed object contexts) 之間沒(méi)有復(fù)雜的依賴(lài)關(guān)系,測(cè)試用例都應(yīng)該根據(jù)上下文在主線(xiàn)程上設(shè)置它自己的 core data 堆棧,并在其中執(zhí)行各自的操作。
在這個(gè)測(cè)試實(shí)例中,我們就是在 setUp
方法中設(shè)置 core data 堆棧,然后檢查 PCImage
實(shí)體的描述是否存在,如果不存在就構(gòu)造一個(gè),并更新它的值。當(dāng)然這和異步測(cè)試沒(méi)有關(guān)系,我們就不深入細(xì)說(shuō)了。
框架中的第二個(gè)組件就是服務(wù)器接口控制器。它主要處理服務(wù)器請(qǐng)求以及服務(wù)器 API 到模型的映射關(guān)系。讓我們來(lái)看一下下面這個(gè)方法:
- [PCServerAPIController fetchImageWithId:queue:completionHandler:]
調(diào)用它需要三個(gè)形參:一個(gè)圖片對(duì)象 Id,所在的執(zhí)行隊(duì)列以及一個(gè)完成后的回調(diào)方法。
因?yàn)榉?wù)器根本不存在,一個(gè)比較好的做法就是偽造一個(gè)代理服務(wù)器,正好 OHHTTPStubs 可以解決這個(gè)問(wèn)題。在它的最新版本中,可以在示例的請(qǐng)求響應(yīng)中包含一個(gè) bundle,發(fā)送給客戶(hù)端。
為了能 stub 請(qǐng)求,OHHTTPStubs 需要在測(cè)試類(lèi)初始化時(shí)或者 setUp 方法中進(jìn)行配置。首先,我們需要加載一個(gè)包含請(qǐng)求響應(yīng)對(duì)象(response)的 bundle:
NSURL *url = [[NSBundle bundleForClass:[self class]]
URLForResource:@"ServerAPIResponses"
withExtension:@"bundle"];
NSBundle *bundle = [NSBundle url];
然后我們從 bundle 加載 response 對(duì)象,作為請(qǐng)求的響應(yīng)值:
OHHTTPStubsResponse *response;
response = [OHHTTPStubsResponse responseNamed:@"images/123"
fromBundle:responsesBundle
responseTime:0.1];
[OHHTTPStubs stubRequestsPassingTest:^BOOL(NSURLRequest *request) {
return YES /* 如果所返回的request是我們所期望的,就返回YES */;
} withStubResponse:^OHHTTPStubsResponse *(NSURLRequest *request) {
return response;
}];
通過(guò)如上的設(shè)置之后,簡(jiǎn)化版的測(cè)試服務(wù)器接口控制器如下:
- (void)testFetchImageAsync
{
[self.server
fetchImageWithId:@"123"
queue:[NSOperationQueue mainQueue]
completionHandler:^(id imageData, NSError *error) {
STAssertEqualObjects([NSOperationQueue currentQueue], queue, nil);
STAssertNil(error, [error localizedDescription]);
STAssertTrue([imageData isKindOfClass:[NSDictionary class]], nil);
// 檢查返回的字典中的值.
STSuccess();
}];
STFailAfter(2.0, nil);
}
最后一個(gè)部分是資源管理器,它不但把服務(wù)器接口控制器和模型層聯(lián)系起來(lái), 還管理著 core data 堆棧。下面我們想測(cè)試獲取一個(gè)圖片對(duì)象的方法:
-[PCResourceManager imageWithId:usingManagedObjectContext:queue:updateHandler:]
該方法根據(jù) id 返回一個(gè)圖片對(duì)象。如果圖片在數(shù)據(jù)庫(kù)中不存在,它會(huì)創(chuàng)建一個(gè)只包含 id 的新對(duì)象,然后通過(guò)服務(wù)器接口控制器獲取圖片對(duì)象的詳細(xì)信息。
由于資源管理器的測(cè)試不應(yīng)該依賴(lài)于服務(wù)器接口控制器,所以我們可以用 OCMock 來(lái)模擬,如果要做方法的部分 stub,它是一個(gè)理想的框架。如以下的 資源管理器測(cè)試 :
OCMockObject *mo;
mo = [OCMockObject partialMockForObject:self.resourceManager.server];
id exp = [[serverMock expect]
andCall:@selector(fetchImageWithId:queue:completionHandler:)
onObject:self];
[exp fetchImageWithId:OCMOCK_ANY queue:OCMOCK_ANY completionHandler:OCMOCK_ANY];
上面的代碼實(shí)際上它并沒(méi)有真正調(diào)用服務(wù)器接口控制器的方法,而是調(diào)用我們寫(xiě)在測(cè)試類(lèi)中的方法。
用上面的做法,對(duì)資源管理的測(cè)試就變得很直觀。當(dāng)我們調(diào)用資源管理器獲取資源時(shí),實(shí)際上調(diào)用的是我們模擬的服務(wù)器接口控制器的方法。這樣我們也能檢查調(diào)用服務(wù)器接口控制器時(shí)參數(shù)是否正確。在調(diào)用了獲取圖像對(duì)象的方法后,資源管理器會(huì)更新模型,然后調(diào)用驗(yàn)證測(cè)試成功與否的宏。
- (void)testGetImageAsync
{
NSManagedObjectContext *ctx = self.resourceManager.mainManagedObjectContext;
__block PCImage *img;
img = [self.resourceManager imageWithId:@"123"
usingManagedObjectContext:ctx
queue:[NSOperationQueue mainQueue]
updateHandler:^(NSError *error) {
// 檢查error是否為空以及image是否已經(jīng)被更新
STSuccess();
}];
STAssertNotNil(img, nil);
STFailAfter(2.0, @"Timeout");
}
剛開(kāi)始時(shí)候,使用并發(fā)設(shè)計(jì)模式測(cè)試應(yīng)用程序是具有一定的挑戰(zhàn)性,但是一旦你理解了它們的不同,并建立最佳實(shí)踐,一切都會(huì)變得簡(jiǎn)單而有趣。
在 nxtbgthng 項(xiàng)目中,我們用 SenTestingKitAsync 框架來(lái)測(cè)試。但是像 Kiwi 和 GHUnit 也都是不錯(cuò)的異步測(cè)試框架。建議你都可以嘗試下,然后找到合適自己的測(cè)試工具并開(kāi)始使用它。