Hatena::Groupiphone-dev

laiso

URLが変わりました (2013/03/08)

2013-03-09

URLが変わります

01:21 | はてなブックマーク -  URLが変わります - laiso

もうすでに終戦しているのに俺がジャングルに潜伏しているような状態になってはや1,2年。それ自体は別にいいんだけどはてなグループダイアリーの機能がだんだん貧弱に思えてきたのではてなダイアリーの方にポートしました。URL以外は別に変わりません。

トラックバック - http://iphone-dev.g.hatena.ne.jp/laiso/20130309

2013-03-08

『Test Driving iOS Development with Kiwi』を日本から買えるようにしてもらった

14:26 | はてなブックマーク -  『Test Driving iOS Development with Kiwi』を日本から買えるようにしてもらった - laiso

https://itunes.apple.com/jp/book/test-driving-ios-development/id502345143?mt=11

これ。

先日、日本のiBooksStoreからでも有料の電子書籍が買えるようになったみたいなので、著者の@dimsumthinkingに連絡して日本のストアでも買えるように設定してもらった(日本人には滅多に話しかけないんだけど外国人相手だと英会話の練習がてらに気軽に問い合せてる)。

トラックバック - http://iphone-dev.g.hatena.ne.jp/laiso/20130308

2013-03-06

ベンチマーク用の簡単なモジュールをつくった

15:56 | はてなブックマーク -  ベンチマーク用の簡単なモジュールをつくった - laiso

    [RRBenchmark benchmark:^(RRBRunner* runner){

      [runner report:@"NSUUID:UUIDString" context:^{
        // Code block 1
        NSString* uid = [[NSUUID UUID] UUIDString];
      }];

      [runner report:@"CFUUIDCreate" context:^{
        // Code block 2
        CFUUIDRef uuid = CFUUIDCreate(nil);
        NSString* uid =  (__bridge NSString*)CFUUIDCreateString(nil, uuid);
        CFRelease(uuid);
      }];

      [runner report:@"ooid" context:^{
        // Code block 3
        // http://sourceforge.net/projects/ooid/
        NSString* uid = [UUID generateV4];
      }];

    } iterations:10000];

https://github.com/laiso/RRBenchmark/raw/master/Documents/images/console-result.png

こういうの。

iOSアプリケーションを開発していてパフォーマンスを改善するフェーズにきて、Instruments やプロファイラやなんかでボトルネックを探してて、ああここが遅いなというところを発見して、じゃあこういう風に変えようという案があるんだけどその時点での計測がしたいという時にコードの中にしのびこませる使い方がしたい。

みんなどうやっているのかよく知らないけど(計測用のスニペットとかあるのかな)、そういう場面が度々あったのでRuby標準ライブラリのBenchmarkモジュールみたいなものが欲しいと思ってつくった。

俺のたいがい解説くんタイプなので開発最新情報とかには詳しいけど実装はアヤしいのでライブラリとして妥当な感じかどうかはわからないけど、レビューedがわりにもなるしいいだとうとそのまま開陳している次第です。


インディーズレーベルのCocoapods Specリポジトリでデビューする方法

15:56 | はてなブックマーク -  インディーズレーベルのCocoapods Specリポジトリでデビューする方法 - laiso

更新:
久しぶりにドキュメント確認したら、なんかもっといいやり方ありそうなので再考します:
https://github.com/CocoaPods/CocoaPods/wiki


Cocoapodsでインストールできるようにしたい、だが公式リポジトリに含めないという段階の時どういう手順を踏めばいいのかというのを他の人の参考になるよう書いてみます。

最終的にユーザーは`pod repo add`というコマンドで非公式なリポジトリを登録しておくことでpodコマンド経由でセットアップできるようになります。PHP PEARが一番イメージしやすいかも(非公式なリポジトリ使う場面が多いので)。

ライブラリを書く

通常のアプリを開発する時のような感覚でXcodeプロジェクトを作成します。ライブラリのビルド設定も要りません。

ここがCocoapods環境のライフチェンジングなポイントだと思っているんですが、Cocoapodsでのインストールを前提にしていると開発者はめんどうなインストール用のスクリプトやファットバイナリやFramework形式を容易する必要がなくなり、ライブラリユーザーはハマりがちな依存ライブラリのセットアップやXcode設定を一切しなくてよくなります(Win-Win)。

なのでMyLibraryExampleというサンプルアプリプロジェクトを作成し、依存ライブラリはPodfileに記述してインストールし、MyLibraryというディレクトリを作成し、ライブラリに含めるソースコードは全部その中に置くという手順を踏むだけでもう公開する準備が整います。

開発中はユーザー向けのソースコードをMyLibraryExampleに書き、開発者向けのソースコードをテストターゲットに追加して。

MyLibraryExample.appで結合テスト+Xcodeでユニットテストを実行するというスタイルにするとテンポよく実装できます。

あと最近知ったのですがGithubメンバーの人のObjective-Cプロジェクト(https://github.com/github/ 参照)はだいたいテストフレームワークに[Specta](https://github.com/petejkim/specta "petejkim/specta · GitHub")を採用しているようです。ライブラリ実装用の使いやすさもあるんでしょうが。


まずローカルでセットアップを完結できるかを確認

以前以下に投稿した記事のような感じで。~/.cocoapods/local にローカルの作業スペースを掘ります。

この時点ではpodspecを一時的に

    s.source       = { :git => '/Users/omae/workspace/MyLibray', :tag => 'v0.1.0'}

のようにしてローカルにあるgitブランチを指定すればいいだけなので、ネットワークレスに作業できます。


ライブラリを公開用Gitリポジトリに配備

Githubでもどこでもチェックアウトできる場所につくるだけです。詳細は省きます。

Cocoapods経由でインストールするのにtag付けされている必要があるので`git push --tags`でリモートブランチにも反映されるようにしておきます。

spec用のGitリポジトリを用意

GithubじゃなくてもいいですがGithub前提で進めます。

Githubを使うのは公式リポジトリがあってそれをフォークした方が、今後公式へpull-requestする際などに都合がいいからです。

https://github.com/CocoaPods/Specs を自分のアカウントでフォークしてください。

specリポジトリのbeta配布用のブランチを作成

このままでも使えはするんですが、公式のpodspec群がフルセットでコピーされてしまうので新たなブランチを作成して一旦真っ新な状態にします(masterは基本的にpull-request用のトピックブランチを作成する前に公式リポジトリの更新を取り込むだけ)。

    git checkout --orphan beta
    git rm -rf .

やってることはGithubPagesのプロジェクトページ用のブランチを手動でつくる時と同じ

ここにさっき~/.cocoapods/local でテストしたpodspecを持ってきてpush

    mv ~/.cocoapods/local/MyLibrary ./
    git push origin beta

インストールできるか確認

実際のライブラリユーザーは以下のようなコマンドを実行することになりますのでドキュメントなどに記述しましょう。

    # pod repo add ${cocoapods上のリポジトリ名} https://github.com/*/Specs.git ${gitブランチ名}
    pod repo add omae https://github.com/omae/Specs.git beta
    ls ~/.cocoapods/

    pod install
トラックバック - http://iphone-dev.g.hatena.ne.jp/laiso/20130306

2012-11-22

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

21:55 | はてなブックマーク -  iOS/OS Xテスト向けのURL読み込みに偽のレスポンスを返すモジュールいろいろ - laiso

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の方です。

トラックバック - http://iphone-dev.g.hatena.ne.jp/laiso/20121122

2012-10-13

Mac版KIFによるCocoaアプリケーションの自動受け入れテスト(実験的)

22:16 | はてなブックマーク -  Mac版KIFによるCocoaアプリケーションの自動受け入れテスト(実験的) - laiso

iOS向けの受け入れテストフレームワークはたくさん出揃っているんだけどMacアプリ向けのは全然話聞かないなあ、と思っていたので試してみた。

KIFとは

KIFはiOSアプリ向けの受け入れテストツール。ユーザーから見たアプリの正しい振舞いを検証する。ユーザー操作をエミュレーションするAPIを備えたライブラリを使ってシナリオを記述し、アプリに含めてビルドして実行する。


Mac版KIFとは

Mac版KIFはGithub上で@joshaberによってフォークされている。iOS版KIFをベースにいくつかのMac版のサポートコードが追加されている。


はじめよう

まずXcodeからテスト対象となるCocoaアプリケーションを作成します。今回は"KIFMac01"という面白気のない名前で作成しました。

仕様としてはウィンドウ中央にあるHelloボタンが押せるというだけのアプリケーションです。

MainWondows.xibを開いてPushボタンのパーツを追加します。そしてダブルクリックしてタイトルをHelloにします。これでアプリケーションは完成です(!)。

ビルド実行してアプリケーションがただしく使えるかを手動でテストしてみてください。

そうしたら次は自動テスト用のビルドの為に下準備をします。

一旦黒い画面を開いてコマンドラインからgit-submodule を利用して取得したMac版KIFのソースをプロジェクトにインポートします。

cd KIFMac01
git init
mkdir Vendors
git submodule add https://github.com/joshaber/KIF.git  Vendors/KIF-Mac

Xcode上で<Command-N>で Vendors/KIF-Mac/KIF.xcodeproj を取り込みましょう。xcodeprojを自分のプロジェクトに含めるコツは"Create folder references ..." で作成することです。そうすると自分のプロジェクトから子プロジェクトのターゲットや静的ライブラリ、フレームワークファイルなどが参照できます。

http://gyazo.com/5330e94b68865acdd6eed06c1feac810.png

通常アプリケーションとテスト実行ターゲットを分けるために、元のターゲットを複製します。

http://gyazo.com/7351228918fbdd2d6b603a2deba9bd04.png

作成したターゲットの依存ターゲットにKIFMacを追加、リンクバイナリにKIF.framewrokを追加します。

プリプロセッサマクロに RUN_KIF_TESTS=1 を設定し、共通のAppDelegateからテスト実行に分岐させます。

// AppDelegate.m

#import "AppDelegate.h"

#ifdef RUN_KIF_TESTS
#import "KMTestController.h"
#endif

@implementation AppDelegate

- (void)applicationDidFinishLaunching:(NSNotification *)aNotification
{
#ifdef RUN_KIF_TESTS
  [[KMTestController sharedInstance] startTestingWithCompletionBlock:^{
    exit([[KIFTestController sharedInstance] failureCount]);
  }];
#endif
}

@end

KMTestController はわたしたちがこれから作るテストコントローラーです。この中でテストシナリオを追加していきます。

以降はKIFMac01のアプリ本体のターゲットではなく、テスト用に作成したターゲットにソースを追加していきます。

Helloボタンを押すテストと、Goodbyテストを押すテストの2パターンを作ります。

自動テストの成功と失敗、栄光と挫折を体験する為にわざと失敗するテストも入れました。Goodbyボタンはまだ作ってないので押すことができない=失敗するはずです。

// KMTestController.h

#import <KIF/KIFMac.h>

@interface KMTestController : KIFTestController
@end
// KMTestController.m

#import "KMTestController.h"
#import "KIFTestScenario+Additions.h"

@implementation KMTestController

- (void)initializeScenarios;
{
  [self addScenario:[KIFTestScenario scenarioToHello]];
  [self addScenario:[KIFTestScenario scenarioToGoodBy]]; // GoodByボタンを設置してないので失敗するはず
}

@end

シナリオの具体的な処理はKIFTestScenarioのObject-Cカテゴリとして自分で作成します。標準で操作のタイムアウト(対象のViewが見付からないとか)が確か30秒ぐらいなので1秒に変更してます。

// KIFTestScenario+Additions.h

#import <KIF/KIFMac.h>

@interface KIFTestScenario (Additions)
+ (id)scenarioToHello;
+ (id)scenarioToGoodBy;
@end
// KIFTestScenario+Additions.m

#import "KIFTestScenario+Additions.h"
#import <KIF/KIFMac.h>

@implementation KIFTestScenario (Additions)
+ (id)scenarioToHello;
{
  KIFTestScenario *scenario = [KIFTestScenario scenarioWithDescription:@"Test that user can click 'Hello' button."];
  KIFTestStep* step = [KIFTestStep stepToClickViewWithTitle:@"Hello"];
  step.timeout = 1.0;
  [scenario addStep:step];
  
  return scenario;
}

+ (id)scenarioToGoodBy
{
  KIFTestScenario *scenario = [KIFTestScenario scenarioWithDescription:@"Test that user can click 'GoodBy' buton."];
  KIFTestStep* step = [KIFTestStep stepToClickViewWithTitle:@"GoodBy"];
  step.timeout = 1.0;
  [scenario addStep:step];
  
  return scenario;
}
@end

テストの手筈が整いました。ではテストターゲットを実行してみましょう。

以下のようにコンソールに結果が出力され、アプリケーションは自動で終了します。

2012-10-13 22:00:28.431 KIFMac01IntegeRationTests[9208:303] Logging KIF test activity to /Users/laiso/Library/Logs/KIF Tests Oct 13, 2012 10.00.28 PM GMT+09.00.log
2012-10-13 22:00:28.431 KIFMac01IntegeRationTests[9208:303] BEGIN KIF TEST RUN: 2 scenarios
2012-10-13 22:00:28.432 KIFMac01IntegeRationTests[9208:303]  
2012-10-13 22:00:28.433 KIFMac01IntegeRationTests[9208:303] ---------------------------------------------------
2012-10-13 22:00:28.433 KIFMac01IntegeRationTests[9208:303] BEGIN SCENARIO 1/2 (1 steps)
2012-10-13 22:00:28.434 KIFMac01IntegeRationTests[9208:303] Test that user can click 'Hello' button.
2012-10-13 22:00:28.435 KIFMac01IntegeRationTests[9208:303] ---------------------------------------------------
2012-10-13 22:00:29.052 KIFMac01IntegeRationTests[9208:303] PASS (0.62s): Click view with title "Hello"
2012-10-13 22:00:29.052 KIFMac01IntegeRationTests[9208:303] ---------------------------------------------------
2012-10-13 22:00:29.053 KIFMac01IntegeRationTests[9208:303] END OF SCENARIO (duration 0.62s)
2012-10-13 22:00:29.053 KIFMac01IntegeRationTests[9208:303] ---------------------------------------------------
2012-10-13 22:00:29.054 KIFMac01IntegeRationTests[9208:303]  
2012-10-13 22:00:29.054 KIFMac01IntegeRationTests[9208:303] ---------------------------------------------------
2012-10-13 22:00:29.055 KIFMac01IntegeRationTests[9208:303] BEGIN SCENARIO 2/2 (1 steps)
2012-10-13 22:00:29.055 KIFMac01IntegeRationTests[9208:303] Test that user can click 'GoodBy' buton.
2012-10-13 22:00:29.056 KIFMac01IntegeRationTests[9208:303] ---------------------------------------------------
2012-10-13 22:00:30.067 KIFMac01IntegeRationTests[9208:303] FAIL (1.01s): Click view with title "GoodBy"
2012-10-13 22:00:30.067 KIFMac01IntegeRationTests[9208:303] FAILING ERROR: Error Domain=KIFTest Code=0 "The step timed out after 1.00 seconds." UserInfo=0x100199920 {NSLocalizedDescription=The step timed out after 1.00 seconds., NSUnderlyingError=0x100119c80 "Failed to find accessibility element with the title "GoodBy""}
2012-10-13 22:00:30.068 KIFMac01IntegeRationTests[9208:303] ---------------------------------------------------
2012-10-13 22:00:30.068 KIFMac01IntegeRationTests[9208:303] END OF SCENARIO (duration 1.01s)
2012-10-13 22:00:30.068 KIFMac01IntegeRationTests[9208:303] ---------------------------------------------------
2012-10-13 22:00:30.069 KIFMac01IntegeRationTests[9208:303]  
2012-10-13 22:00:30.069 KIFMac01IntegeRationTests[9208:303] ---------------------------------------------------
2012-10-13 22:00:30.069 KIFMac01IntegeRationTests[9208:303] KIF TEST RUN FINISHED: 1 failures (duration 1.64s)
2012-10-13 22:00:30.070 KIFMac01IntegeRationTests[9208:303] ---------------------------------------------------
2012-10-13 22:00:30.070 KIFMac01IntegeRationTests[9208:303] *** KIF TESTING FINISHED: 1 failures

Helloボタンのクリックテストは成功し、Goodbyボタンの方はタイムアウトが発生し失敗したことがわかります。

例によってGithubにこのチュートリアルのソースコードを上げておいたのでうまくいかない時などにチェックアウトして参考にしてください。

Mac-Smaples/KIFMac01 at master · laiso/Mac-Samples · GitHub


まとめ

Mac版KIFが動いた。うれしい。

他に候補としてはSIKULI(画像処理を使った汎用的なインティグレーションツール)なんかが考えられるが、そもそもMac向けデスクトップ自動操作するソリューション自体は結構あるので、あとはそれに正しさを検証する仕組みが加わればいい感じもする。

ただMac版KIFも一年ぐらい前のKIFからフォークされたものでその後作者によって更新はされていないので最近のKIFの機能のサポートや将来性なんかには不安がありそう(明日のメンテナは君だ!)。

おまけ

iOS/Macアプリケーション開発で以下のスライド資料のような受け入れテストとクラスベースのユニットテストをサイクルに取り入れた開発を行いたいと思っているので。iOS/Mac向けテストツール・モックフレームワークなどの情報を日々もとめています。何かお得情報があったら@laisoなどへ教えてください。

テスト駆動開発の進化 (デブサミ関西 2012)

トラックバック - http://iphone-dev.g.hatena.ne.jp/laiso/20121013