在之前的几篇博文中,笔者介绍过访问异步网络的单元测试方法及如何使用模拟对象来进一步控制单元测试的范围。在今天的教程中,笔者将展示另一种方法,即:通过自定义 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 官方博客


OneAPM蓝海讯通
11.4k 声望510 粉丝

Software makes the world run. OneAPM makes the software run.