注:文章翻譯自網(wǎng)絡(luò)博客驻子。
Using NSURLProtocol forInjecting Test Data
September13, 2011 By: Claus Broch Filed in: Develop | ILBitly | iPhone | Testing |Tutorial
用NSURProtocol注入測試數(shù)據(jù)
2011年9月13日,Claus Broch分類:Develop| ILBitly | iPhone | Testing | Tutorial
Inearlier posts I described methods for unit testing asynchronous network accessand how to use mock objects for further control of the scope of these unittests. In this tutorial I’ll present an alternative way of providing reliabletest data by customizing the NSURLProtocol class in order to deliver statictest data.
在之前的幾篇博文中,我介紹過訪問異步網(wǎng)絡(luò)的單元測試方法及如何用mock對象進(jìn)一步確定單元測試的范圍剃诅。今天的教程中正林,我將展示另一種方法赡模,即:通過自定義NSURProtocol類來獲取靜態(tài)測試數(shù)據(jù)婿着,從而為測試可靠的數(shù)據(jù)。
Afew months ago Gowalla made the networking code used in their iPhone clientavailable as open source on GitHub. The AFNetworking library as it is called isa “A delightful iOS networking library with NSOperations and block-basedcallbacks“. One of the things that first caught my eye was the built-in supportfor accessing JSON based services with just a few lines of code.
幾個月前Gowalla在GitHub上公開了他們用于iPhone客戶端的網(wǎng)絡(luò)代碼近她。這個被稱為AFNetworking的庫叉瘩,是一個“使用NSOperations和block回調(diào)的、討喜的iOS網(wǎng)絡(luò)庫”粘捎。這段代碼中首先吸引我的一個部分是:利用該庫內(nèi)置的支持服務(wù)薇缅,僅需幾行代碼即可訪問JSON服務(wù)。
Thesimplicity of the AFNetworking interface inspired me to give it a test spin andwrite ILBitly which provides an Objective C based wrapper for the Bitly urlshortening service. It’s very easy to use AFNetworking and especially the JSONsupport that is accessed using a single class methods. Unfortunately thissimplicity also makes it quite difficult to write self-contained unit and mocktests using OCMock. This is mainly because OCMock doesn’t support mocking ofclass methods. My attempts with other techniques such as method swizzlingwasn’t successful either.
AFNetworking的界面之簡潔,啟發(fā)我運(yùn)行一次快速的測試,并編寫了ILBitly仇参。ILBitly可提供一個基于Object C的包裝類,從而獲得Bitly的URL縮短服務(wù)蓬痒。AFNetworking的使用非常簡單,尤其是JSON的支持服務(wù)漆羔,僅需調(diào)用一個單個類的方法即可獲得梧奢。然而這簡潔性也為我們使用MCMock編寫自包含單元和mock測試增添不少難度。這主要因?yàn)镺CMock不支持mocking類方法演痒。我也嘗試過其它方法亲轨,例如method swizzling,然而也并不成功鸟顺。
Itwasn’t until a few days ago when I noticed a discussion on GitHub about how toproperly mock the interface to AFNetworking. In the discussion Adam Ernstsuggested to use a customized NSURLProtocol for doing the task. That finallygave me the missing clue on how to solve the testing problem.
就在幾天前惦蚊,我才看到GitHub上的一則討論,有關(guān)如何恰當(dāng)?shù)乩肁FNetworking的接口讯嫂,引入mock對象蹦锋。討論中Adam
Ernst建議使用自定義的NSURLProtocol來完成這項(xiàng)任務(wù)。這讓我靈光一現(xiàn)欧芽,終于想到了該如何解決我的測試問題莉掂。
Subclassing NSURLProtocol
子類化NSURLProtocol
Asmentioned above I didn’t find any easy way to mock the interface toAFJSONRequestOperation in order to intercept the network access. So analternative solution is to intercept the standard http protocol built into iOS.This is done by registering our own custom NSURLProtocol subclass capable ofhandling http requests: ILCannedURLProtocol. Since each registered protocolhandler is asked in reverse order of registration our class will always beconsulted before the standard classes.
如上文提到的,我需要攔截網(wǎng)絡(luò)訪問千扔,但我當(dāng)時找不到一種簡單的方法來模擬AFJSONRequestOperation的接口憎妙。于是我想到了另一條路库正,即攔截iOS內(nèi)置的標(biāo)準(zhǔn)http協(xié)議。這可以通過注冊我們自定義的NSURLProtocol子類ILCannedURLProtocol來實(shí)現(xiàn)厘唾。該子類可處理http請求褥符。由于詢問protocol
handler的順序,與注冊順序是相反的抚垃。因此相較于標(biāo)準(zhǔn)類喷楣,我們的類總是會被優(yōu)先訪問。
Theprimary goal of ILCannedURLProtocol is to respond with a pre-loaded set of testdata every time a http request is made. This way we’ll be able to remove anyoutside influences when running the tests. We’ll also be able to have the httprequest fail when we want it to fail. The interface for ILCannedURLProtocol is shownbelow:
這樣做的主要目的是讯柔,每當(dāng)出現(xiàn)一個http請求抡蛙,ILCannedURLProtocol即會回應(yīng)一組預(yù)先加載好的測試數(shù)據(jù)护昧。如此一來魂迄,我們就能在測試中消除所有外部影響。同時惋耙,我們可以在需要時捣炬,故意使http的請求失敗。ILCannedURLProtocol的接口如下所示:
@interfaceILCannedURLProtocol : NSURLProtocol
+(void)setCannedResponseData:(NSData*)data;
+(void)setCannedHeaders:(NSDictionary*)headers;
+(void)setCannedStatusCode:(NSInteger)statusCode;
+(void)setCannedError:(NSError*)error;
@end
Itis not able to fully replace any http requests in its current form. Forinstance it is only designed to intercept GET requests. Neither does it supportany type of authentication challenge/response. But it provides enoughfunctionality to deliver the test data needed for testing ILBitly and probablyother similar classes.
在現(xiàn)有http請求的形式下绽榛,我們并不能替換掉任何一個請求的全部內(nèi)容湿酸。舉例來說,我們只能攔截GET請求灭美,卻無法攔截任何類型的權(quán)限認(rèn)證質(zhì)詢(authentication
challenge)或認(rèn)證應(yīng)答(authentication response)推溃。但它現(xiàn)有的功能已經(jīng)足以為測試ILBitly及其相似的類提供測試數(shù)據(jù).
Basicallyeach of the setCannedXxx methods just retains the object passed to it so theobject can be returned again when needed by a http request. This also meansthat it is only able to serve one set of test data at a time.
基本上每個setCannedXxx方法都會保留下傳給它的對象,因此每當(dāng)http請求需要時届腐,可以返回這些對象铁坎。但這也意味著它們只能每次應(yīng)對一組測試數(shù)據(jù)。
Thereare a few additional methods that need to be implemented when subclassingNSURLProtocol. One of these iscanInitWithRequest: This method is called every time a NSURLRequest is startedin order to determine if that request is supported by the class. We’ll use thatto intercept the http GET requests:
子類化NSURLProtocol還需要實(shí)現(xiàn)一些其他的方法犁苏。其中之一是canInitWithRequest:每當(dāng)發(fā)起一個NSURLRequest時硬萍,都會調(diào)用該方法,來判斷該類是否支持這一請求围详。我們將使用這個方法來攔截http GET請求:
+(BOOL)canInitWithRequest:(NSURLRequest *)request {
// For now only supporting http GET
return [[[request URL] scheme]isEqualToString:@"http"]
&& [[request HTTPMethod]isEqualToString:@"GET"];
}
Wealso need to implement the startLoading method. This method is called once theappropriate protocol handler has been instantiated in order to service therequest with data. Our method is able to either respond with a successfulresponse or with an error depending on which of the canned data that has beenset:
同時我們也需要實(shí)現(xiàn)startLoading方法朴乖。該方法會在每次實(shí)例化相關(guān)protocol handler時被調(diào)用,從而給請求提供數(shù)據(jù)助赞。根據(jù)設(shè)置的封裝數(shù)據(jù)不同买羞,我們的方法將會給出一個成功的回應(yīng),或者報(bào)出一個錯誤:
-(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:selfdidReceiveResponse:response
cacheStoragePolicy:NSURLCacheStorageNotAllowed];
[client URLProtocol:selfdidLoadData:gILCannedResponseData];
[client URLProtocolDidFinishLoading:self];
[response release];
}
else if(gILCannedError) {
// Send the canned error
[client URLProtocol:selfdidFailWithError:gILCannedError];
}
}
Ifyou decide to use the above code for testing in your own project you must makesure not to accidentally include it in the production code for any appstargeted for the App Store. If you haven’t already spotted the reason for thisI’ll lead your attention to the initializer for NSHTTPURLResponse. This is aprivate api obtained by running class-dump on the iOS 4.3 SDK. If you includethis call in your production code you therefore risk it being rejected byApple. There is also a slight chance Apple might decide to modify it in futureupdates of iOS. But as long as it’s just used when running the unit testseverything should be fine.
如果你決定在自己的項(xiàng)目中使用上述代碼測試雹食,小心不要把它寫入任何打算上傳到APP Store的產(chǎn)品代碼中去畜普。如果你不明白為什么,讓我們來看一下NSHTTPURLResponse的初始化程序婉徘。這是一個私有API漠嵌,通過在iOS 4.3 SDK上運(yùn)行class-dump來獲取咐汞。如果你把這段回調(diào)加在你的產(chǎn)品代碼中,蘋果就又能會拒了它儒鹿。蘋果甚至可能會在未來的iOS更新中對它進(jìn)行修改化撕,盡管可能性不大。但如果只是用它來跑單元測試的話约炎,那應(yīng)該沒什么問題植阴。
Exceptfor a few other methods which are basically empty that’s all there is to it.Now we’ll just need to register our custom class and load some canned data intoit.
除去另外幾個基本為空的方法,所有的方法都在這了』常現(xiàn)在我們只需注冊我們自定義的類掠手,然后再加載一些封裝數(shù)據(jù)進(jìn)去。
Preparing the Unit Tests
準(zhǔn)備單元測試
Theunit test class for ILBitly just includes a few instance variables:
ILBitly的單元測試類只包含了幾個實(shí)例變量:
@interfaceILBitlyTest : SenTestCase {
ILBitly *bitly;
id bitlyMock;
BOOL done;
}
@end
Thebitly variable contains an instance of the ILBitly code under test, bitlyMockholds the partial mock object for the ILBitly test, and done is used forsignaling when the asynchronous calls have finished. These are explained morein details later.
變量bitly包含test下ILBitly代碼的一個實(shí)例狸捕,bitlyMock包含了用作ILBitly測試的部分mock對象喷鸽,done是異步調(diào)用結(jié)束的信號。后面我會詳細(xì)地解釋這些變量灸拍。
Beforeevery test case is executed the setUp method is automatically called allowingus to prepare things:
執(zhí)行每個測試用例之前做祝,setUp方法都會被自動調(diào)用,來做以下準(zhǔn)備:
-(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:[ILCannedURLProtocolclass]];
[ILCannedURLProtocolsetCannedStatusCode:200];
}
We’lluse this method to prepare a default test instance as well as registering theILCannedURLProtocol. The parameters used for initializing the ILBitly instanceare just placeholders which are passed on to the service requests. Since we’llbe using static test data they have no real meaning except that we’ll verifylater on that they are actually passed on as expected.
我們這個方法來準(zhǔn)備默認(rèn)的測試實(shí)例鸡岗,以及注冊ILCannedURLProtocol混槐。那些用來實(shí)例化ILBitly的參數(shù)只是傳給服務(wù)請求的占位符。因?yàn)橹笪覀儠褂渺o態(tài)測試數(shù)據(jù)轩性,所以它們其實(shí)并沒有什么實(shí)際的用途声登,僅供我們稍后確認(rèn)它們是否被如期傳遞了。
Inorder to balance things out properly, we’ll unregister our custom protocol aswell as dispose of the test data after each test:
為了平衡資源揣苏,每次測試后悯嗓,我們都會注銷自定義協(xié)議,同時銷毀測試數(shù)據(jù)舒岸。
-(void)tearDown
{
[NSURLProtocolunregisterClass:[ILCannedURLProtocol class]];
[ILCannedURLProtocol setCannedHeaders:nil];
[ILCannedURLProtocolsetCannedResponseData:nil];
[ILCannedURLProtocol setCannedError:nil];
[bitly release];
bitlyMock = nil;
[super tearDown];
}
We’llalso need to prepare some test data. This can easily be done by using curl tosave the raw response from bitly to a JSON file and loading that again for eachtest case as described in this previous post.
我們也需要準(zhǔn)備一些測試數(shù)據(jù)绅作。這很容易:如上一篇博文所說,我們可以用curl來保存從bitly到JSON文件的原始應(yīng)答蛾派,然后在每個測試用例中加載出來俄认。
Putting it all Together
動手組裝
Finallywe’ll need to write some tests that verifies the ILBitly code. As an exampleone of the tests for the shortening service is shown below:
最后,我們寫些測試來驗(yàn)證ILBitly代碼洪乍。例如眯杏,下文是一個驗(yàn)證縮短URL服務(wù)的測試:
-(void)testShorten {
// Prepare the canned test result
[ILCannedURLProtocolsetCannedResponseData:[self cannedDataWithName:@"shorten"]];
[ILCannedURLProtocol setCannedHeaders:
[NSDictionarydictionaryWithObject:@"application/json; charset=utf-8"
forKey:@"Content-Type"]];
// Prepare the mock
bitlyMock = [OCMockObjectpartialMockForObject:bitly];
NSURL *trigger = [NSURLURLWithString:@"http://"];
[[[bitlyMock expect] andReturn:[NSURLRequestrequestWithURL:trigger]]
requestForURLString:[OCMArgcheckWithBlock:^(id url) {
return [urlisEqualToString:EXPECTED_REQUEST];
}]];
// Execute the code under test
[bitlyshorten:@"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];
}
Inthe first part the static test data is loaded into the test protocol.
在第一部分中,靜態(tài)測試數(shù)據(jù)被加載到測試協(xié)議中壳澳。
Nexta partial mock object is created for the bitly object. Its primary role is tointercept the internal call to requestForURLString: and setup an expectationthat it’s actually being called. Once that call is made it will verify that theexpected url is requested and finally return an instance of NSURLRequest. Thatinstance just contains enough of the basic url scheme in order to trigger theload of our custom protocol.
之后我們?yōu)閎itly對象創(chuàng)建了部分mock對象岂贩。它的主要功能是攔截對requestForURLString的內(nèi)部調(diào)用,并創(chuàng)建一個我們期望調(diào)用的URL巷波。調(diào)用時萎津,測試會驗(yàn)證是否向我們期望的URL發(fā)出了請求卸伞,并最終返回一個NSURLRequest實(shí)例。為觸發(fā)加載我們自定義Protocol锉屈,該實(shí)例只包含了基本的URL Scheme荤傲。
Thecode under test can now be executed as shown in the third part. Since theblocks may be called at any time after invoking the shorten:result:error:method done is set so we know when it has been called.
被測試的代碼可如第三部分所示被執(zhí)行。由于invoke shorten:result:error后block隨時可能被回調(diào)颈渊,我們設(shè)置了done遂黍,這樣一來被調(diào)用時我們就能知道了。
Thefinal part of the code then waits for up to 5 seconds for done to be set asdetailed in a previous post. Finally verify is called on the mock object toensure that the expected messages were received.
如上一篇博文所述俊嗽,最后的一段代碼將會給done信號最多5秒的等待時間雾家。最后,確認(rèn)mock對象被調(diào)回绍豁,從而確認(rèn)已經(jīng)收到了所期望的信息芯咧。
Ifwe instead want to test for proper handling of errors we’ll just have toreplace the first part of the test method so it sets up error data and changethe tests accordingly:
如果我們轉(zhuǎn)而想測試系統(tǒng)對錯誤的處理,我們只需替換掉測試方法的第一部分妹田,改為錯誤數(shù)據(jù)唬党,同時相應(yīng)地對測試做如下改動:
[ILCannedURLProtocol setCannedError:
[NSError errorWithDomain:NSURLErrorDomain
code:kCFURLErrorTimedOut
userInfo:nil]];
Conclusion
結(jié)論
AsI’ve shown above it’s possible to use NSURLProtocol for injecting predictabletest data into unit and mock tests that would otherwise have been subject toexternal factors. It’s also possible to extend these tests even further. Forinstance you could use this method for implementing various simulations of badnetwork conditions such as high latencies and low bandwidth. The possibilitiesare endless and I just hope that this post at least have provided someinspiration.
綜上所述鹃共,我們可以利用NSURLProtocol鬼佣,將可預(yù)測的測試數(shù)據(jù)注入單元測試和mock測試中,以減少外部因素的影響霜浴。我們甚至可以擴(kuò)展這些測試晶衷。舉例來說,你可以用這個方法模擬糟糕的網(wǎng)絡(luò)環(huán)境阴孟,如長延遲和窄帶寬晌纫。可能性是無窮的永丝,我僅希望可用此文拋磚引玉锹漱。
TheILBitly wrapper as well as the accompanying test classes used in this post areavailable on GitHub along with a sample iPhone app that demonstrates some of thefunctionality.
本文中所使用的ILBitly包及測試類都可在GitHub上找到,同時我還放了一個iPhone
APP樣例慕嚷,用以演示某些功能哥牍。
Update:The ILCannedURLProtocol class is now included in the ILTesting repository onGithub.
更新:ILCannedURLProtocol類也已放到Github的ILTesting庫中。
Commentsand suggestions are welcome as always.
歡迎各類評論與建議喝检。