iOSアプリケーション開発におけるテスト(非同期処理編)

仕事としてiOSアプリケーションの開発をはじめて1年立ちました。ちょうど一年前に現在のプロジェクトに入った時、真っ先にテストの導入を考えたのですが、当時は技術的な問題のためにコスト対効果が低いとの結論に至り断念しました。しかし、iOSのバージョンが4になり、Xcode4が登場し、またチームへの新しいメンバーの参加によって状況がかわり、テスト導入の新しい方向性が見えてきました。一ヶ月ほど試してみて、効果が見えてきたので記事にしておきます。

テストと非同期処理

iOS3の時代にも標準のOCUnitの他にiUnitTestを使って、テストを導入しようと試みましたが、Pankiaの実装の多くを占める非同期処理を効率的にテストする方法がなく断念していました。iOS3の時代、通常非同期処理はデリゲートかNotification centerを使って実装されていましたが、この実装はテストを書く上でとても都合が悪い。
例えば引数に渡したURLの内容をダウンロードしてくる

- (void)wget:(NSString*)url;

というメソッドがあり、ダウンロードが完了するとデリゲートメソッド

- (void)download:(id)sender didDone:(NSString*)contents;

が呼ばれるとします。
このメソッドを使ってYahooとGoogleのトップページをダウンロードしてくるテストケースを書こうとすると

- (void)download:(id)sender didDone:(NSString*)contents {
  if (sender == wgetter1) {
    // yahooの時のテストの評価
    [wgetter1 release], wgetter1 = nil;
  }
  //googleのときの場合...(略)
}
- (void)testYahoo {
  wgetter1 = [[WGetter alloc] init];
  [wgetter1 wget:@"http://www.yahoo.com/"];
  [self waitForResponse];
}
- (void)testGoogle {
  wgetter2 = [[WGetter alloc] init];
  [wgetter2 wget:@"http://www.google.com/"];
  [self waitForResponse];
}

のような形で無理矢理書く事はできますが、この例ではテストが増えれば増えるほど- (void)download:(id)sender didDone:(NSString*)contentsの中身が増大していき可読性が下がっていきます。また、「YahooをダウンロードしてからGoogleをダウンロードするテスト」のように二つの非同期処理を絡めたテストを書こうとすると、どんどんテストの記述が複雑になっていきます。

- (void)responseForYahoo:(NSString*)content {
}
- (void)testYahoo {
  wgetter1 = [[WGetter alloc] init];
  [wgetter1 wget:@"http://yahoo.com"
       onSuccess:@selector(responseForYahoo:)];
}

デリゲートメソッドをセレクタで指定できるようにする、という方法も考えられます。しかしこの方法を使うと、今度はプロトコルの機構を使う事ができなくなるためビルド時チェックができなくなってしまうという別の問題がでてきます。
どちらの方法でも、一つのテストケースに対する記述が複数箇所に分かれてしまうため、テストケースが増えてくると可読性が失われ効率が下がってしまいます。

ブロック構文

これらの問題は、iOS4から使えるようになったブロック構文を使う事で解決できます。
例えば上記のwgetのテストも

- (void)testYahoo {
  WGetter* getter = [[[WGetter alloc] init] autorelease];
  [getter wget:@"http://yahoo.com/" onSuccess:^(NSString* content) {
    // yahoo.comの内容がダウンロードできたかテストする
  }];
}
- (void)testGoogle {
  WGetter* getter = [[[WGetter alloc] init] autorelease];
  [getter wget:@"http://google.com/" onSuccess:^(NSString* content) {
    // google.comの内容がダウンロードできたかテストする
  }];
}

のように、シンプルに書く事ができるようになります。ブロックを使った記述では、テストをする条件とその結果を評価する処理を同一のメソッド内に記述することができるため、直感的にテストを記述/読むことができます。

- (void)testGoogleAfterYahoo {
  WGetter* getter = [[[WGetter alloc] init] autorelease];
  [getter wget:@"http://yahoo.com/" onSuccess:^(NSString* content) {
    // yahoo.comの内容がダウンロードできたかテストする
    WGetter* getter2 = [[[WGetter alloc] init] autorelease];
    [getter2 wget:@"http://google.com/" onSuccess:^(NSString* content) {
      // google.comの内容がダウンロードできたかテストする
    }];
  }];
}

また、「Yahooを読みにいってからGoogleを読みにいく」ような機能をチェーンするようなテストも簡単に記述できます。

セマフォ

通常、テストケースはメソッドの終わりに到達するとそのケースは完了となり、次のテストケースへと処理がうつっていきます。しかし非同期処理は、処理のレスポンスが返ってくるまでテストが成功したかどうか評価することができないため、レスポンスが返ってくるまでテストケースの処理を待機させておく必要があります。
whileループとフラグを使って実現する方法もありますが、iOS4ではGCDのセマフォを使うとこの待機もスマートに記述できます。

- (void)testYahoo {
  WGetter* getter = [[[WGetter alloc] init] autorelease];
  dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
  [getter wget:@"http://yahoo.com" onSuccess:^(NSString* content) {
    // contentが正しいか、ここでチェックします。
    dispatch_semaphore_signal(semaphore);
  }];
  dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);
  dispatch_release(semaphore);
}

結果

上記のブロック構文とセマフォの機構を使って低コストで非同期処理のテストを記述できるようになったこと、またXcode4になりSchemeの仕組みが入った事によりテストの管理がしやすくなったことから、試しにテストを導入してみて一ヶ月経ちました。
テスト自体の実装コスト/管理コストが大きいとテスト駆動開発は根付かないと考えていますが、今のところテスト駆動開発は継続されていて効果も少しずつ見られるようになってきました。テストによるメリットは他の言語におけるそれとかわりませんが、

  • バグの発生が早い段階で検知/対処できるようになったこと
  • 実装があやしいなと思ったときや問題が発生したときの検証コストが下がったこと
  • 実装者がなにを意識してコードを書いたかわかりやすくなったこと
  • メソッド/クラスの設計がシンプルになってきたこと(役割/挙動が明瞭になってきたこと)

等様々なメリットが少しずつですが実際に感じられるようになってきました。様々な要因が考えられるため簡単には判断できませんが、テスト駆動開発を導入してから実装された箇所については発生するトラブルの数がとても少ないです。

課題

この方法だけですべての問題が解決できたわけではありません。テストはメインスレッドで動き、セマフォを使って処理を止めてしまうため、メインスレッドのランループを前提とした処理はこの方法ではテストできません。すなわち、UI周りの処理のテストは書く事ができません。
そして、上記の方法では必然的に非同期の処理のブロックはメインスレッド以外の箇所から呼ばれる形で実装していくことになります。iOSの標準ライブラリには、UI周りやGameKitのメソッド等、メインスレッドから操作しなければいけない機能が多数あるため、その部分だけメインスレッド上で動かすような処理を書いていく必要があり、注意が必要です。
また、シングルトンオブジェクトに関してはテストケースをまたいでインスタンスが生き残ってしまうため、前回テストケースの副作用が次のテストケースに発生してしまうことがあります。
以上のような問題もあり、まだテストを導入できる箇所は限られてはいますが、それでも通信処理など内部の重要な処理に関してテストが導入できるようになったのは、とても嬉しいニュースです。

Pocket

「iOSアプリケーション開発におけるテスト(非同期処理編)」への3件のフィードバック

オープンソースのテスト管理ツール TestLinkをさくらのレンタルサーバーに設置する | なんてこったいブログ へ返信する コメントをキャンセル

メールアドレスが公開されることはありません。 が付いている欄は必須項目です