黒魔術を使って UI のテストを自動化する

Qiita で知り合った方との勉強会で発表（しようとして全然間に合わず帰ってから実装）した UI テスト自動化の話。

# iOS のイベント処理の仕組み
これ→ https://developer.apple.com/library/ios/documentation/EventHandling/Conceptual/EventHandlingiPhoneOS/EventHandlingiPhoneOS.pdf

日本語はこちら→ https://developer.apple.com/jp/devcenter/ios/library/documentation/EventHandlingiPhoneOS.pdf

# 黒魔術
iOS において、ユーザーが画面をタップしたり、デバイス自体を傾けたりシェイクしたり、別のリモコンを使って音楽を再生したりといった「行為」は、`UIEvent` というオブジェクトにまとめられ、それを `UIView` や `UIViewController` や `UIGestureRecognizer` がハンドリングすることで、「行為」に対する「アクション」を実現している。

UI のテストを自動化しようと思うと、まずこの `UIEvent` のモックを作って、それを例えば `UIView` の `touchesBegan:withEvent:` に渡すことでタッチイベントを模倣することが考えられる。だだし iOS は `UIEvent` をものごっつカプセル化しており、タッチイベントの実体である `UITouchesEvent` （`UIEvent` の孫クラス）に至っては公開すらされていない。

それでも無理やり自動化しようと思うと、これはもう黒魔術を使わざるを得ない。Objective C の場合、全てのメッセージパッシングは `id` 型に対して行われるため、非公開のメソッド（例えば `UITouchesEvent` の `_firstTouchForView:`）であっても、Mock クラスに同名のメソッドを用意してやれば問題なく動かすことが出来る。ただし、内部実装はいつ変更されるかわからないため、このテスト手法が将来にわたって使えることは保証できない。

# サンプルコード
流れとしては、

- `window` （`UIWindow` インスタンス） に対してタップした場所 `point` （`CGPoint`）を渡し、最初にタッチイベントをハンドリングできる `view` を探す（`hitTest:withEvent:`を呼び出す）
- 取得した `view` に対して、 `touchesBegan:withEvent:` （タッチ開始）及び `touchesEnded:withEvent:` メソッドを呼び出す。
- これで画面上の `point` をタッチし、離したというイベントが `view` に渡り、それがボタンであること、期待する処理が行われること、をテストできる。


```objc
@interface AMOUITestViewControllerTests : XCTestCase

@end

@implementation AMOUITestViewControllerTests

- (void)window:(UIWindow *)window tapSimulationWithPoint:(CGPoint)point
{
    // UIEvent のモック
    MockUIEvent *event = [MockUIEvent new];

    // 当たり判定
    UIView *view = [window hitTest:point withEvent:event];

    // UITouch のモック
    MockUITouch *touch = [MockUITouch new];
    touch.aTouchPoint = touch.aPreviousTouchPoint = [view convertPoint:point fromView:nil];
    touch.aWindow = window;
    touch.aView = view;
    touch.aTapCount = 1;
    
    NSSet *touches = [NSSet setWithObject:touch];
    event.aTouches = touches;

    // タッチ開始    
    touch.aPhase = UITouchPhaseBegan;
    [view touchesBegan:touches withEvent:event];
    
    // タッチ終了
    touch.aPhase = UITouchPhaseEnded;
    [view touchesEnded:touches withEvent:event];
}

- (void)testCase
{
    UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 320, 568)];
    AMOUITestViewController *vc = [AMOUITestViewController new];
    window.rootViewController = vc;
    [window makeKeyAndVisible];
    CGPoint touchPoint = CGPointMake(vc.view.center.x, vc.view.center.y / 2);
    
    // 最初の色は vc.deactiveColor である
    XCTAssertEqualObjects(vc.someView.backgroundColor, vc.deactiveColor);
    
    // ボタンをタッチした
    [self window:window tapSimulationWithPoint:touchPoint];
    
    // タッチ後の色は vc.activeColor である
    XCTAssertEqualObjects(vc.someView.backgroundColor, vc.activeColor);

    // もう一度タッチした
    [self window:window tapSimulationWithPoint:touchPoint];

    // タッチ後の色は vc.deactiveColor に戻っている
    XCTAssertEqualObjects(vc.someView.backgroundColor, vc.deactiveColor);
}

@end
```

# github
以下でサンプルコードを公開している。
https://github.com/amo12937/AMOUITestApplicationSample/tree/20141214_for_qiita

```sh
git clone -b 20141214_for_qiita git@github.com:amo12937/AMOUITestApplicationSample.git
```

# マイルストン
## スワイプ操作の模倣
`touchesMoved:withEvent:` を使えば、スワイプなどもきっと模倣できる。ただしどんな 非公開メソッドを使っているかはわからないので `MockUIEvent` や `MockUITouch` には修正が必要かもしれない。

## ライブラリ化
テストは確かに黒魔術だが、アプリバイナリとして申請する際にはテストコードは含まれないので、これをもってリジェクトされることは無いはず。ただし、黒魔術を使ったテストを信じるべきではない、という意見は確かにその通りで、受託案件では特に、これを持って結合試験の代わりにはできないだろう（参考程度にはなるかも）。ご利用は飽くまで自己責任で。