XcodeのUIテストフレームワーク「XCUITest」のTips

XCUITestでUIテストの自動化にチャレンジしています。
その中で得られたTipsを記します。

# 環境

Xcode 10.1
Swift 4.2

# そもそも、XCUITestとは

- Xcodeに統合されているUIテストフレームワークです。
- シナリオコードを書いて、ユーザーの操作をシミュレーションします。
- アプリの挙動が期待通りになっているかのアサーションも、もちろんコードで記述します。
- アサーションに失敗したタイミングで、自動的にスクリーンショットを撮ってくれます。
- ブレークポイントで止めておいて、操作の記録を開始すると、操作をテストコードに自動変換してくれます（この「レコーディング機能」の注意については後述）。
- Xcode 10より"Parallel Testing"つまり並列実行が導入され、パフォーマンスが向上しました。

# 導入手順と、基本的なテストコードの書き方

本稿では割愛します。
以下の記事が参考になるかと思います。
[【Swift】初めてのUITest導入](https://qiita.com/taji-taji/items/c00e5b94376c37f17443)
[UITestの導入とチートシート](https://qiita.com/hanawat/items/79bc81ed87704b15f3cd)
[UI Testing Cheat Sheet and Examples](http://masilotti.com/ui-testing-cheat-sheet/)
※少々前の記事になりますが、基本は変わっていないと思います。

# メリット／デメリット、そして導入のポイント

XCUITestの導入を検討されている方向けに、先にまとめっぽいことを書きます。
※個人の感想です。

## メリット

一般的なコトではありますが…

- 書いてしまえば、リグレッションテストを自動化できる。
- JenkinsなどでCIに組み込める。
- UIのデグレード防止に一定の効果があり、リファクタリングする"勇気"が出る。
- テストのドキュメントを書かなくても済む。
- したがって、アジャイルとは相性が良い（はず）。

## デメリット

- 慣れないうちはテストを書くことにコストがかかる。
- リーダブルなテストコードを書くようにチーム内で統一意識を持たないと、テストコードのメンテナンスにコストがかかる。
- Parallel Testingが導入されたとはいえ、実行時間はそれなりに掛かる。
    - テストコードが数百件になると、Parallel Testingでも1時間ぐらい掛かると思います。

## ポイント

- XCUITestは書いていて楽しいけど実行時間が掛かるので、「書き過ぎ」に注意。
    - ロジック部分はXCTestで書いた方がContextも分かりやすいし実行時間も短い。
    - XCUITestはUI部分のテストだけと割り切った方が良い。
- ということは、アプリのアーキテクチャ検討の際、ロジックとUIを分離させる戦略が必要ということになります。
    - アーキテクチャ検討の際は、テスト戦略やCIも踏まえて検討を！
- テストコードをリーダブルに保つ重要性をチーム全員が良く理解することが大事！
    - **具体的には、テスト対象、テスト条件、そして期待結果（= Context）が分かるように書くこと**

XCUITestは効果的に使うと、テストを楽しめると思います！:wink:

# Tips

ここからが私なりの知見です。

## 利用範囲をあらかじめ決めておくべし

- XCUITestはユーザー操作をシミュレーションするモノなので、結合テスト的に「も」使えます。
- 画面単独のUIテストにのみ利用するのか、画面間の結合にも利用するのか、チーム内で利用範囲の認識を合わせておいた方が良いと思います。

## テストコードの作成単位をあらかじめ決めておくべし

- 上とも関連しますが、テストメソッドやファイルをどのような単位で作成するのか、あらかじめチームの決め事を作っておかないと、カオスになります。
- 画面単独であれば、"テスト対象クラス名UITests.swift"で事足りますが、画面間の結合にも利用するとなると、どのファイルにどんなシナリオが含まれるのか、さっぱり分からなくなります。

## レコーディング機能は極力使うべからず

- レコーディング機能は、必ずView（テスト対象の項目）を取得できるとは限らず、エラーになる場合もあります。
- レコーディング機能に頼りすぎると、上手くViewを取得できない場合に時間を浪費します。
- レコーディング機能で自動生成されるコードからはContextが読み取りにくく、テストコードのメンテナンスにコストが掛かります。

## accessibilityIdentifierを活用すべし

- どのようにしてViewを取得するのが良いかというと、accessibilityIdentifierを設定するのが良いです。
    - UIButtonなど、テキストを持っているViewであれば、`app.buttons["ほげ"]`などでも簡単に取得できるのですが、UIImageViewなどはaccessibilityIdentifierを設定しないと取得しづらいです。
    - また、『「ほげ」を「ふが」に文言変更する』なんていういことも多々ありますし…

コードで設定する場合はこんな感じで書きます。
`button.accessibilityIdentifier = "ViewController_button"`

StoryBoardであれば、"Identity inspector"にて設定します。
![スクリーンショット 2018-12-16 6.56.50.png](https://qiita-image-store.s3.amazonaws.com/0/122917/b95b5a8c-dbcf-23bd-b9c6-3cadb85b2532.png)

XCUITestではこんな感じで書きます。
`app.buttons["ViewController_button"]`

設定する文字列は、上のように「クラス名_View名」などルールを決めると、テストコードのContextを読み取りやすいと思います。
（単に`app.buttons["button"]`とすると、Contextが分かりにくくなります。）

## XCTContext.runActivity(named:)でテストのContextを表現すべし

- 前述の通り、テストのContextを読み取れることは重要です。
- コメントに書くのも悪くないですが、どうせなら、`XCTContext.runActivity(named:)`にテスト条件や期待結果を記述する方が良いです。
- `XCTContext.runActivity(named:)`に書くと、以下のようにXcodeのReport navigatorに表示されて見やすくなります。

![スクリーンショット 2018-12-15 17.38.27.png](https://qiita-image-store.s3.amazonaws.com/0/122917/f67e0986-e86c-29df-851a-09902d3ae870.png)

## 「待つ」コードがポイント

- 「何かのタイミングで通信を開始して、インジケータを表示して操作禁止とし、レスポンスが返ってきたら画面操作を可能にして…」とか、よくあると思います。
- 「表示されるまで待つ」とか「tapできるようになるまで待つ」という場面は結構頻繁にあり、待たせ方がポイントになります。
    - 固定的な時間で待たせると、環境によって待ち時間が足りなかったり、実行時間が余分に掛かってしまったりします。
- 後述するサンプルコードの`waitToAppear()`、`waitToHittable()`を参照してください。

## 共通メソッドでAssertする場合は、引数に#fileと#lineを設定すべし

- そうすることで、共通メソッドの呼び出し元の行がAssertion Failureになります。
- 逆にそうしないと、共通メソッド内でAssertion Failureになってしまい、失敗原因箇所がわかりにくくなります。
- 後述するサンプルコードの`waitToAppear()`、`waitToHittable()`を参照してください。

## 通信のスタブと組み合わせるべし

- 通信エラーケースのテストを行うには、スタブ化が欠かせません。
- URLRequestのレスポンスをスタブ化するには、[OHHTTPStubs](https://github.com/AliSoftware/OHHTTPStubs)という定番ライブラリがあります。
- OHHTTPStubsはWebViewのスタブ化はできません。そこで、[Swifter](https://github.com/httpswift/swifter)という軽量HTTP Serverを使う手があります。
    - [[iOS] [Swift] HTTPサーバーライブラリ"Swifter"を使って、WebViewをUITestする](https://qiita.com/y-some/items/4bbd11a76f487d2a6916)

## テストデータを投入しやすいローカルDB

- [Realm Swift](https://realm.io/docs/swift/latest)はXCUITestに向いていると思います。
    - JSONからデータを簡単に投入できます。
    - メモリー上に簡単にインスタンスを生成できます。

環境変数などを上手く利用して、XCUITestからの実行の場合は通信やDBをスタブ化するようにすると捗ります。

# サンプルアプリ

ここからはサンプルコードによる解説です。
まず、テスト対象のサンプルアプリです。

![Qiita201812.gif](https://qiita-image-store.s3.amazonaws.com/0/122917/363e842a-2998-bf12-ac37-d14e8207148d.gif)

UIButtonがtapされたらActionSheetを表示して、その選択結果によってUILabelにテキストを表示する、という、ごくシンプルなものです。

プロジェクトはGitHubに公開しておきました。
https://github.com/y-some/UITestSample

# サンプルテストコード

```swift
import XCTest

class ViewControllerUITests: XCTestCase {
    let app = XCUIApplication()
    
    override func setUp() {
        continueAfterFailure = false
        app.launch()
    }

    override func tearDown() {
    }

    func testExample() {
        XCTContext.runActivity(named: "初期表示でTextViewの文言を確認") { (activity) in
            waitToAppear(for: app.textViews["ViewController_textView"])
            XCTAssertEqual(app.textViews["ViewController_textView"].value as? String,
                           "Hello world!\n\nXCUITestのサンプルです。")
        }
        XCTContext.runActivity(named: "ボタンをtap、ActionSheetのAction1をtapし、Labelの文言を確認") { (activity) in
            waitToHittable(for: app.buttons["ViewController_button"]).tap()
            waitToAppear(for: app.sheets["タイトル"])
            app.buttons["Action1"].tap()
            XCTAssertEqual(app.staticTexts["ViewController_label"].label,
                           "Action1がtapされました")
        }
        XCTContext.runActivity(named: "ボタンをtap、ActionSheetのAction2をtapし、Labelの文言を確認") { (activity) in
            waitToHittable(for: app.buttons["ViewController_button"]).tap()
            waitToAppear(for: app.sheets["タイトル"])
            app.buttons["Action2"].tap()
            XCTAssertEqual(app.staticTexts["ViewController_label"].label,
                           "Action2がtapされました")
        }
        XCTContext.runActivity(named: "ボタンをtap、ActionSheetのCancelをtapし、Labelの文言を確認") { (activity) in
            waitToHittable(for: app.buttons["ViewController_button"]).tap()
            waitToAppear(for: app.sheets["タイトル"])
            app.buttons["Cancel"].tap()
            XCTAssertFalse(app.staticTexts["ViewController_label"].exists)
        }
    }
}

extension XCTestCase {
    func waitToAppear(for element: XCUIElement,
                      timeout: TimeInterval = 5,
                      file: StaticString = #file,
                      line: UInt = #line) {
        let predicate = NSPredicate(format: "exists == true")
        let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element)
        let result = XCTWaiter().wait(for: [expectation], timeout: timeout)
        XCTAssertEqual(result, .completed, file: file, line: line)
    }

    func waitToHittable(for element: XCUIElement,
                        timeout: TimeInterval = 5,
                        file: StaticString = #file,
                        line: UInt = #line) -> XCUIElement {
        let predicate = NSPredicate(format: "hittable == true")
        let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element)
        let result = XCTWaiter().wait(for: [expectation], timeout: timeout)
        XCTAssertEqual(result, .completed, file: file, line: line)
        return element
    }
}
```