iOS/OS Xテスト向けのURL読み込みに偽のレスポンスを返すモジュールいろいろ

URLリクエストを指定した結果に書き換えるテスト用ライブラリを作った - laiso - iPhoneアプリ開発グループ

この頃はあんまり良いライブラリが他になかったので自分で作ってたけど、今はいろいろ良いものが出てきているので実際の使い方などを紹介します。

モックとスタブ

スタブという言葉が以降よく出てくるんですが、基本的には以下文章などを目を通した上で、「リクエストのモックオブジェクトがスタブの振舞いをして偽のレスポンスを返す」というような表現を使います。

あんまり理解できている自信もないですが……

なぜURL読み込みに偽のレスポンスを返すしくみが必要なのか?

以前書いた内容とカブっているけど、以下のような諸問題を改善します

  1. ユニットテスト実行時間の高速化
  2. レスポンスが固定化されることで、より正確な検証が可能
  3. オフラインでのテスト実行サポート
  4. CIサーバーフレンドリー
  5. 設計レベルでの依存状態の解消(平行開発してWeb APIメンテ中デバッグできないなど)

サンプルコードについて

サンプルコードは以下。NSURLConnectionを利用したシンプルなQiitaのWeb API呼び出しクラスのメソッドをテストする。

https://github.com/laiso/iOSSamples/tree/master/FakeResponse

git clone https://github.com/laiso/iOSSamples.git
cd FakeResponse/

インストールできそうなものはcocoapodsで入れる。

# Podfile
platform :ios

target :FakeResponseAppTests, :exclusive => false do
  pod "Nocilla"
  pod "OHHTTPStubs"
  pod "NLTHTTPStubServer"
  pod "OCMock"

  # 公式SpecリポジトリにはまだないのでsubmoduleとしてVendors/ 以下にチェックアウトする
  #pod "SenAsyncTestCase"
  #pod "ILTesting"
  #pod "NSURLConnectionVCR"
end
cd ..
git submodule init FakeResponse/Vendors/SenAsyncTestCase
git submodule init FakeResponse/Vendors/ILTesting 
git submodule init FakeResponse/Vendors/NSURLConnectionVCR
// QiitaAPI..m
+ (void)loadItemsWithTag:(NSString *)tag
       completionHandler:(void (^)(NSArray* items, NSError* error))handler
{
  NSString* url = [NSString stringWithFormat:@"https://qiita.com/api/v1/tags/%@/items", tag];
  NSURLRequest* req = [NSURLRequest requestWithURL:[NSURL URLWithString:url]];
  
  [NSURLConnection sendAsynchronousRequest:req queue:[NSOperationQueue mainQueue] completionHandler:^(NSURLResponse *resp, NSData *data, NSError *err) {
    if(err){
      handler(nil, err);
      return;
    }
    
    id object = [NSJSONSerialization JSONObjectWithData:data options:NSJSONReadingAllowFragments error:&err];
    if(err){
      NSLog(@"[ERROR]: %@,\n"
            "%@",
            [err description],
            [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]);
    }
    handler(object, err);
  }];
}

非同期処理のテストについて、akisuteさんのSenAsyncTestCaseを使っている。

// SimpleTest.m
- (void)testRequest
{
  [QiitaAPI loadItemsWithTag:@"iOS" completionHandler:^(NSArray *items, NSError *error) {
    STAssertNil(error, nil);
    STAssertEquals(items.count, 20U, @"per_pageを指定していないのでデフォルトで20個のitemが返ってくる");
    [self notify:SenAsyncTestCaseStatusSucceeded];
  }];
  [self waitForTimeout:3];
}


偽のレスポンスを返す実現方法については、おおまかに分けて3つのアプローチがあるので以下で順番に解説する

(1) NSURLProtocolに準拠したクラスを登録し、読み込んだURLに対するレスポンスを制御する

Cocoa/Foundation標準のURL読み込みシステムを使って、リクエスト・レスポンスの挙動をフックして処理する。

割と柔軟になんでもできるので一応これが主流だと思う。冒頭のURLで提示した試作品と同じやり方。

範囲がURL読み込みなんでもなのでUIWebViewとかにも適用される。

Nocilla

https://github.com/luisobo/Nocilla/

LSNocillaにstart/stopの命令を送ることでリクエストオブジェクトとそこから返ってくるレスポンスをスタブ化できる。

SenTestCase内で

// WithNocillaTests.m
-(void)setUp
{
  [super setUp];
  
  [[LSNocilla sharedInstance] start];
}

-(void)tearDown
{
  [[LSNocilla sharedInstance] stop];
  
  [super tearDown];
} 

とするとテストメソッド実行ごとに真っ新にする。[[LSNocilla sharedInstance] clear]っているメソッドもあるんだけどstop内から呼ばれている。

公式ドキュメントだとBDDスタイル(Kiwi)のbeforeAll/afterAll/afterEachなんかを使ってテストケース全体でstart/stop、テストごとにclearと、効率よく書けるというのをすすめているみたい。

リクエスト・レスポンスの返り値は以下のような内部DSLで定義する。

// WithNocillaTests.m
stubRequest(@"GET", @"https://qiita.com/api/v1/tags/iOS/items").
  andReturn(200).
  withHeaders(@{@"Content-Type": @"application/json"}).
  withBody("{\"status\: \"ok\""}");

andReturn以前がリクエストの定義で、以下がそれにマッチした際に返すレスポンスになってる。マッチするかというのはURL、HTTPメソッド以外にヘッダやBodyも見ててそれも一致しないと「スタブ化されたレスポンスがありません」的なエラーが出ると思う。

全体的なテストはこんな感じ

// WithNocillaTests.m
- (void)testRequest
{
  NSString* body = [TestHelper readResponse:@"fixtures/item.json"];

  stubRequest(@"GET", @"https://qiita.com/api/v1/tags/iOS/items").
  andReturn(200).
  withHeaders(@{@"Content-Type": @"application/json"}).
  withBody(body);
  
  
  [QiitaAPI loadItemsWithTag:@"iOS" completionHandler:^(NSArray *items, NSError *error) {
    NSLog(@"isMainThread: %d", [NSThread isMainThread]);

    STAssertNil(error, nil);
    STAssertEquals(items.count, 1U, nil);
    
    NSDictionary* item = [items lastObject];
    
    STAssertEqualObjects([item objectForKey:@"id"], [NSNumber numberWithInt:10884], nil);
    STAssertEqualObjects([item objectForKey:@"title"], @"keyboardWasShownに関して", nil);
    STAssertEqualObjects([item objectForKey:@"url"], @"http://qiita.com/items/ac6577db608748bb3a78", nil);
    
    [self notify:SenAsyncTestCaseStatusSucceeded];
  }];
  [self waitForTimeout:3];
}

これだけでURL読み込み状態に依存しない、常に同じレスポンスが返ってくるテストへ分離できる。

JSONテキストのレスポンスをベタに貼るとNSStringの都合上ダブルクォートをエスケープしなきゃいけないのと、ちょと弄った時にJSONスキーマエラーとか出てないかとデバッグするのに都合がいいので外部ファイルに書き出してfixturesにしておいた方がいい。curlコマンドとかブラウザで直接取得した実際のレスポンスをコピペしておく。

この時注意するのは実行するFakeResponseApp.appの[NSBunble mainBundle]じゃなくて、ここのテストケースから見るのはFakeResponseAppTests.octestのバンドルなので以下のように取得する

// TestHelper.m
NSString* file = [[NSBundle bundleForClass:[self class]] pathForResource:path ofType:nil]; // path: fixture/item.json
OHHTTPStubs

https://github.com/AliSoftware/OHHTTPStubs/

OHHTTPStubsの場合単純な全リクエストの置き換えなら以下の2、3行でOK

// WithOHHTTPStubsTests.m
- (void)testRequest
{
  [OHHTTPStubs addRequestHandler:^OHHTTPStubsResponse*(NSURLRequest *request, BOOL onlyCheck){
     return [OHHTTPStubsResponse responseWithFile:@"fixtures/item.json" contentType:@"text/json" responseTime:2.0];
  }];
  
  [QiitaAPI loadItemsWithTag:@"iOS" completionHandler:^(NSArray *items, NSError *error) {
    STAssertNil(error, nil);
    STAssertEquals(items.count, 1U, nil);
    
    NSDictionary* item = [items lastObject];
    
    STAssertEqualObjects([item objectForKey:@"id"], [NSNumber numberWithInt:10884], nil);
    STAssertEqualObjects([item objectForKey:@"title"], @"keyboardWasShownに関して", nil);
    STAssertEqualObjects([item objectForKey:@"url"], @"http://qiita.com/items/ac6577db608748bb3a78", nil);
    
    [self notify:SenAsyncTestCaseStatusSucceeded];
  }];
  [self waitForTimeout:3];
}

responseTimeでダウンロード速度も指定できる。

スタブ化しないリクエストにはOHHTTPStubsResponseDontUseStubを返すようにすると実リクエストが発行される。

// WithOHHTTPStubsTests.m
  [OHHTTPStubs addRequestHandler:^OHHTTPStubsResponse*(NSURLRequest *request, BOOL onlyCheck){
    if([request.URL.path isEqualToString:@"/api/v1/tags/iOS/items"]){
      return OHHTTPStubsResponseDontUseStub;
    }
    return [OHHTTPStubsResponse responseWithFile:@"fixtures/item.json" contentType:@"text/json" responseTime:2.0];
  }];
ILTesting

https://github.com/InfiniteLoopDK/ILTesting

OS Xでしか動かないクラスも混っているけど、とりあえずILCannedURLProtocol.h,mをプロジェクトに取り込めば大丈夫

セットアップはNSURLProtocolの素のAPIでそのままやる感じ

// WithILTestingTests.m
- (void)setUp
{
  [super setUp];
  [NSURLProtocol registerClass:[ILCannedURLProtocol class]];
}

-(void)tearDown
{
  [NSURLProtocol unregisterClass:[ILCannedURLProtocol class]];
  [super tearDown];
}

レスポンスは直後のものを一律全部置き換え

// WithILTestingTests.m
- (void)testRequest
{
  NSData* body = [TestHelper readResponseData:@"fixtures/item.json"];

  [ILCannedURLProtocol setCannedStatusCode:200];
  [ILCannedURLProtocol setCannedHeaders:@{@"Content-Type": @"application/json"}];
  [ILCannedURLProtocol setCannedResponseData:body];
  
  [QiitaAPI loadItemsWithTag:@"iOS" completionHandler:^(NSArray *items, NSError *error) {
    STAssertNil(error, nil);
    STAssertEquals(items.count, 1U, nil);
    
    NSDictionary* item = [items lastObject];
    
    STAssertEqualObjects([item objectForKey:@"id"], [NSNumber numberWithInt:10884], nil);
    STAssertEqualObjects([item objectForKey:@"title"], @"keyboardWasShownに関して", nil);
    STAssertEqualObjects([item objectForKey:@"url"], @"http://qiita.com/items/ac6577db608748bb3a78", nil);
    
    [self notify:SenAsyncTestCaseStatusSucceeded];
  }];
  [self waitForTimeout:3];
}

(2) Method Swizzling でNSURLConnectionやASIHttpRequestのメソッドを置き換えるタイプ

これはNSURLProtocolのCocoaのhooksの仕組みを使うのではなく、Objective-Cランタイムに定義されているmethod_exchangeImplementationsで実オブジェクトの実装を置き換えてスタブ化する。
NSURLConnection,ASIHttpRequestなど対象のクラスに依存する(が、だいたいどのネットワークライブラリも内部的にこの二種の実装使ってることが多い)

Method Swizzlingでのレスポンス置き換えについては、以前からよくpracticeとしてブログで書いている人たちが居た。

自前実装

以下で外部ライブラリを使わずにMethod Swizzling を利用して対象のクラスメソッドの実装を入れ替える。置き換える先はクラスメソッドではないのがポイント。

Objective-Cでクラスメソッドからのレスポンスをモックに置き換えたい - yaakaito's diary

// WithMethodSwizzTests.m
#import <objc/runtime.h>
// ...

- (void)testSwizzRequest
{
  Method original = class_getClassMethod([QiitaAPI class], @selector(loadItemsWithTag:completionHandler:));
  Method sub = class_getInstanceMethod([self class], @selector(loadItemsWithTag:completionHandler:));
  method_exchangeImplementations(original, sub);

  
  [QiitaAPI loadItemsWithTag:@"iOS" completionHandler:^(NSArray *items, NSError *error) {
    STAssertNil(error, nil);
    STAssertEquals(items.count, 1U, nil);
    
    NSDictionary* item = [items lastObject];
    
    STAssertEqualObjects([item objectForKey:@"id"], [NSNumber numberWithInt:10884], nil);
    STAssertEqualObjects([item objectForKey:@"title"], @"keyboardWasShownに関して", nil);
    STAssertEqualObjects([item objectForKey:@"url"], @"http://qiita.com/items/ac6577db608748bb3a78", nil);
    
    method_exchangeImplementations(sub, original);
    [self notify:SenAsyncTestCaseStatusSucceeded];
  }];
  [self waitForTimeout:3];
}

- (void)loadItemsWithTag:(NSString *)tag
       completionHandler:(void (^)(NSArray* items, NSError* error))handler
{
  NSData* body = [TestHelper readResponseData:@"fixtures/item.json"];
  id object = [NSJSONSerialization JSONObjectWithData:body options:NSJSONReadingAllowFragments error:nil];
  handler(object, nil);
}

が、これだとloadItemsWithTag:completionHandler:内部で実装しているJSONのパースやエラー処理を全部すっとばして、望んだテスト用レスポンスを検証しているだけで、自作自演状態になっているのであんまり意味がない。

今度はテスト対象のQiitaAPIクラスではなくて、メソッド内部で依存しているNSURLConnectionのsendAsynchronousRequest:queue:completionHandler:だけを置き換え必要最小限にする。

- (void)testSwizzURLConnection
{
  Method original = class_getClassMethod([NSURLConnection class], @selector(sendAsynchronousRequest:queue:completionHandler:));
  Method sub = class_getInstanceMethod([self class], @selector(sendAsynchronousRequest:queue:completionHandler:));
  method_exchangeImplementations(original, sub);
  
  [QiitaAPI loadItemsWithTag:@"iOS" completionHandler:^(NSArray *items, NSError *error) {
    STAssertNil(error, nil);
    STAssertEquals(items.count, 1U, nil);
    
    NSDictionary* item = [items lastObject];
    
    STAssertEqualObjects([item objectForKey:@"id"], [NSNumber numberWithInt:10884], nil);
    STAssertEqualObjects([item objectForKey:@"title"], @"keyboardWasShownに関して", nil);
    STAssertEqualObjects([item objectForKey:@"url"], @"http://qiita.com/items/ac6577db608748bb3a78", nil);
    
    method_exchangeImplementations(sub, original);
    [self notify:SenAsyncTestCaseStatusSucceeded];
  }];
  [self waitForTimeout:3];
}

- (void)sendAsynchronousRequest:(NSURLRequest *)request
                          queue:(NSOperationQueue*) queue
              completionHandler:(void (^)(NSURLResponse*, NSData*, NSError*)) handler
{
  NSData* body = [TestHelper readResponseData:@"fixtures/item.json"];
  handler(nil, body, nil);
}
FakeWeb

https://github.com/dealforest/iOS-FakeWeb

同名のrubyコミュニティで有名なモジュールがあり、そのiOS版的な位置付け。

NSURLConnectionとASIHttpRequestの拡張カテゴリをテストから読み込み、以下のように使える

// WithFakeWebTests.m
#import "FakeWeb.h"
#import "NSURLConnection+FakeWeb.h"
// ...

-(void)tearDown
{
  [FakeWeb cleanRegistry];
  [super tearDown];
}

- (void)testRequest
{
  NSString* body = [TestHelper readResponse:@"fixtures/item.json"];
  [FakeWeb registerUri:@"https://qiita.com/api/v1/tags/iOS/items" method:@"GET" body:body];
  
  [QiitaAPI loadItemsWithTag:@"iOS" completionHandler:^(NSArray *items, NSError *error) {
    STAssertNil(error, nil);
    STAssertEquals(items.count, 1U, nil);
    
    NSDictionary* item = [items lastObject];
    
    STAssertEqualObjects([item objectForKey:@"id"], [NSNumber numberWithInt:10884], nil);
    STAssertEqualObjects([item objectForKey:@"title"], @"keyboardWasShownに関して", nil);
    STAssertEqualObjects([item objectForKey:@"url"], @"http://qiita.com/items/ac6577db608748bb3a78", nil);
    
    [self notify:SenAsyncTestCaseStatusSucceeded];
  }];
  [self waitForTimeout:3];
}
NSURLConnectionVCR

https://bitbucket.org/martijnthe/nsurlconnectionvcr

これは正確にはレスポンスをスタブ化するモジュールではなくて、リクエストの記録、結果をキャッシュして繰り返しのテストに使って実行速度とネットワーク依存を改善するためのものらしい。

Rails向けにVCRというモジュールがもともとあり、それに着想と得てCocoa向けに使えるようにしたものみたい。

VCRで外部APIとのやりとりを記録する #Ruby #Rails #test - Qiita

関係ないけど「なぜかhg cloneできね〜」と思っていたらGitリポジトリだった。

最初にレスポンスを保存しておくパスを設定しておく

- (void)setUp
{
  [super setUp];
  NSString* path = [NSTemporaryDirectory() stringByAppendingPathComponent:@"vcr_cassettes"];
  [NSURLConnectionVCR startVCRWithPath:path error:nil];
}

-(void)tearDown
{
  [NSURLConnectionVCR stopVCRWithError:nil];
  [super tearDown];
}

ドキュメントそのままだとパスに書き込み権限なくて動かないので注意。上ではFakeResponseApp.app/tmp/vcr_cassettes 以下に保存できるように指定した。

何が保存されるかというとバイナリPLIST形式でシリアライズしたものです。

(3) 自身にHTTPサーバーを立て、リクエストURLに対してレスポンスを制御する

これはリクエストやレスポンスをスタブ化するのではなくて、リクエストする先のサーバーの方をスタブ化するようなイメージ。ウェブアプリ開発だと、本番サーバーからローカルの開発サーバとアクセス先を切り変えるけど、そのローカルサーバーがアプリ自身内に埋め込まれているという違いがある。

リクエストやレスポンスオブジェクト自体には手を加えないので、黒魔術的な意図しない動作は起きにくいのが利点と言える。

ただアクセス先ホストをテスト時に動的に変えてやる必要がある。

NLTHTTPStubServer

https://github.com/yaakaito/NLTHTTPStubServer

日本人開発者のyaakaitoさんが作っているモジュール。

などを参照。

セットアップはSenTestCaseベースだとこんな感じ?

// WithNLTHTTPStubServerTests.m

@interface WithNLTHTTPStubServerTests : SenAsyncTestCase
@property (nonatomic, weak) NLTHTTPStubServer* server;
@end

// ...

- (void)setUp
{
  [super setUp];
  self.server = [NLTHTTPStubServer stubServer];
  [self.server startServer];
}


-(void)tearDown
{
  [self.server clear];
  [self.server stopServer];
  [super tearDown];
}

そしてリクエスト先をlocalhost:12345(サーバーのデフォルトのポート番号)にすべくテスト対象クラスのインターフェイスに手を加えはじめる(突貫工事的になってしまった)

// QiitaAPI.m
+ (void)loadItemsWithTag:(NSString *)tag
       completionHandler:(void (^)(NSArray* items, NSError* error))handler
{
  [QiitaAPI loadItemsWithTag:tag completionHandler:handler baseURL:@"https://qiita.com"];
}

+ (void)loadItemsWithTag:(NSString *)tag
       completionHandler:(void (^)(NSArray* items, NSError* error))handler
                    baseURL:(NSString *)aBaseURL
{
  NSString* url = [NSString stringWithFormat:@"%@/api/v1/tags/%@/items", aBaseURL, tag];
  NSURLRequest* req = [NSURLRequest requestWithURL:[NSURL URLWithString:url]];
  
  [NSURLConnection sendAsynchronousRequest:req queue:[NSOperationQueue mainQueue] completionHandler:^(NSURLResponse *resp, NSData *data, NSError *err) {
    if(err){
      handler(nil, err);
      return;
    }
    
    id object = [NSJSONSerialization JSONObjectWithData:data options:NSJSONReadingAllowFragments error:&err];
    if(err){
      NSLog(@"[ERROR]: %@,\n"
            "%@",
            [err description],
            [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]);
    }
    handler(object, err);
  }];
}

最終的にテストがこう書ける。

- (void)testRequest
{
  NSData* body = [TestHelper readResponseData:@"fixtures/item.json"];
  
  [[[self.server stub] forPath:@"/api/v1/tags/iOS/items"] andJSONResponse:body];
  
  [QiitaAPI loadItemsWithTag:@"iOS" completionHandler:^(NSArray *items, NSError *error) {
    STAssertNil(error, nil);
    STAssertEquals(items.count, 1U, nil);
    
    NSDictionary* item = [items lastObject];
    
    STAssertEqualObjects([item objectForKey:@"id"], [NSNumber numberWithInt:10884], nil);
    STAssertEqualObjects([item objectForKey:@"title"], @"keyboardWasShownに関して", nil);
    STAssertEqualObjects([item objectForKey:@"url"], @"http://qiita.com/items/ac6577db608748bb3a78", nil);
    
    [self notify:SenAsyncTestCaseStatusSucceeded];
  } baseURL:@"http://localhost:12345"];
  [self waitForTimeout:3];
}

まとめ

Nocilla, OHHTTPStubs あたりは使いやすくてMethod Swizzlingより副作用もなくて、内蔵ウェブサーバーより手軽なので、まずこの2つから候補にして使ってみるといいでしょう。

パッと見ユーザー多そうなのはNocillaの方です。