鍍金池/ 教程/ iOS/ 測試并發(fā)程序
與四軸無人機的通訊
在沙盒中編寫腳本
結構體和值類型
深入理解 CocoaPods
UICollectionView + UIKit 力學
NSString 與 Unicode
代碼簽名探析
測試
架構
第二期-并發(fā)編程
Metal
自定義控件
iOS 中的行為
行為驅動開發(fā)
Collection View 動畫
截圖測試
MVVM 介紹
使 Mac 應用數(shù)據(jù)腳本化
一個完整的 Core Data 應用
插件
字符串
為 iOS 建立 Travis CI
先進的自動布局工具箱
動畫
為 iOS 7 重新設計 App
XPC
從 NSURLConnection 到 NSURLSession
Core Data 網(wǎng)絡應用實例
GPU 加速下的圖像處理
自定義 Core Data 遷移
子類
與調(diào)試器共舞 - LLDB 的華爾茲
圖片格式
并發(fā)編程:API 及挑戰(zhàn)
IP,TCP 和 HTTP
動畫解釋
響應式 Android 應用
初識 TextKit
客戶端
View-Layer 協(xié)作
回到 Mac
Android
Core Image 介紹
自定義 Formatters
Scene Kit
調(diào)試
項目介紹
Swift 的強大之處
測試并發(fā)程序
Android 通知中心
調(diào)試:案例學習
從 UIKit 到 AppKit
iOS 7 : 隱藏技巧和變通之道
安全
底層并發(fā) API
消息傳遞機制
更輕量的 View Controllers
用 SQLite 和 FMDB 替代 Core Data
字符串解析
終身學習的一代人
視頻
Playground 快速原型制作
Omni 內(nèi)部
同步數(shù)據(jù)
設計優(yōu)雅的移動游戲
繪制像素到屏幕上
相機與照片
音頻 API 一覽
交互式動畫
常見的后臺實踐
糟糕的測試
避免濫用單例
數(shù)據(jù)模型和模型對象
Core Data
字符串本地化
View Controller 轉場
照片框架
響應式視圖
Square Register 中的擴張
DTrace
基礎集合類
視頻工具箱和硬件加速
字符串渲染
讓東西變得不那么糟
游戲中的多點互聯(lián)
iCloud 和 Core Data
Views
虛擬音域 - 聲音設計的藝術
導航應用
線程安全類的設計
置換測試: Mock, Stub 和其他
Build 工具
KVC 和 KVO
Core Image 和視頻
Android Intents
在 iOS 上捕獲視頻
四軸無人機項目
Mach-O 可執(zhí)行文件
UI 測試
值對象
活動追蹤
依賴注入
Swift
項目管理
整潔的 Table View 代碼
Swift 方法的多面性
為什么今天安全仍然重要
Core Data 概述
Foundation
Swift 的函數(shù)式 API
iOS 7 的多任務
自定義 Collection View 布局
測試 View Controllers
訪談
收據(jù)驗證
數(shù)據(jù)同步
自定義 ViewController 容器轉場
游戲
調(diào)試核對清單
View Controller 容器
學無止境
XCTest 測試實戰(zhàn)
iOS 7
Layer 中自定義屬性的動畫
第一期-更輕量的 View Controllers
精通 iCloud 文檔存儲
代碼審查的藝術:Dropbox 的故事
GPU 加速下的圖像視覺
Artsy
照片擴展
理解 Scroll Views
使用 VIPER 構建 iOS 應用
Android 中的 SQLite 數(shù)據(jù)庫支持
Fetch 請求
導入大數(shù)據(jù)集
iOS 開發(fā)者的 Android 第一課
iOS 上的相機捕捉
語言標簽
同步案例學習
依賴注入和注解,為什么 Java 比你想象的要好
編譯器
基于 OpenCV 的人臉識別
玩轉字符串
相機工作原理
Build 過程

測試并發(fā)程序

在開發(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)用到
    }];
}

這里的斷言為什么沒失敗呢?

關于SenTestingKit

XCode4 所使用的測試框架是基于 OCUnit。為了理解之前所提到的異步測試問題,我們需要了解一下測試包中的各個部分之間的執(zhí)行順序。下圖展示了一個簡化的流程。

SenTestingKit call stack

在測試框架在主 run loop 開始運行之后,主要執(zhí)行了以下幾個步驟:

  1. 配置一個包含所有相關測試的測試包 (比如可以在工程的 scheme 中配置)。
  2. 運行測試包,內(nèi)部會調(diào)用所有以 test 開頭測試用例的方法。運行結束后會返回一個包含單個測試結果的對象。
  3. 調(diào)用 exit() 退出測試。

這其中我們最感興趣的是單個測試是如何被調(diào)用的。在異步測試中,包含斷言的 Block 會被加到主 run loop。當所有的測試執(zhí)行完畢后,測試框架就會退出,而 block 卻從來沒有被執(zhí)行,因此不會引起測試失敗。

當然我們有很多種方發(fā)來解決這個問題。但是所有的方法都必須在主 run loop 中運行,而且在測試方法返回和比較結果之前需要處理已入隊所有操作。

Kiwi 使用探測輪詢 (probe poller),它可以在測試方法中被調(diào)用。 GHUnit 編寫了一個單獨的測試類,它必須在測試的方法內(nèi)初始化,并在結束時接收一個通知。以上兩種方式都是通過編寫相應的代碼來確保異步測試方法在測試結束之前都不會返回。

SenTestingKit的異步擴展

我們對這個問題的解決方案是對 SenTestingKit 添加一個擴展,它在棧上使用同步執(zhí)行,并把每個部分加入到主隊列上。正如下圖所見,在驗證整個測試框架結果之前,報告異步測試成功或者失敗的 Block 就被加入到隊列。這種執(zhí)行順序允許我們開啟一個測試并等待它的測試結果。

SenTestingKitAsync call stack

如果測試方法以 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ā)的許多應用中也使用了這種模式。

PinacotecaCore architecture

從上圖我們可以知道,示例框架有三個組件我們需要測試:

  1. 模型層
  2. 模擬服務器請求的服務器接口控制器(API Controller)
  3. 管理 core data 堆棧以及連接模型層和服務接口控制器的資源管理器

模型層

測試應該盡量使用同步的方式進行,而模型層就是一個很好的實例。只要不同的被托管對象上下文 (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 框架來測試。但是像 KiwiGHUnit 也都是不錯的異步測試框架。建議你都可以嘗試下,然后找到合適自己的測試工具并開始使用它。