yaakaito.org

iOS部で話したViewControllerのテストとかのまとめ

OCMock, Objective-C, SenTestingKit, Testing, iOS

こんにちは!うきょーです! この前iOS部というイベントでiOSテストの話をしたんですが、ViewControllerのテストで反応が結構あったのと、 ちゃんと説明できてない部分があったなーということで、文章としてまとめようと思います。

iOS部でやったものからコードが変わってますが、やりたいことは一緒なので、iOS部に参加されていた方は適宜変換して読んでもらえればと思います。

ツールとか

SenTestingKit + OCMockです。 OCMockの解説はiOS部では初めての方が居たので軽く説明しましたが、これを読んでる方はオンラインかと思いますので、分からなければググってください。 僕が古い方のブログで書いたものもあるので、そちらを読まれるとよいかもしれません。

ViewControllerの役割

最初にぶっちゃけておくとViewControllerのテストはしんどいです。できれば書きたくない。 なのでViewControllerが何なのか、きちんと整理しておきましょう。

僕はViewControllerって、

  • ビューからやってきたユーザー操作の実際の処理
  • モデルへの操作、モデルからのイベントなんかの受け取り、ビューへの反映

この2つ(3つ?)にフォーカスしてるべきだと思ってます。 なのでViewControllerって基本的にはステートとかをもっちゃ駄目で、そういうのはロジックとしてモデルで管理されるべきだと思います。

ここをちゃんと意識して書いていくとViewControllerのテストが結構楽になります。 モデルのテストはViewControllerと比較して圧倒的に楽なので、そちらでやりましょう。

モックを使いまくります

OCMockなんかのモックライブラリは便利でよく使いますが、僕はほとんどViewControllerのテストの為に使います。 ある程度モデルが完成した段階であれば、モックを使わずに記述していくこともまあ出来るんですが、 例えば「サーバーとの通信に失敗したときの動作」、みたいなのをテストするときに大抵めんどくさいことになります。 モックを使っておけばここは一行でサッパリと書いてしまったり出来るので、基本的には全部モックを差し込みます。

モデル自体が単純なバリューを表すものであればわざわざモックする必要もないかなーとは思うので、まあそのあたりは好みでいいかなと思います。

あとはUILabelとかUITextFieldとか、Storyboardとか使ってるとコードから生成されないものですね。 別にこのオブジェクト自体を作って自分でセットしてあげるようにしてもいいんですが、 さっきと同じような理由で、「ユーザーがHelloWorldと入力したときの動作」、みたいなのをテストするときに大抵めんd(ry

というわけで僕はViewControllerは基本的にモックを使って書いています。

プライベートとかreadonlyなプロパティに対するモックの差し込み

テストを始める前に、予備知識として知っている必要のある項目です。 これはViewController以外のテストでも応用できると思うので、わけで書きます。 正しいけどきわどいので、似たようなことをアプリ側でやるのはやめたほうがいいと思います。

これは無名カテゴリを使って、プロパティの宣言を上書きすることで実現します。

例えば

1
2
3
4
5
#import <UIKit/UIKit.h>

@interface MIViewController : UIViewController

@end
1
2
3
4
5
6
7
8
9
#import "MIViewController.h"

@interface MIViewController ()
@property (weak, nonatomic) IBOutlet UILabel *hogeLabel; // <- Stroyboardから作られる
@end

@implementation MIViewController

@end

という感じに.mの方でプライベートなプロパティとして宣言するとかよくあると思います。 この無名カテゴリをテストの方にも持ってきます。

1
2
3
4
5
6
7
8
9
10
11
12
13
@interface MIViewController ()
@property (weak, nonatomic) IBOutlet UILabel *hogeLabel;
@end

@implementation MIViewControllerTests

- (void)testExample
{
    MIViewController *viewController = [[MIViewController alloc] init];
    viewController.hogeLabel = nil; // <- 通る
}

@end

こうするとパブリックのようにメッセージを送ることが出来るので、モックに差し替えたりできるわけです。きわどい感じがしますね。

これは完全に雑感ですけど、標準ライブラリでもまあ、よく見ると @property(nonatomic, readonly, retain) みたいな宣言をしてあるものがあって、内部的にproperty(nonatomic, retain)になってたりするんじゃないかなーと思えるものもあるので、まあまあまあ。

というわけでテストを書きましょう

前置きが長くなりましたが、そろそろViewControllerのテストの話をしましょう。

サンプルとしてこんな感じの画面を使います。

この画面はモーダルとして呼び出されて、メモを作成&編集できる画面です。 以下のような要件で作られています。

  • タイトルと本文を入力できる
  • 「Done」を押したら、入力したタイトルと本文からメモオブジェクトを作って、delegateへ渡す。
  • すでに生成されているメモが渡されたら、それを初期値として表示する。

実際の実装としては、

  • 新規と編集の処理を同じにするために、このViewControllerはメモオブジェクトを作成せずに、外から渡されたメモオブジェクトを更新するようになっています。
  • ユーザーの入力に影響せず、viewWillDissapearでdelegateを呼び出すようになっています。

メモオブジェクトはtitlebodyを持っています。

1
2
3
4
5
6
@interface TBMemo : NSObject
@property (strong, nonatomic) NSString *titile;
@property (strong, nonatomic) NSString *body;

- (id)initWithTitle:(NSString *)title body:(NSString *)body;
@end

このViewControllerはこんな感じのインターフェイスを持っています。ここではあえて実装は出さないことにします。

1
2
3
4
5
6
7
8
@protocol TBMemoEditDelegate
- (void)editViewController:(TBMemoEditViewController *)viewController didFinishEditMemo:(TBMemo *)memo;
@end

@interface TBMemoEditViewController : UIViewController
@property (weak, nonatomic) NSObject<TBMemoEditDelegate> *delegate;
@property (weak, nonatomic) TBMemo *editingMemo;
@end

editingMemoはこのViewControllerが更新するメモオブジェクトでこのViewControllerを呼び出す際に外からセットされます。 またプライベートとして、.mの方でStoryboardから生成される2つのUIを持っています。

1
2
3
4
@interface TBMemoEditViewController ()
@property (weak, nonatomic) IBOutlet UITextField *titleTextField;
@property (weak, nonatomic) IBOutlet UITextView *bodyTextView;
@end

今回テストした方がよさそうなのは、

  • viewWillDisappearで、入力したタイトルと本文からメモオブジェクトを更新して、delegateへ渡す。
  • viewWillAppearで、渡したメモの内容を初期値として設定する。

の2つです。

「done」を押した時にどうこうというのは、これは単純に「モーダルを閉じる」とだけの実装であるべきなので、テストする価値が薄いですし、 アプリを調整していく仮定で変更される可能性の高いものなので(やっぱりpushがいいとか)、安全に変更を加えるためのテストが、逆に足を引っ張ってしまいます。

ではテストを書いていきましょう。

setup

まずはsetupUITextFieldなんかをモックに差し替えていきます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#import "TBMemoEditViewController.h"
#import "OCMock.h"

@interface TBMemoEditViewController ()
@property (weak, nonatomic) IBOutlet UITextField *titleTextField;
@property (weak, nonatomic) IBOutlet UITextView *bodyTextView;
@end

@implementation TBMemoEditViewControllerTests {
    TBMemoEditViewController *viewController;
    id mockTitleTextField;
    id mockBodyTextView;
}

- (void)setUp {
    [super setUp];

    viewController = [[TBMemoEditViewController alloc] init];

    // UITextFieldを差し替える
    mockTitleTextField = [OCMockObject mockForClass:[UITextField class]];
    viewController.titleTextField = mockTitleTextField;

    // UITextViewを差し替える
    mockBodyTextView = [OCMockObject mockForClass:[UITextView class]];
    viewController.bodyTextView = mockBodyTextView;

}
@end

こんな感じ、これで準備が整いました。

viewWillAppearで渡したメモの内容を初期値として設定する

まずはこっちを書いてみましょう。 editingMemoが呼び出し元からセットされますが、この場合はテストケースが呼び出し元になるので、これをセットします。

1
2
3
4
- (void)testUpdateViewWithEditingMemoObjectInViewWillAppear {
    TBMemo *editingMemo = [[TBMemo alloc] initWithTitle:@"TITLE" body:@"BODY"];
    viewController.editingMemo = editingMemo;
}

次にこのメモオブジェクトの値がtitleTextFieldbodyTextViewにそれぞれ、text経由でセットされるはずなので、これをOCMockでexpectします。

1
2
3
4
5
6
7
- (void)testUpdateViewWithEditingMemoObjectInViewWillAppear {
    TBMemo *editingMemo = [[TBMemo alloc] initWithTitle:@"TITLE" body:@"BODY"];
    viewController.editingMemo = editingMemo;

    [[mockTitleTextField expect] setText:@"TITLE"];
    [[mockBodyTextView expect] setText:@"BODY"];
}

viewWillAppearを呼び出して、

1
2
3
4
5
6
7
8
9
- (void)testUpdateViewWithEditingMemoObjectInViewWillAppear {
    TBMemo *editingMemo = [[TBMemo alloc] initWithTitle:@"TITLE" body:@"BODY"];
    viewController.editingMemo = editingMemo;

    [[mockTitleTextField expect] setText:@"TITLE"];
    [[mockBodyTextView expect] setText:@"BODY"];

    [viewController viewWillAppear:NO];
}

titleTextFieldbodyTextViewtextを経由して値がセットされたかをverifyします。

1
2
3
4
5
6
7
8
9
10
11
12
- (void)testUpdateViewWithEditingMemoObjectInViewWillAppear {
    TBMemo *editingMemo = [[TBMemo alloc] initWithTitle:@"TITLE" body:@"BODY"];
    viewController.editingMemo = editingMemo;

    [[mockTitleTextField expect] setText:@"TITLE"];
    [[mockBodyTextView expect] setText:@"BODY"];

    [viewController viewWillAppear:NO];

    [mockTitleTextField verify];
    [mockBodyTextView verify];
}

これでよさそうです。案外、簡単でしたね。

viewWillDisappearで、入力したタイトルと本文からメモオブジェクトを更新して、delegateへ渡す

次はこっちです。これはdelegateの扱い方で、大きく分けて2つ方法があります。

1つめはdelegateのモックオブジェクトを使うパターン、そしてテストケース自身がdelegateになるパターンです。 これは結構好みだと思うので、好きな方を採用すればよいかと思います。

共通部分

まずは共通なところ、titleTextFieldbodyTextViewをスタブして、TITLEBODYが入力された状態を再現します。

1
2
3
4
5
6
7
- (void)testFinishDelegateWithUpdatedMemoObjectInViewWillDisappear{

    // ユーザー入力を再現
    [[[mockTitleTextField stub] andReturn:@"TITLE"] text];
    [[[mockBodyTextView stub] andReturn:@"BODY"] text];

}

こうすると、titleTextField.textTITLEが入っている状態になりますね。

delegateのモックオブジェクトを使うパターン

OCMockでプロトコルからモックのdelegateを作り、セットします。

1
2
id mockDelegate = [OCMockObject mockForProtocol:@protocol(TBMemoEditDelegate)];
viewController.delegate = mockDelegate;

このモックのeditViewController:didFinishEditMemo:が、更新されたメモオブジェクトを持って呼び出される事を期待します。 これには[OCMArg checkWithBlock:]を利用します。

1
2
3
4
[[mockDelegate expect] editViewController:viewController didFinishEditMemo:[OCMArg checkWithBlock:^BOOL(id obj) {
    TBMemo *memo = (TBMemo *)obj;
    return [memo.titile isEqualToString:@"TITLE"] && [memo.body isEqualToString:@"BODY"];
}]];

そして、メモオブジェクトをセットして、viewWillDisappearを呼び、verifyします。

1
2
3
4
5
6
TBMemo *edtingMemo = [[TBMemo alloc] init];
viewController.editingMemo = edtingMemo;

[viewController viewWillDisappear:NO];

[mockDelegate verify];

全体としてこんなテストになります。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
- (void)testFinishDelegateWithUpdatedMemoObjectInViewWillDisappear{

    [[[mockTitleTextField stub] andReturn:@"TITLE"] text];
    [[[mockBodyTextView stub] andReturn:@"BODY"] text];

    id mockDelegate = [OCMockObject mockForProtocol:@protocol(TBMemoEditDelegate)];
    [[mockDelegate expect] editViewController:viewController didFinishEditMemo:[OCMArg checkWithBlock:^BOOL(id obj) {
        TBMemo *memo = (TBMemo *)obj;
        return [memo.titile isEqualToString:@"TITLE"] && [memo.body isEqualToString:@"BODY"];
    }]];
    viewController.delegate = mockDelegate;

    TBMemo *editingMemo = [[TBMemo alloc] init];
    viewController.editingMemo = editingMemo;

    [viewController viewWillDisappear:NO];

    [mockDelegate verify];
}

テストケース自身がdelegateになるパターン

こっちはiOS部では説明してなかったです。(すればよかった)

まずは、テストケースがDelegateを実装することを宣言します。

1
2
@interface TBMemoEditViewControllerTests () <TBMemoEditDelegate>
@end

テストケースにdelegateを実装します。渡ってきたメモオブジェクトをeditedMemoにとっておきます。

1
2
3
- (void)editViewController:(TBMemoEditViewController *)viewController didFinishEditMemo:(TBMemo *)memo {
    editedMemo = memo;
}

自身をdelegateにして、viewWillDisappearを呼びます。

1
2
3
4
5
6
viewController.delegate = self;

TBMemo *edtingMemo = [[TBMemo alloc] init];
viewController.editingMemo = edtingMemo;

[viewController viewWillDisappear:NO];

editedMemoに更新されたメモオブジェクトがあるはずなので、これをアサートします。

1
2
STAssertEqualObjects(editedMemo.titile, @"TITLE", nil);
STAssertEqualObjects(editedMemo.body, @"BODY", nil);

これでよさそうです。全体としてはこんな感じ。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
- (void)testFinishDelegateWithUpdatedMemoObjectInViewWillDisappearWithSelfDelegate {

    [[[mockTitleTextField stub] andReturn:@"TITLE"] text];
    [[[mockBodyTextView stub] andReturn:@"BODY"] text];

    viewController.delegate = self;

    TBMemo *editingMemo = [[TBMemo alloc] init];
    viewController.editingMemo = editingMemo;

    [viewController viewWillDisappear:NO];

    STAssertEqualObjects(editedMemo.titile, @"TITLE", nil);
    STAssertEqualObjects(editedMemo.body, @"BODY", nil);
}

- (void)editViewController:(TBMemoEditViewController *)viewController didFinishEditMemo:(TBMemo *)memo {
    editedMemo = memo;
}

どうでしょう、僕は前者の方が好みですが、分かりやすいのは多分後者ですかね。

まとめ

こんな感じのことを解説しました。 ちゃんと分割していけば、複雑になるかもしれないですが、大抵はこのパータンで事足りるかなーと思います。

非同期の終了をまってからBlocksでほげほげ、とかはOCMockのandDo:とかでうまい感じに表現できますし、NSNotificationとか使う場合にも結構素直に書けます。