Hatena::ブログ(Diary)

yaakaito::Blog RSSフィード

2012-02-07

ViewControllerがモデルを持つから微妙なのでコントローラーにViewController持たせてみる(もちろんテストも書くよ)

タバコ吸っててちょっと思いついたのでやってみた。

感じとしてはViewControllerは完全にViewの一部だと見なしてしまって、まったく新しくコントローラーを作ろうという具合。試作段階、うまく行くかもしれない、くらい。

以下、説明を楽にするために一旦完全にViewControllerを悪者にして書くので、その辺はご理解ください。

コードは前のテストの時に使ってた簡易Twitterクライアントを使い回し、コードはここ

まず最初に

アプリを書くと一番最初に手を付けるのがいわゆるプロジェクト名ViewController的なやつだとおもうのですが、コイツがよくない。最初からなんでビューいじらなきゃいけないのか。ただiOSアプリにビューがあるのは正しいので一旦コイツを大元のビューにすることにしよう。

で、どっから書くかという感じになるのですが、AppDelegateとかに書いて行くのも微妙な気がするので、 didFinishLaunchingなんかでエントリーポイントになるクラスを作ってもらうようにしました。

// TDDAppEntry.h
@interface TDDAppEntry : NSObject {
    
    UIViewController *_rootViewController;
    
    @private
    PublicTimeLineController *_publicTimeLine;
}
@property(nonatomic,retain) UIViewController *rootViewController;

- (void)boot;
- (void)reboot;
@end
//TDDAppEntry.m
#import "TDDAppEntry.h"

@implementation TDDAppEntry
@synthesize rootViewController;

- (void)boot {
    // 起動時に呼ばれる
}

- (void)reboot {
    // 終了時に呼ばれる
}
@end

終了時に呼ばれるメソッドはもう一回アプリを立ち上げてほしいという願いを込めてrebootにしてみました、嘘です、間違えたけど直すのが面倒なだけです。

こいつをAppDelegateのプロパティに追加して、didFinishLaunchingで

    _appEntry = [[TDDAppEntry alloc] init];
    _appEntry.rootViewController = self.viewController;
    [_appEntry boot];

という具合でさっきのViewControllerの参照をもらってbootを呼ぶ、という具合にしました。

新しいコントローラーを追加する(ViewControllerじゃないよ!)

とりあえず前に作らなかったパブリックライムラインを実際にテーブルで表示したいと思います。

こいつを表示するために「PublicTimeLineController」を作りました。Viewは付けちゃダメ。

このコントローラーがViewControllerを間接的に操作します。大まかな役割は

  • 表示に必要な情報をモデルから中継
  • 表示をリクエスト
  • ViewControllerからのイベントを受け取って何かする

だと思ってください。

このコントローラーがモデルとViewControllerを操作するので、プロパティにTwitterClientとViewControllerを入れておきましょう。PublicTimeLineViewController(ただのUITableViewController)も作って以下のようにしました。addSubViewしやすいように、viewのゲッターも作っておきました。

// PublicTimeLiveController.h
#import "PublicTimeLineViewController.h"
#import "TwitterClient.h"

@interface PublicTimeLineController : NSObject {
    
    PublicTimeLineViewController *_viewController;
@private
    TwitterClient *_twitterClient;
}
@property(nonatomic,retain) PublicTimeLineViewController *viewController;
@property(nonatomic,retain) TwitterClient *twitterClient;

- (UIView*)view;
@end
//PublicTimeLineViewController.m
#import "PublicTimeLineController.h"

@implementation PublicTimeLineController

@synthesize viewController = _viewController;
@synthesize twitterClient  = _twitterClient;

- (id)init {
    self = [super init];
    if(self){
        self.viewController = [[PublicTimeLineViewController alloc] initWithNibName:@"PublicTimeLineViewController" bundle:nil];
        self.twitterClient  = [[TwitterClient alloc] init];
    }
    return self;
}

- (void)dealloc {
    
    [self.viewController release];
    self.viewController = nil;
    [self.twitterClient release];
    self.twitterClient  = nil;
    
    [super dealloc];
}

- (UIView*)view {
    return self.viewController.view;
}
@end

こいつをTDDAppEntryで

    _publicTimeLine = [[PublicTimeLineController alloc] init];
    [self.rootViewController.view addSubview:_publicTimeLine.view];

という具合で作ってあげます。そうすると、なんとなくビューが表示されるはずですね。

次にViewControllerからユーザーの操作を受け取りましょう。

@protocol PublicTimeLineInterface
- (NSInteger)tweetCount;
- (Tweet*)tweetForIndexPath:(NSIndexPath*)indexPath;
- (void)reload;
@end

こんな感じでプロトコルを宣言して

@interface PublicTimeLineController : NSObject<PublicTimeLineInterface>

PublicTImeLineControllerがこれを実装します。

- (NSInteger)tweetCount {
    
    return [self.twitterClient.tweets count];
}

- (Tweet*)tweetForIndexPath:(NSIndexPath *)indexPath {
    return [self.twitterClient tweetForIndexPath:indexPath];
}

- (void)reload {
    NSLog(@"reload");
    [self.twitterClient requestPublicTimeline:^(TwitterClientResponseStatus status) {
        if(status == TwitterClientResponseStatusSuccess){
            NSLog(@"success");
            [self.viewController.tableView performSelector:@selector(reloadData)];
        }
    }];
}

ViewController側の呼び出しは

//PublicTimeLineViewController.h
@interface PublicTimeLineViewController : UITableViewController {
    NSObject<PublicTimeLineInterface> *_controller;
}

@property(nonatomic,assign) NSObject<PublicTimeLineInterface> *controller;

@end
//PublicTimeLineViewController.m
@implementation PublicTimeLineViewController

@synthesize controller = _controller;
/.. 中略 ../
- (void)viewDidLoad
{
    [super viewDidLoad];
    [self.controller reload];
}
/.. 中略 ../

- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
{
    // Return the number of rows in the section.
    return [self.controller tweetCount];
}

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
    static NSString *CellIdentifier = @"Cell";
    
    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:CellIdentifier];
    if (cell == nil) {
        cell = [[[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:CellIdentifier] autorelease];
    }
    cell.textLabel.text = [[self.controller tweetForIndexPath:indexPath] text];
    return cell;
}
/.. 中略 ../
@end

self.controller を経由して呼び出します。こうすると、ViewControllerにはほとんど実装がないことになりますね。

controllerに関連付けする必要があるので

// PublicTimeLineController
- (id)init {
    self = [super init];
    if(self){
        self.viewController = [[PublicTimeLineViewController alloc] initWithNibName:@"PublicTimeLineViewController" bundle:nil];
        self.viewController.controller = self;
        self.twitterClient  = [[TwitterClient alloc] init];
    }
    return self;
}

という具合でアサインしてあげます。

これで、なんとなく要件を満たしたコントローラーが作れましたね。

テストはどうなのか

ViewControllerをほげほげする必要がないので、LogicTestで十分ですね。

ほとんどが中継なので、こんな感じで済むはずです。TwitterClientの生成は前の記事からそのまま持ってきました。

- (void)testView {
    STAssertEquals(controller.view, controller.viewController.view, @"viewがviewControllerのviewを指しているか");
}

- (void)testInterfaces {
    
    TwitterClient *client = [[TwitterClient alloc] init];
    
    __block BOOL calledBack = NO;
    
    NSString *path = [[NSBundle bundleForClass:[self class]] pathForResource:@"public_time_line" ofType:@"json"];
    NSData *jsonData = [NSData dataWithContentsOfFile:path];
    NSError *error=nil;
    NSDictionary *json = [NSJSONSerialization JSONObjectWithData:jsonData options:NSJSONReadingAllowFragments error:&error];    
    STAssertNil(error, @"jsonの読み込みに失敗");
    
    [client __setMockResponse:json];
    
    [client requestPublicTimeline:^(TwitterClientResponseStatus status) {
        
        calledBack = YES;
    }];
    
    NSDate *loopUntil = [NSDate dateWithTimeIntervalSinceNow:10];
    while (calledBack == NO && [loopUntil timeIntervalSinceNow] > 0) {
        [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode
                                 beforeDate:loopUntil];
    }
    
    STAssertEquals(20U, [[client tweets] count], @"20件取得するはずなのでcountが20になってほしい");
    
    controller.twitterClient = nil;
    controller.twitterClient = client;
    
    STAssertEquals(20, [controller tweetCount], @"20件あるはず %d", [controller tweetCount]);
    NSIndexPath *indexPath = [NSIndexPath indexPathForRow:1 inSection:0];
    STAssertEqualObjects([client tweetForIndexPath:indexPath], [controller tweetForIndexPath:indexPath], @"tweetが一致しない");
    
    controller.twitterClient = nil;
    [client release];
}

で、どうなの

ViewControllerでやってることがほとんどコントローラーに来るはずなので、割といいかな、と思いました。ViewControllerのテストは疎通のテストだけでほとんどカバーできるようになるはず。

問題はいちいちプロトコルつくってーあれしてこれしてーみたいな手順を普段よりたくさん踏まなければいけないので、続かなさそう。これで統一できれば結構よい感じがするけれど、途中で破綻してしまうと最悪な感じになりそうですね。

という感じでした。

GHUnitで非同期テストな話

しばらく開発からはなれてましたがやっと帰ってきました!

iOSと非同期テストな話 - yaakaito::Blog

でOCUnitで無理やり非同期テストを書いてみましたが、GHUnitだともっと綺麗にかけます。フレームワークの支援が受けられる分、楽でいいですね。

流れとしては

  • prepare
  • waitForStatusで待つ
  • 非同期処理内でnotifyを呼び出して戻る

という感じです

実装を見た感じはiOSと非同期テストな話 - yaakaito::Blogで解説してる下の方(自分でがんばる)と似たような感じだったので、メインスレッドに行ってしまっても大丈夫です。

    [self prepare];
    dispatch_queue_t global_queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
    
    dispatch_async(global_queue, ^{
        [self notify:kGHUnitWaitStatusSuccess];
    });
    
    [self waitForStatus:kGHUnitWaitStatusSuccess timeout:10.0f];
    [self prepare];
    dispatch_queue_t global_queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
    dispatch_queue_t main_queue = dispatch_get_main_queue();
    
    dispatch_async(global_queue, ^{
        // ...
        dispatch_async(main_queue, ^{
            //  dispatch_semaphoreだとここで昇天する
            dispatch_async(global_queue, ^{
                // もういっかい!
                dispatch_async(main_queue, ^{
                    [self notify:kGHUnitWaitStatusSuccess];
                });
            });
        });
    });
    
    [self waitForStatus:kGHUnitWaitStatusSuccess timeout:10.0f];

サンプルコードの雑さが目立ちますね!

追記

timeoutするとテストにちゃんと失敗してくれるので、便利ですね。

2012-01-20

iOSアプリのテストをJenkinsでまわす

A-Liaison BLOG: Jenkins を iOS アプリ開発に導入してみた (SenTestKit編)

紹介されていました、ちょっとづつiOSのテストあたりの話も出始めて、いい感じになってきてますね!

2012-01-19

iOSのテストで非同期のHTTPリクエスト結果をモックしたい

先に結論

期待して見てる方がいるかもしれないので先に結論を。(最後まで読むつもりの人もここで書いてる説明は飛ばすので軽く読んでおいた方がいいかも)

AFNetworkingとASIHttpRequestで試してみたのですが、結果的に綺麗に実装はできませんでした。両ライブラリとも非同期通信をサポートしていますが、ライブラリの提供する非同期通信は使わないのが一番綺麗な気がします。(別エントリで書きます) 同期で通信して、それを返してくれるメソッドがあれば、OCMockなりでそこを差し替えてしまえばよいので、あとはdispatch_asyncなりで裏に送ってやれば同じことができるはずです。無理矢理やろうと思えばできなくもない(ASIHttpRequestで一度やったことはある(後述))のですが、無理してもテストコードの品質が下がる一方ですし、よくないと思いました。

NSURLがローカルのファイルを指せて、NSURLRequestもfile://をサポートしているはずなので、NSURLConnectionのレベルまで落とせばできないこともないような感じだったのですが、今時HTTP通信程度でこいつを直に叩くのもどうなのかと思ったので試してないです。というよりは上記ライブラリがこれに対応すれば・・・とか考えましたが、人任せにするといつになっても終わらないので(奴は仕事ができないとかそういう意味ではない)、一旦あるものでなんとかしてみようと思いました。(言い訳)

結局のところ、割り切ってテスト対象のクラスをそれができるように実装してしまうか、簡単にHTTPサーバー立てる何かを準備するか、ASIHttpRequestの場合ならテストの時だけresponseDataを乗っ取る(一度試した)、の3択という感じだったのですが、テストにフィーチャーしてるので、テストを綺麗に、簡潔にという意味でテスト対象のクラスがうまいこと面倒みてくれる、という方針を取りました。いい感じの方法があればコメント、はてブコメントあたりはみてるので、教えてください。

経緯

前回のTDD的にiOSアプリケーション開発してみる - yaakaito::Blogでテストの毎にTwitterAPIに問い合わせていたら、毎回レスポンスは変わりますし、その度にテストが通ったり通らなかったりだとか、Twitterが落ちていたらテストにならないだとか、いろいろ問題がありますね。(前回は量の関係で割愛してます。)常に同じ結果を得て、繰り返しテストができるようにするためにはAPIから返されるレスポンスを常に同じにする必要があります。ここでレスポンスをモックしたいわけですが、上記の通りちょっと変えてテストコードでなんとかするみたいなのはうまくいかなかったので、割と大幅に変える必要があります。

コードは引き続きここです

モックのレスポンスを用意する

とりあえず、データがないと話にならないので、前回使用した

https://api.twitter.com/1/statuses/public_timeline.json?count=20
http://search.twitter.com/search.json?q=yaakaito&rpp=20&result_type=mixed

を直接叩いて内容を保存しましょう。ところで、前回通ると思ったテストが通らないパターンがありましたね。TwitterAPIが20件と要求しているにもかかわらず21件以上のツイートを返していました。その動作に対して、まるめ処理を書いたはずなので、テストもできるようにしておきましょう。

http://search.twitter.com/search.json?q=iOS&rpp=20&result_type=mixed

保存したら、これらをプロジェクトに追加しましょう。僕の場合はTDDTest/Mockというグループを作って、そこに追加しました。関連付けるターゲットはテストのみです。

f:id:yaakaito:20120119071010p:image

テスト対象(TwitterClient)のクラスでレスポンスを偽装できるようにする

実際の実装は様々だと思いますし、考えつく限り一番簡単で大雑把な方法なのですが、

- (void)__setMockResponseJSON:(id)json
{
    if(__mockResponse){
        [__mockResponse release];
    }
    __mockResponse = [json retain];
}

- (id)__response:(id)response
{
    if(__mockResponse){
        return __mockResponse;
    }
    
    return response;
}

このようなメソッドをTwitterClientに用意してあげて、

    AFJSONRequestOperation *operation = [AFJSONRequestOperation JSONRequestOperationWithRequest:request
                                                                                        success:^(NSURLRequest *request, NSHTTPURLResponse *response, id JSON){
                                                                                            
                                                                                            [_tweets removeAllObjects];
                                                                                            NSArray *tweetsJSON = [self __response:JSON]; //モックが登録されていればそちらを利用
                                                                                            for (id tweetJSON in tweetsJSON) {
                                                                                                [_tweets addObject:[Tweet tweetWithJSON:tweetJSON]];
                                                                                            }
                                                                                            
                                                                                            callback(TwitterClientResponseStatusSuccess);
                                                                                            
                                                                                        }
                                                                                        failure:^(NSURLRequest *request, NSHTTPURLResponse *response, NSError *error, id JSON){
                                                                                            
                                                                                            callback(TwitterClientResponseStatusFail);
                                                                                        }];

という風にして、レスポンスを利用する前に差し替える事ができる機能を追加します。ただこの場合だと404などが返ってきたときに、failureに入ってしまうので、ちょっと微妙ですね。

テストを直す

では、これにあわせてテストを書きましょう。まずは、不安ですしモック機能のテストを書きましょう。

- (void)testMock
{
    TwitterClient *noMock = [[TwitterClient alloc] init];
    
    STAssertEqualObjects(@"モックしない", [noMock __response:@"モックしない"], @"モックしないはずなので モックしない になってほしい");
    
    [noMock release];
    
    TwitterClient *mock = [[TwitterClient alloc] init];
    [mock __setMockResponse:@"モック"];
    
    STAssertEqualObjects(@"モック", [mock __response:@"モックできていない"], @"モックするので モック になってほしい");
    
    [mock release];
}

こんな感じでしょうか、これが通れば一旦モックはうまく動いているはずですね。

では、他の部分をモックのレスポンスに置き換えましょう。NSBundelを使って、ファイルを読み出しましょう。テストはmainBundleではないので、テストクラスからバンドルを得ます。

NSString* path = [[NSBundle bundleForClass:[self class]] pathForResource:@"public_time_line" ofType:@"json"];
NSString* fileContents = [NSString stringWithContentsOfFile:path];

これでファイルを読み込めますが、NSStringで返ってくるので、このJSONを解釈してNSDictionaryにする必要がありますね。SBJsonなんかが便利ですが、iOS5以降は標準でNSJSONSerializationがついてくるので、せっかくなのでこっちを使ってみましょう。NSJSONSerializationの場合はNSStringではなくNSDataなので、ファイルもNSDataで読み込むようにします。

    NSString *path = [[NSBundle bundleForClass:[self class]] pathForResource:@"public_time_line" ofType:@"json"];
    NSData *jsonData = [NSData dataWithContentsOfFile:path];
    NSError *error=nil;
    NSDictionary *json = [NSJSONSerialization JSONObjectWithData:jsonData options:NSJSONReadingAllowFragments error:&error];    
    STAssertNil(error, @"jsonの読み込みに失敗");

結構簡単ですね!これで読み込んだjsonをモックとして使ってあげると、こんな感じのテストになります。

- (void)testRequestPublicTimeline
{
    TwitterClient *client = [[TwitterClient alloc] init];

    __block BOOL calledBack = NO;
    
    NSString *path = [[NSBundle bundleForClass:[self class]] pathForResource:@"public_time_line" ofType:@"json"];
    NSData *jsonData = [NSData dataWithContentsOfFile:path];
    NSError *error=nil;
    NSDictionary *json = [NSJSONSerialization JSONObjectWithData:jsonData options:NSJSONReadingAllowFragments error:&error];    
    STAssertNil(error, @"jsonの読み込みに失敗");

    [client __setMockResponse:json];
    
    [client requestPublicTimeline:^(TwitterClientResponseStatus status) {

        calledBack = YES;
    }];
    
    NSDate *loopUntil = [NSDate dateWithTimeIntervalSinceNow:10];
    while (calledBack == NO && [loopUntil timeIntervalSinceNow] > 0) {
        [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode
                                 beforeDate:loopUntil];
    }
    
    STAssertEquals(20U, [[client tweets] count], @"20件取得するはずなのでcountが20になってほしい");
    
    [client release];
}

先のtestMockが通っていれば、モック機能がきちんと動いているはずなので、このテストは読み込んだjsonをレスポンスとして実行されるはずです。検索とかも同じ要領で変更しましょう。


- (void)testSearchWithWord
{
    TwitterClient *client = [[TwitterClient alloc] init];
    
    __block BOOL calledBack = NO;
    
    NSString *path = [[NSBundle bundleForClass:[self class]] pathForResource:@"search_yaakaito" ofType:@"json"];
    NSData *jsonData = [NSData dataWithContentsOfFile:path];
    NSError *error=nil;
    NSDictionary *json = [NSJSONSerialization JSONObjectWithData:jsonData options:NSJSONReadingAllowFragments error:&error];    
    STAssertNil(error, @"jsonの読み込みに失敗");
    
    [client __setMockResponse:json];
    
    [client searchWithString:@"yaakaito" callback:^(TwitterClientResponseStatus status){
        calledBack = YES;
    }];
    
    NSDate *loopUntil = [NSDate dateWithTimeIntervalSinceNow:10];
    while (calledBack == NO && [loopUntil timeIntervalSinceNow] > 0) {
        [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode
                                 beforeDate:loopUntil];
    }
    
    STAssertTrue([[client tweets] count] <= 20, @"20件以下で検索結果を取得してほしい");
    
    [client release];
}

- (void)testSearchWithWord_TwittersBug
{
    TwitterClient *client = [[TwitterClient alloc] init];
    
    __block BOOL calledBack = NO;
    
    NSString *path = [[NSBundle bundleForClass:[self class]] pathForResource:@"search_ios" ofType:@"json"];
    NSData *jsonData = [NSData dataWithContentsOfFile:path];
    NSError *error=nil;
    NSDictionary *json = [NSJSONSerialization JSONObjectWithData:jsonData options:NSJSONReadingAllowFragments error:&error];    
    STAssertEquals(23U, [[json objectForKey:@"results"] count], @"23個あるか確認");
    STAssertNil(error, @"jsonの読み込みに失敗");
    
    [client __setMockResponse:json];
    
    [client searchWithString:@"iOS" callback:^(TwitterClientResponseStatus status){
        calledBack = YES;
    }];
    
    NSDate *loopUntil = [NSDate dateWithTimeIntervalSinceNow:10];
    while (calledBack == NO && [loopUntil timeIntervalSinceNow] > 0) {
        [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode
                                 beforeDate:loopUntil];
    }
    
    STAssertEquals(20U, [[client tweets] count], @"20件以上取得するはずなので20個にまるめてほしい :-|");
    
    [client release];
}

- (void)testTweetForIndexPath
{
    TwitterClient *client = [[TwitterClient alloc] init];

    __block BOOL calledBack = NO;
    
    NSString *path = [[NSBundle bundleForClass:[self class]] pathForResource:@"public_time_line" ofType:@"json"];
    NSData *jsonData = [NSData dataWithContentsOfFile:path];
    NSError *error=nil;
    NSDictionary *json = [NSJSONSerialization JSONObjectWithData:jsonData options:NSJSONReadingAllowFragments error:&error];    
    STAssertNil(error, @"jsonの読み込みに失敗");
    
    [client __setMockResponse:json];
    
    [client requestPublicTimeline:^(TwitterClientResponseStatus status) {
        
        calledBack = YES;
    }];
    
    NSDate *loopUntil = [NSDate dateWithTimeIntervalSinceNow:10];
    while (calledBack == NO && [loopUntil timeIntervalSinceNow] > 0) {
        [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode
                                 beforeDate:loopUntil];
    }
    
    NSIndexPath *indexPath = [NSIndexPath indexPathForRow:0 inSection:0];
    Tweet *tweet = [client tweetForIndexPath:indexPath];
    
    STAssertNotNil(tweet, @"なんか帰ってきてほしい");
    
    [client release];
}

testSearchWithWord_TwittersBugでは念のため、元のjsonにちゃんとツイートが23個含まれているかをチェックしています。

全部、緑になりましたか?なった人はおめでとうございます!これで、常に同じテストを何回も回せるようになりましたね!

f:id:yaakaito:20120119071009p:image