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