用 NSURProtocol 注入测试数据

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,423评论 6 491
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,147评论 2 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,019评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,443评论 1 283
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,535评论 6 385
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,798评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,941评论 3 407
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,704评论 0 266
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,152评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,494评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,629评论 1 340
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,295评论 4 329
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,901评论 3 313
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,742评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,978评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,333评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,499评论 2 348

推荐阅读更多精彩内容

  • 注:文章翻译自网络博客。 Using NSURLProtocol forInjecting Test Data S...
    瞎猫与死耗子阅读 597评论 2 0
  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,761评论 25 707
  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,629评论 18 139
  • 我有一颗善良的种子 人却始终没长大 我在南方的海滩上 拾起,一串贝壳 暗色的宝,去向 没有回忆的城市 走进幽谷的小...
    余温好似凉白开阅读 165评论 0 0
  • 先声明这是我第一次写影评,抱歉可能会写的不太好,但是看在我晚上11点半仍然坚持写的份上,希望看的朋友宽容些。 很喜...
    窗子阅读 1,243评论 3 2