IT类翻译 -- 用NSURProtocol注入测试数据

注:文章翻译自网络博客。

Using NSURLProtocol forInjecting Test Data

September13, 2011 By: Claus Broch Filed in: Develop | ILBitly | iPhone | Testing |Tutorial

NSURProtocol注入测试数据

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.

在之前的几篇博文中,我介绍过访问异步网络的单元测试方法及如何用mock对象进一步确定单元测试的范围。今天的教程中,我将展示另一种方法,即:通过自定义NSURProtocol类来获取静态测试数据,从而为测试可靠的数据。

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客户端的网络代码。这个被称为AFNetworking的库,是一个“使用NSOperations和block回调的、讨喜的iOS网络库”。这段代码中首先吸引我的一个部分是:利用该库内置的支持服务,仅需几行代码即可访问JSON服务。

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的界面之简洁,启发我运行一次快速的测试,并编写了ILBitly。ILBitly可提供一个基于Object C的包装类,从而获得Bitly的URL缩短服务。AFNetworking的使用非常简单,尤其是JSON的支持服务,仅需调用一个单个类的方法即可获得。然而这简洁性也为我们使用MCMock编写自包含单元和mock测试增添不少难度。这主要因为OCMock不支持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上的一则讨论,有关如何恰当地利用AFNetworking的接口,引入mock对象。讨论中Adam

Ernst建议使用自定义的NSURLProtocol来完成这项任务。这让我灵光一现,终于想到了该如何解决我的测试问题。

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.

如上文提到的,我需要拦截网络访问,但我当时找不到一种简单的方法来模拟AFJSONRequestOperation的接口。于是我想到了另一条路,即拦截iOS内置的标准http协议。这可以通过注册我们自定义的NSURLProtocol子类ILCannedURLProtocol来实现。该子类可处理http请求。由于询问protocol

handler的顺序,与注册顺序是相反的。因此相较于标准类,我们的类总是会被优先访问。

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:

这样做的主要目的是,每当出现一个http请求,ILCannedURLProtocol即会回应一组预先加载好的测试数据。如此一来,我们就能在测试中消除所有外部影响。同时,我们可以在需要时,故意使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.

在现有http请求的形式下,我们并不能替换掉任何一个请求的全部内容。举例来说,我们只能拦截GET请求,却无法拦截任何类型的权限认证质询(authentication

challenge)或认证应答(authentication response)。但它现有的功能已经足以为测试ILBitly及其相似的类提供测试数据.

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方法都会保留下传给它的对象,因此每当http请求需要时,可以返回这些对象。但这也意味着它们只能每次应对一组测试数据。

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还需要实现一些其他的方法。其中之一是canInitWithRequest:每当发起一个NSURLRequest时,都会调用该方法,来判断该类是否支持这一请求。我们将使用这个方法来拦截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:

同时我们也需要实现startLoading方法。该方法会在每次实例化相关protocol handler时被调用,从而给请求提供数据。根据设置的封装数据不同,我们的方法将会给出一个成功的回应,或者报出一个错误:

-(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.

如果你决定在自己的项目中使用上述代码测试,小心不要把它写入任何打算上传到APP Store的产品代码中去。如果你不明白为什么,让我们来看一下NSHTTPURLResponse的初始化程序。这是一个私有API,通过在iOS 4.3 SDK上运行class-dump来获取。如果你把这段回调加在你的产品代码中,苹果就又能会拒了它。苹果甚至可能会在未来的iOS更新中对它进行修改,尽管可能性不大。但如果只是用它来跑单元测试的话,那应该没什么问题。

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.

除去另外几个基本为空的方法,所有的方法都在这了。现在我们只需注册我们自定义的类,然后再加载一些封装数据进去。

Preparing the Unit Tests

准备单元测试

Theunit test class for ILBitly just includes a few instance variables:

ILBitly的单元测试类只包含了几个实例变量:

@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代码的一个实例,bitlyMock包含了用作ILBitly测试的部分mock对象,done是异步调用结束的信号。后面我会详细地解释这些变量。

Beforeevery test case is executed the setUp method is automatically called allowingus to prepare things:

执行每个测试用例之前,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:[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.

我们这个方法来准备默认的测试实例,以及注册ILCannedURLProtocol。那些用来实例化ILBitly的参数只是传给服务请求的占位符。因为之后我们会使用静态测试数据,所以它们其实并没有什么实际的用途,仅供我们稍后确认它们是否被如期传递了。

Inorder to balance things out properly, we’ll unregister our custom protocol aswell as dispose of the test data after each test:

为了平衡资源,每次测试后,我们都会注销自定义协议,同时销毁测试数据。

-(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.

我们也需要准备一些测试数据。这很容易:如上一篇博文所说,我们可以用curl来保存从bitly到JSON文件的原始应答,然后在每个测试用例中加载出来。

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:

最后,我们写些测试来验证ILBitly代码。例如,下文是一个验证缩短URL服务的测试:

-(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.

在第一部分中,静态测试数据被加载到测试协议中。

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.

之后我们为bitly对象创建了部分mock对象。它的主要功能是拦截对requestForURLString的内部调用,并创建一个我们期望调用的URL。调用时,测试会验证是否向我们期望的URL发出了请求,并最终返回一个NSURLRequest实例。为触发加载我们自定义Protocol,该实例只包含了基本的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.

被测试的代码可如第三部分所示被执行。由于invoke shorten:result:error后block随时可能被回调,我们设置了done,这样一来被调用时我们就能知道了。

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秒的等待时间。最后,确认mock对象被调回,从而确认已经收到了所期望的信息。

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:

如果我们转而想测试系统对错误的处理,我们只需替换掉测试方法的第一部分,改为错误数据,同时相应地对测试做如下改动:

[ILCannedURLProtocol setCannedError:

[NSError errorWithDomain:NSURLErrorDomain

code:kCFURLErrorTimedOut

userInfo:nil]];

Conclusion

结论

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,将可预测的测试数据注入单元测试和mock测试中,以减少外部因素的影响。我们甚至可以扩展这些测试。举例来说,你可以用这个方法模拟糟糕的网络环境,如长延迟和窄带宽。可能性是无穷的,我仅希望可用此文抛砖引玉。

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.

欢迎各类评论与建议。

你可能感兴趣的:(IT类翻译 -- 用NSURProtocol注入测试数据)