こんにちは。iOSデベロッパーのタイラー・テープです。
データレイヤーとUIレイヤーの架け橋として、UIViewControllerはiOSのアプリケーション構造において膨大な役割を占めていると言えます。このようなコントローラーのロジックが複雑になればなるほど、コードが冗長になりがちなのは、周知の通りです。しかし、いざリファクタリングしよう決断しても、テストがなければバグが出てきそうで怖いですね。
残念ながら、コントローラーをテストするに当たって落とし穴が多々あり、「コントローラーはテスト不可能」とお手上げになってしまうエンジニアも世の中に少なくないのではないでしょうか。
それで、テスト・カバレージが充実した明日を祈願しつつ、今日はよくある落とし穴と、オススメの対応策をいくつか紹介しようと思います。
この記事のソースコードをすべて含んだプロジェクト・ファイルがこのリポにあります。
対策一覧
-viewDidLoad
を呼ぶ- テスト環境を汚さずに
NSUserDefaults
を弄る -viewDidAppear
を呼ぶ- 非同期挙動をダイレクトにテストする
- `UIAlertView{ の表示確認
GNHogeViewController
一例として、GNHogeViewControllerなるコントローラーを作ってみましょう。その挙動は、
- ユーザーの名前で挨拶する
- 紙飛行機が飛んでくるアニメーションを表示する
- その後、励ましのメッセージをUIAlertViewとして表示する
という単純なものになります。さて、実行の結果とソースコードは以下の通りです。
// GNHogeViewController.h #import <UIKit/UIKit.h> @interface GNHogeViewController : UIViewController @property (strong, nonatomic) IBOutlet UILabel *greetingLabel; @property (strong, nonatomic) IBOutlet UIImageView *hogeImageView; @end
// GNHogeViewController.m #import "GNHogeViewController.h" @implementation GNHogeViewController #pragma mark - UIViewController Method Overrides - (void)viewDidLoad { [super viewDidLoad]; NSString *username = [[NSUserDefaults standardUserDefaults] objectForKey:@"username"] ?: @"ピヨ"; self.greetingLabel.text = [NSString stringWithFormat:@"こんにちは、%@さん!", username]; self.hogeImageView.image = [UIImage imageNamed:@"logo.png"]; self.hogeImageView.contentMode = UIViewContentModeScaleToFill; self.hogeImageView.alpha = 0.f; } - (void)viewDidAppear:(BOOL)animated { [super viewDidAppear:animated]; [UIView animateWithDuration:3.0 animations:^{ self.hogeImageView.alpha = 1.f; self.hogeImageView.frame = CGRectMake(60.f, 350.f, 200.f, 120.f); } completion:^(BOOL finished) { UIAlertView *alert = [[UIAlertView alloc] initWithTitle:@"一言" message:@"今日もほげほげしく生きましょう!" delegate:nil cancelButtonTitle:@"(^―^)" otherButtonTitles:nil]; [alert show]; }]; } @end
Kiwiスペック作成
このポストでは、Kiwiというビヘイビア駆動開発向けのテスティング・フレームワークを使います。RSpec経験者の方は見慣れた構文になるでしょう。初めての方は、Kiwiのwikiでインストール方法と使用例をご覧になれます。
まず、テスト対象となるGNHogeViewControllerを生成して、nilでないことを確認します。
SPEC_BEGIN(GNHogeViewControllerSpec) describe(@"GNHogeViewController", ^{ __block GNHogeViewController *hogeVC; context(@"when it has been initialized", ^{ beforeEach(^{ hogeVC = [GNHogeViewController new]; }); it(@"should not be nil", ^{ [[hogeVC shouldNot] beNil]; }); }); }); SPEC_END
ここまでは大丈夫そうですが、すぐに最初の壁にぶつかってしまいます。
-viewDidLoadを呼ぶ
コントローラーのほとんどが -viewDidLoad
というメッソドでそのプロパティの初期化を行うので、ほかの挙動を試す前に、このメソッドを呼んでおく必要があります。しかし、外から直接 -viewDidLoad
を呼んでも、無視されるばかりです。
直撃が全く無効果なので、婉曲な方法で攻めます。コントローラーの -view
を呼ぶと、そのviewが自動的にロードされ、結果的に -viewDidLoad
内のコードが実行されます。以下のテスト(あるいはデバッガー)でこの事実が確認できます。
describe(@"GNHogeViewController", ^{ __block GNHogeViewController *hogeVC; context(@"when it has been initialized", ^{ beforeEach(^{ hogeVC = [GNHogeViewController new]; }); it(@"should not be nil", ^{ [[hogeVC shouldNot] beNil]; }); describe(@"-viewDidLoad", ^{ it(@"initializes its properties", ^{ [hogeVC view]; [[hogeVC.greetingLabel shouldNot] beNil]; }); }); }); });
テスト環境を汚さずにNSUserDefaultsを弄る
テストの対象となるコードが実行されるようになったので、続いて①の挙動をテストします。
各テスト・ケースに新しく清潔な環境を備えるのがテスティングの大前提なので、テスト間で状態を持ち続ける NSUserDefaults
を扱う場合には注意しなければなりません。例えば、次のコードをご覧ください。
context(@"if a username has been set in user defaults", ^{ beforeEach(^{ [[NSUserDefaults standardUserDefaults] setObject:@"フガ" forKey:@"username"]; [hogeVC view]; }); it(@"greets the user by that name", ^{ [[hogeVC.greetingLabel.text should] equal:@"こんにちは、フガさん!"]; }); }); context(@"if a username has not been set in user defaults", ^{ it(@"greets the user by a default name", ^{ [hogeVC view]; [[hogeVC.greetingLabel.text should] equal:@"こんにちは、ピヨさん!"]; }); });
上のテストが修了した後も、 NSUserDefaults
がフガという名前を登録したままなので、下のテストは通りません。これを避けるためには、上のブロックの前に以下のコードを入れて、環境を整えます。
beforeEach(^{ [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"username"]; }); afterEach(^{ [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"username"]; });
-viewDidAppearを呼ぶ
前述の-viewDidLoadと同様に、直接に呼んでも無効果です。ドキュメントでは
Notifies the view controller that its view was added to a view hierarchy.
とあるので、早速メインのビュー・ヒエラルキーに埋め込んでみましょう。
describe(@"-viewDidAppear", ^{ __block UINavigationController navCon; beforeEach(^{ navCon = [[UINavigationController alloc] initWithRootViewController:[UIViewController new]]; [UIApplication sharedApplication].delegate.window.rootViewController = navCon; [navCon presentViewController:hogeVC animated:NO completion:nil]; } }
こうすれば、-viewDidAppear内のコードがちゃんと実行されます。(-viewDidLoadももちろんその直前に実行されます。)このパターンは少しくどいので、何度も使う場合は以下のように独立したヘルパー関数にリファクタリングするのも良いでしょう。
void presentViewControllerInNavigationController(UIViewController *vc) { UINavigationController *navigationController = [[UINavigationController alloc] initWithRootViewController:[UIViewController new]]; [UIApplication sharedApplication].delegate.window.rootViewController = navigationController; [navigationController presentViewController:vc animated:NO completion:nil]; }
非同期挙動をダイレクトにテストする
-viewDidAppearの中では、アニメーションの結果を確認したいのですが、 アニメーションが終わる遥か前にテストが終了してしまうため、また困ってしまいます。
ここで、Kiwiの非同期テスティング機能を使うのが一つの手段ではありますが、その場合テストが終了するまで3秒以上かかるというデメリットがあります。 今回はKWCaptureSpyで、よりタイムリーなテストを書きます。
KWCaptureSpyは、指定のセレクターをスタブし、そのメソッドが実行された際、渡された引数を一つ自分のargumentプロパティに保存することができます。ここでは、UIViewの-animateWithDuration:animations:completion:に渡される二つのブロックを取るために、KWCaptureSpyを二つ設定し、-viewDidAppearを実行させます。
describe(@"-viewDidAppear", ^{ __block UINavigationController *navCon; __block void (^animationBlock)(); __block void (^completionBlock)(); beforeEach(^{ KWCaptureSpy *animationSpy = [UIView captureArgument:@selector(animateWithDuration:animations:completion:) atIndex:1]; KWCaptureSpy *completionSpy = [UIView captureArgument:@selector(animateWithDuration:animations:completion:) atIndex:2]; presentViewControllerInNavigationController(hogeVC, navCon); animationBlock = animationSpy.argument; completionBlock = completionSpy.argument; }); });
ブロックを手に入れれば、アニメーションの実行を待たず②の結果を確認できます。
it(@"fades in the image view", ^{ animationBlock(); [[theValue(hogeVC.hogeImageView.alpha) should] equal:@1]; });
UIAlertViewの表示確認
最後に、③の振る舞いをテストします。アラートの-showメソッドが呼ばれたことを確かめるのが目的ですが、アラートの変数をクラスの外から参照できません。
この場合、モックとスタブを巧みに利用すれば、望む結果が得られます。 [[UIAlertView alloc] initWithTitle:message:delegate:cancelButtonTitle:otherButtonTitles:]
が呼ばれたら、本物の UIAlertView
ではなく、我らのモックが返されるようにしたいです。
it(@"shows an inspirational message", ^{ UIAlertView *alertViewMock = [UIAlertView mock]; [UIAlertView stub:@selector(alloc) andReturn:alertViewMock]; [alertViewMock stub:@selector(initWithTitle:message:delegate:cancelButtonTitle:otherButtonTitles:) andReturn:alertViewMock]; [[alertViewMock should] receive:@selector(show)]; completionBlock(); });
このように、自前のモックを挿入することで、前に取っておいたブロックの挙動を確認できます。
終わりに
以上、コントローラーのテスティングにおけるいくつかの問題点を紹介しましたが、決してすべてを網羅していません。 iOSがテストしやすい環境とはまだまだ言いにくいですが、コミュニティの工夫やオプンソースプロジェクトのおかげで、徐々に改善しています。
その他のiOSテスティング関連プロジェクト
Quick - SwiftとObj-cの両方に対応するRSpecライクなテスティング・フレームワーク Specta - Kiwiによく似て、さらにshared behaviors機能で、複数のオブジェクトが同じ降るまいをするかテストできます。 Nocilla - iOSのHTTPスタブ Defactory - 簡単なモックが不十分な時には、ファクトリーを使いましょう。