在開發高質量應用程式的時候,測試是一個很重要的工具。在過去,併發在應用程式架構中還沒有那麼重要,測試就相對簡單。隨著這幾年的發展,併發設計模式已愈發重要了,想要測試好,已成了一個不小的挑戰.
測試併發程式碼最主要的困難在於程式或者資訊流不再反映在呼叫堆疊上了。函式並不會立即返回給呼叫者結果,而是通過回撥函式,閉包(Block),通知或者一些類似的機制來推遲返回結果,這就讓測試變得更加困難。
然而,測試非同步程式碼也會帶來一些好處,比如可以提早暴露一些較差的設計決定,讓最終的實現變得更加清晰。
非同步測試的問題
首先,我們來看一個簡單的同步單元測試:兩個數求和。
1 2 3 4 |
+ (int)add:(int)a to:(int)b { return a + b; } |
測試這個方法很簡單,只需要比較該方法返回的值和期望的值是否相同,如果不相同,則測試失敗。
1 2 3 4 5 |
- (void)testAddition { int result = [Calculator add:2 to:2]; STAssertEquals(result, 4, nil); } |
接下來,我們利用Block將該方法改變成非同步返回結果,同時我們也會新增一個Bug,讓測試失敗。
1 2 3 4 5 6 |
+ (int)add:(int)a to:(int)b block:(void(^)(int))block { [[NSOperationQueue mainQueue] addOperationWithBlock^{ block(a - b); // Buggy implementation }]; } |
雖然這是一個人為的例子,但是它卻真實的反應了在程式設計中可能遇到的問題,只不過實際過程更復雜罷了。測試上面的方法最簡單的做法就是把斷言放到Block的實現中,然而這種情況下,測試絕不會失敗,Bug卻依然存在:
1 2 3 4 5 6 |
// don't use this code! - (void)testAdditionAsync { [Calculator add:2 to:2 block^(int result) { STAssertEquals(result, 4, nil); // Never fails! }]; } |
斷言為什麼會失敗呢?
關於SenTestingKit
XCode4使用的測試框架是基於開源的OCUnit, 為了能更好的理解非同步測試,我們需要了解一下各種測試方法之間執行順序的不同。下圖展示了一個簡化的流程。
測試程式從主run loop開始後,執行順序主要有以下幾步:
- 配置一個測試套件包含所有的測試(如在工程的scheme中配置)。
- 執行測試套件,主要是呼叫以test開頭的所有方法。執行結束後會返回一個物件,它包含所有執行的單個測試的結果。
- 呼叫exit()方法,退出測試。
這其中我們最感興趣的是單個測試方法是如何被呼叫的。在非同步測試中,包含斷言的Block在主run loop中排隊。當所有的單個測試執行完畢後,測試框架就會退出測試,而block卻從來沒有被呼叫,因此不會觸發測試失敗。
當然我們有很多種方式來解決這個問題,但問題的核心在於:在測試方法未返回結果,框架也還未檢查測試結果之前,都必須執行主run loop和處理加入主run loop佇列。
Kiwi用輪詢的方式來解決,而GHUnit用一個單獨的測試類,它會在測試方法內初始化,結束時傳送一個通知。以上兩種方式都是通過程式碼來確保非同步測試方法在測試結束之前都不會返回。
SenTestingKit的非同步擴充套件
我們的解決方式是對 SenTestingKit新增一個擴充套件。正如下圖所見,驗證非同步測試失敗或者成功的方法被放在一個Block內,它在框架檢視測試結果之前被加入到了主run loop佇列中。這種執行順序允許我們開啟一個測試並等待它的測試結果。
如果測試方法以Async結尾,框架就會認為該方法是非同步測試。在非同步測試中,我們在Block中新增一個巨集來表示測試成功,為了防止Block永遠不會被呼叫,我們還新增了一個超時方法。之前的錯誤的測試方法修改後如下所示:
1 2 3 4 5 6 7 |
- (void)testAdditionAsync { [Calculator add:2 to:2 block^(int result){ STAssertEquals(result, 4, nil); STSuccess(); // Calling this macro reports success }]; STFailAfter(2.0, @"Timeout"); } |
設計非同步測試
就像同步測試一樣,非同步測試也應該比被測試的功能更簡單。複雜的測試並不會改進程式碼的質量,反而會給測試本身帶來更多的Bug。在以測試驅動開發的情況下,簡單的測試會讓我們對元件,介面以及架構的行為有更清醒的認識.
示例工程
綜上所述,我們建立了一個示例框架:PinacotecaCore,它從一個虛擬的伺服器獲取影象資訊。框架中包含一個資源管理器,它對外暴露一個可以根據影象Id獲取影象物件的介面,該介面的工作原理是資源管理器從伺服器獲取圖片物件的資訊,並更新到本地資料庫。
雖然這個示例框架只是為了演示,但在我們自己開發的App中我們也是這麼做的。
大體來講,示例框架有三個元件我們需要測試:
- 模型層
- 伺服器介面控制器(API Controller),包含所有對伺服器的請求
- 資源管理器,管理core data堆疊,連線模型層和服務介面控制器
模型層
模型層應該儘量用同步的方式來測試。在不同的被管理的物件上下文中,只要沒有太多的依賴,測試用例應該根據上下文在主執行緒上設定它自己的core data堆疊。在這個例子中,我們就是在setUp方法中設定core data堆疊,然後檢查PCImage物件是否存在,不存在則構造一個,並更新它的值。當然這和非同步測試沒有關係,我們就不深入細說了。
伺服器介面控制器
它主要處理伺服器請求以及伺服器API到模型的對映關係。讓我們來看一下下面這個方法:
1 |
- [PCServerAPIController fetchImageWithId:queue:completionHandler:] |
呼叫它需要傳入一個圖片物件Id,所在的執行佇列以及一個完成後的回撥方法。
當伺服器根本不存在時,一個比較好的做法就是偽造一個代理伺服器,正好OHHTTPStubs可以解決這個問題。在它的最新版本中,可以在請求響應中包含一個bundle,傳送給客戶端。
為了能接管請求,在測試類初始化時或者setUp方法中,對OHHTTPStubs進行配置。首先,我們需要載入一個包含請求響應物件(response)的bundle.
1 2 3 4 |
NSURL *url = [[NSBundle bundleForClass:[self class]] URLForResource:@"ServerAPIResponses" withExtension:@"bundle"]; NSBundle *bundle = [NSBundle url]; |
然後我們從bundle中載入請求響應物件,作為請求的響應值。
1 2 3 4 5 6 7 8 9 10 |
OHHTTPStubsResponse *response; response = [OHHTTPStubsResponse responseNamed:@"images/123" fromBundle:responsesBundle responseTime:0.1]; [OHHTTPStubs stubRequestsPassingTest:^BOOL(NSURLRequest *request) { return YES /* true, if it's the expected request */; } withStubResponse:^OHHTTPStubsResponse *(NSURLRequest *request) { return response; }]; |
經過如上設定後,測試伺服器介面控制器的簡化版如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
- (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); // Check the values of the returned dictionary. STSuccess(); }]; STFailAfter(2.0, nil); } |
資源管理器
它不但把伺服器介面控制器和模型層聯絡起來, 還管理著core data堆疊。下面我們想測試獲取一個圖片物件的方法:
1 |
-[PCResourceManager imageWithId:usingManagedObjectContext:queue:updateHandler:] |
該方法根據id返回一個圖片物件。如果圖片在資料庫中不存在,它會建立一個只包含id的新物件,然後通過伺服器介面控制器獲取圖片物件的詳細資訊。
資源管理器並不依賴伺服器介面控制器,我們可以用OCMock來模擬,下面是程式碼實現:
1 2 3 4 5 6 7 |
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]; |
上面的程式碼實際上它並沒有真正呼叫伺服器介面控制器的方法,而是呼叫我們寫在測試類中的方法。
用上面的做法,對資源管理的測試就變得很直觀。當我們呼叫資源管理器獲取資源時,實際上呼叫的是我們模擬的伺服器介面控制器方法。這樣我們也能檢查呼叫伺服器介面控制器時引數是否正確。在呼叫了獲取影象物件的方法後,資源管理器會更新模型,然後呼叫驗證測試成功與否的巨集。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
- (void)testGetImageAsync { NSManagedObjectContext *ctx = self.resourceManager.mainManagedObjectContext; __block PCImage *img; img = [self.resourceManager imageWithId:@"123" usingManagedObjectContext:ctx queue:[NSOperationQueue mainQueue] updateHandler:^(NSError *error) { // Check if the error is nil and // if the image has been updated. STSuccess(); }]; STAssertNotNil(img, nil); STFailAfter(2.0, @"Timeout"); } |
總結
測試併發設計模式開發的應用程式是一個挑戰,但是一旦你理解了它們的不同,並建立最佳實踐,一切都會變得簡單而有趣。在nxtbgthng專案中,我們用SenTestingKitAsync框架來測試。但是像Kiwi 和GHUnit也都是不錯的非同步測試框架。你可以都試用一下,找到適合自己的測試工具。