用 NSURProtocol 注入測試資料

OneAPM藍海訊通發表於2016-01-29

在之前的幾篇博文中,筆者介紹過訪問非同步網路的單元測試方法及如何使用模擬物件來進一步控制單元測試的範圍。在今天的教程中,筆者將展示另一種方法,即:通過自定義 NSURProtocol 類來獲取靜態測試資料,從而為測試提供可靠的資料。

幾個月前,Gowalla 在 GitHub 上公開了他們用於 iPhone 客戶端的網路程式碼。這個被稱為 AFNetworking 的庫,是一個「使用 NSOperations 和 block 回撥的、討喜的 iOS 網路庫」。這段程式碼中首先吸引筆者的一點,是利用該庫內建的支援服務,僅需幾行程式碼即可訪問基於 JSON 的服務。

AFNetworking 的介面之簡潔,啟發筆者執行一次快速的測試,並編寫ILBitly。ILBitly 可提供一個基於 Objective C 的包裝類,從而獲得 Bitly 的 URL 縮短服務。AFNetworking 的使用非常簡單,尤其是 JSON 的支援服務,僅需呼叫單個類的方法即可獲得。然而,這簡潔性也為我們使用 MCMock 編寫自包含單元和模擬測試增添了不少難度。這主要是因為 OCMock 不支援類方法的模擬。筆者也嘗試過其它方法,例如 method swizzling,然而並沒有成功。

就在幾天前,筆者看到 GitHub 上的一則討論,有關如何恰當地模擬 AFNetworking 的介面。討論中 Adam Ernst 建議使用自定義的 NSURLProtocol 來完成這項任務。這讓筆者靈光一現,終於想到了解決測試問題的方法。

子類化 NSURLProtocol

如上文所述,筆者需要攔截網路訪問,但當時找不到一種簡單的方法來模擬 AFJSONRequestOperation 的介面。於是想到了另一條路,即攔截 iOS 內建的標準 http 協議。這可以通過註冊自定義的NSURLProtocol 子類 ILCannedURLProtocol 來實現。該子類可處理 http 請求。由於詢問協議處理器的順序與註冊順序是相反的。因此相較於標準類,我們的類總是會被優先訪問。

這樣做的主要目的,是每當出現一個 http 請求,ILCannedURLProtocol 即會回應一組預先載入好的測試資料。如此一來,我們就能在測試中消除所有外部影響。同時,可以在需要時,故意使 http 請求失敗。ILCannedURLProtocol 的介面如下所示:

@interface ILCannedURLProtocol : NSURLProtocol
+ (void)setCannedResponseData:(NSData*)data;
+ (void)setCannedHeaders:(NSDictionary*)headers;
+ (void)setCannedStatusCode:(NSInteger)statusCode;
+ (void)setCannedError:(NSError*)error;
@end

在現有 http 請求的形式下,我們不能替換任何一個請求的全部內容。舉例來說,我們只能攔截 GET 請求,卻無法攔截任何型別的許可權認證質詢(authentication challenge)或認證應答(authentication response)。但它現有的功能已經足以為測試 ILBitly 及其它相似的類提供測試資料。

基本上每個 setCannedXxx 方法都會保留傳給它的物件,因此每當http 請求需要時,可以返回這些物件。但這也意味著它們只能每次應對一組測試資料。

子類化 NSURLProtocol 還需要實現一些其他的方法。其中之一是canInitWithRequest:每當發起一個 NSURLRequest 時,都會呼叫該方法,來判斷該類是否支援這一請求。我們將使用這個方法來攔截 http GET 請求:

+ (BOOL)canInitWithRequest:(NSURLRequest *)request {
  // For now only supporting http GET
  return [[[request URL] scheme] isEqualToString:@"http"]
         && [[request HTTPMethod] isEqualToString:@"GET"];
}

同時我們也需要實現 startLoading 方法。該方法會在每次例項化相關協議處理器時被呼叫,從而給請求提供資料。根據設定的封裝資料不同,我們的方法將會給出一個成功的回應,或者報出一個錯誤:

- (void)startLoading {
  NSURLRequest *request = [self request];
  id client = [self client];
 
  if(gILCannedResponseData) {
    // Send the canned data
    NSHTTPURLResponse *response = 
      [[NSHTTPURLResponse alloc] initWithURL:[request URL]
                                  statusCode:gILCannedStatusCode
                                headerFields:gILCannedHeaders 
                                 requestTime:0.0];
 
    [client URLProtocol:self didReceiveResponse:response
            cacheStoragePolicy:NSURLCacheStorageNotAllowed];
    [client URLProtocol:self didLoadData:gILCannedResponseData];
    [client URLProtocolDidFinishLoading:self];
 
    [response release];
  }
  else if(gILCannedError) {
    // Send the canned error
    [client URLProtocol:self didFailWithError:gILCannedError];
  }
}

如果你決定在自己的專案中使用上述程式碼測試,小心不要把它寫入任何打算上傳到 APP Store 的產品程式碼中去。如果你不明白為什麼,讓我們來看一下 NSHTTPURLResponse 的初始化程式。這是一個私有 API,通過在 iOS 4.3 SDK 上執行 class-dump 來獲取。如果你把這段回撥加在產品程式碼中,蘋果可能會拒絕它。蘋果甚至可能會在未來的 iOS更新中對它進行修改,儘管可能性不大。 但如果只是用它來跑單元測試的話,那應該沒什麼問題。

除去另外幾個基本為空的方法,所有的方法都在這了。現在只需註冊我們自定義的類,然後再載入一些封裝資料進去。

準備單元測試

The unit test class for ILBitly just includes a few instance variables:

@interface ILBitlyTest : SenTestCase {
  ILBitly *bitly;
  id bitlyMock;
  BOOL done;
}
@end

變數 bitly 包含 test下ILBitly 程式碼的一個例項,bitlyMock 包含了用作 ILBitly 測試的部分 mock 物件,done 是非同步呼叫結束的訊號。後面筆者會詳細地解釋這些變數。

執行每個測試用例之前,setUp 方法都會被自動呼叫,來做以下準備:

- (void)setUp
{
  [super setUp];
 
  // Init bitly proxy using test id and key - not valid for real use
  bitly = [[ILBitly alloc] initWithLogin:@"LOGIN" apiKey:@"KEY"];
  done = NO;
 
  [NSURLProtocol registerClass:[ILCannedURLProtocol class]];
  [ILCannedURLProtocol setCannedStatusCode:200];
}

我們這個方法來準備預設的測試例項,以及註冊ILCannedURLProtocol。那些用來例項化 ILBitly 的引數只是傳給服務請求的佔位符。因為之後我們會使用靜態測試資料,所以它們其實並沒有什麼實際用途,僅供稍後確認它們是否被如期傳遞。

為了平衡資源,每次測試後,我們都會登出自定義協議,同時銷燬測試資料。

- (void)tearDown
{
  [NSURLProtocol unregisterClass:[ILCannedURLProtocol class]];
  [ILCannedURLProtocol setCannedHeaders:nil];
  [ILCannedURLProtocol setCannedResponseData:nil];
  [ILCannedURLProtocol setCannedError:nil];
 
  [bitly release];
  bitlyMock = nil;
 
  [super tearDown];
}

我們也需要準備一些測試資料。這很容易:如上一篇博文所說,我們可以用 curl 來儲存從 bitly 到 JSON 檔案的原始應答,然後在每個測試用例中載入出來。

動手組裝

最後,我們寫些測試來驗證 ILBitly 程式碼。例如,下文是一個驗證縮短 URL 服務的測試:

- (void)testShorten {
  // Prepare the canned test result
  [ILCannedURLProtocol setCannedResponseData:[self cannedDataWithName:@"shorten"]];
  [ILCannedURLProtocol setCannedHeaders:
    [NSDictionary dictionaryWithObject:@"application/json; charset=utf-8" 
                                forKey:@"Content-Type"]];
 
  // Prepare the mock
  bitlyMock = [OCMockObject partialMockForObject:bitly];
  NSURL *trigger = [NSURL URLWithString:@"http://"];
  [[[bitlyMock expect] andReturn:[NSURLRequest requestWithURL:trigger]]
    requestForURLString:[OCMArg checkWithBlock:^(id url) {
      return [url isEqualToString:EXPECTED_REQUEST]; 
  }]];
 
  // Execute the code under test
  [bitly shorten:@"http://www.infinite-loop.dk/blog/" result:^(NSString *result) {
    STAssertEqualObjects(result, @"http://j.mp/qA7S4Q", @"Unexpected short url");
    done = YES;
  } error:^(NSError *err) {
    STFail(@"Shorten failed with error: %@", [err localizedDescription]);
    done = YES;
  }];
 
  // Verify the result
  STAssertTrue([self waitForCompletion:5.0], @"Timeout");
  [bitlyMock verify];
}

在第一部分中,靜態測試資料被載入到測試協議中。

之後我們為 bitly 物件建立了部分模擬物件。它的主要功能是攔截對requestForURLString 的內部呼叫,並建立一個我們期望呼叫的 URL。呼叫時,測試會驗證是否向我們期望的URL發出了請求,並最終返回一個 NSURLRequest 例項。為觸發載入我們自定義的協議,該例項只包含了基本的 URL Scheme。

被測試的程式碼可如第三部分所示被執行。由於呼叫(invoke) shorten:result:error後,block 隨時可能被回撥,我們設定了done,這樣一來呼叫時我們就能知道了。

如上一篇博文所述,最後的一段程式碼將會給 done 訊號最多 5 秒的等待時間。最後,確認模擬物件被調回,從而確認已經收到了所期望的資訊。

如果我們轉而想測試系統對錯誤的處理,我們只需替換掉測試方法的第一部分,改為錯誤資料,同時相應地對測試做如下改動:

  [ILCannedURLProtocol setCannedError:
    [NSError errorWithDomain:NSURLErrorDomain
                        code:kCFURLErrorTimedOut
                    userInfo:nil]];

結論

綜上所述,我們可以利用 NSURLProtocol 將可預測的測試資料注入單元測試和模擬測試中,以減少外部因素的影響。我們甚至可以擴充套件這些測試。舉例來說,你可以用這個方法模擬糟糕的網路環境,如長延遲和窄頻寬。可能性是無窮的,筆者僅希望可用此文拋磚引玉。

本文中所使用的 ILBitly 包及測試類都可在 GitHub 上找到,同時筆者還放了一個 iPhone APP 樣例,用以演示某些功能。

更新:ILCannedURLProtocol 類也已放到 Github的 ILTesting 庫中。

針對現在的資訊就是做的處理。
歡迎各類評論與建議。原文地址:http://www.infinite-loop.dk/blog/2011/09/using-nsurlprotocol-for-injecting-test-data/

OneAPM Mobile Insight ,監控網路請求及網路錯誤,提升使用者留存。訪問 OneAPM 官方網站感受更多應用效能優化體驗,想閱讀更多技術文章,請訪問 OneAPM 官方技術部落格
本文轉自 OneAPM 官方部落格

相關文章