3年以上運用しているアプリにUIテストを導入した #orecon_ios #fastlane_study_jp

10/3に開催された[俺コン Vol.1 Day.2](https://orecon.connpass.com/event/64285/)にて発表した資料です。

---

# UIテストの辛み

- いきなり初期画面が起動する
- 毎回要素を検索するのが面倒
- 遅い

今回は「いきなり初回画面が起動する」点と「毎回要素を検索するのが面倒」な点を改善したいなと思いました。シミュレーターが遅い問題はBluepill等を利用したりお金で解決するのが良いかと思います。

---

# UIテストを導入する為にやった事

1. アプリ起動時に文字列で`ViewController`を指定
2. UIテストから`ViewController`を指定してテスト
3. ページの要素を整理
4. UIテストからモックを渡してテスト

下記に具体的に書いていきます。

---

# 1. アプリ起動時に文字列で`ViewController`を指定

![launch_from_string.png](https://qiita-image-store.s3.amazonaws.com/0/2846/9d6ce0e7-964e-3d16-d11d-d639198f236a.png)

アプリ起動時に文字列でViewControllerを指定して起動出来る様にしました。こちらの方法は[iOSDC 2016の@dealforestさんの発表](http://dealforest.hatenablog.com/entry/2016/08/22/163156)を参考にさせて頂きました。

---

## まず初期画面をStoryboardから開く設定を削除

<img width="394" alt="has_main.png" src="https://qiita-image-store.s3.amazonaws.com/0/2846/b1ffc96b-7cd2-74ac-29f0-c6c72c35e689.png">
↓
<img width="401" alt="no_main.png" src="https://qiita-image-store.s3.amazonaws.com/0/2846/27ff6ea1-6127-eeaf-5d0f-acc7376cd873.png">

---

## ViewControllerを指定するキーとオプションを指定するキーを決める

```swift
struct LaunchKeys {
    static let viewController: String = "LAUNCH_VIEW_CONTROLLER"
    static let userInfo: String = "LAUNCH_USER_INFO"
}
```

ViewControllerを指定するキーとオプションを指定するキーを決めておきます。これはアプリ側のターゲットとUIテストのターゲットそれぞれに追加しておく必要があります。

<img width="274" alt="targets.png" src="https://qiita-image-store.s3.amazonaws.com/0/2846/e33e142a-1b9f-c898-929e-ff3f182c30cd.png">


---

## スキームの`Environment Variables`を編集

<img width="585" alt="scheme.png" src="https://qiita-image-store.s3.amazonaws.com/0/2846/cddd6e97-0302-312c-b4e1-4a69e3b0ef26.png">

Environment Variablesに先ほど作成したキーを追加して、起動したいViewControllerの名前を書いていきます。ここで便利なのは、予め値を登録しておくと、スキームのチェックをON/OFFするだけで起動時のViewControllerが切り替えられる事です。深い画面遷移を辿る必要がなかったり、なかなか遭遇しない画面のテストなんかにも使えるのでかなり便利です。

---

## `Creatable`プロトコルを作成

```swift
protocol Creatable {
    func create<T>(_ identifier: String, userInfo: [AnyHashable: Any]?) -> T?
}
```

特定の文字列とオプション用のuserInfoからインスタンスを作成出来るCreatableというプロトコルを作成しました。

---

## `Creatable`プロトコルを適合した`Creator`を作成

```swift
struct Creator: Creatable {
    func create<T>(_ identifier: String, userInfo: [AnyHashable : Any]?) -> T? {
        switch identifier {
        case "ViewController":
            return viewController() as? T
        default:
            return nil
        }
    }
}

extension Creator {
    func viewController() -> ViewController {
        let storyboard = UIStoryboard(name: "Main", bundle: Bundle.main)
        return storyboard.instantiateInitialViewController() as! ViewController
    }
}
```

先ほど作成した`Creator`を適合した`Creator`という`struct`を作成しました。具体的なインスタンスの作成はここで都度行なっていきます。`Identifier`の文字列をif文で毎度振り分けるのも大変なので内部的に`String`の`enum`を作るのがおすすめです。また、この`Creator`を作っておくと、インスタンスの作成方法に迷わなくなります。この画面はStoryboardだったかな、この画面はコードでの生成だったかな、この画面はどのプロパティが必須だったかな、と迷う事があるかと思いますが、そういう事をまとめておく事が出来ます。UIテストに関係無く導入はおすすめ出来ます。いわゆるfactoryデザインパターンです。

---

## `Launcher`の作成

```swift
struct Launcher {
    var creator: Creatable
    init(with creator: Creatable) {
        self.creator = creator
    }

    func launch<T>() -> T? {
        guard let viewControllerName: String = ProcessInfo.processInfo.environment[LaunchKeys.viewController] else {
            return nil
        }
        var userInfo: [AnyHashable: Any]? = nil
        if let userInfoString: String = ProcessInfo.processInfo.environment[LaunchKeys.userInfo],
            let userInfoData: Data = userInfoString.data(using: .utf8) {
            userInfo = (try? JSONSerialization.jsonObject(with: userInfoData, options: [])) as? [AnyHashable : Any]
        }
        return creator.create(viewControllerName, userInfo: userInfo)
    }
}
```

`ProcessInfo`から`ViewController`名とオプションの情報を取得して`Creatable`に渡してインスタンスを作成します

---

## `AppDelegate.swift`で`window`の`rootViewController`を変更

```swift
let creator = Creator()
let rootViewController: UIViewController
#if DEBUG
if let viewController: UIViewController = Launcher(with: creator).launch() {
    rootViewController = viewController
} else {
    rootViewController = creator.rootViewController()
}
#else
rootViewController = creator.rootViewController()
#endif
window?.rootViewController = rootViewController
```

最後に`AppDelegate.swift`で`window.rootViewController`に渡す`ViewController`を開発中かそうじゃないかで判別して設定しました。これでスキームから`ViewController`という文字列を受け取れば`ViewController`が作成されて起動する処理を書く事が出来ました。

---

# 2. UIテストから`ViewController`を指定してテスト

![launch_from_xctest.png](https://qiita-image-store.s3.amazonaws.com/0/2846/081838d5-6c57-8718-6326-609de22110c0.png)

UIテストからViewControllerを指定してテスト出来る様にしました。簡単にいうと上記のスキームから起動する仕組みをUIテストからキックする事でUIテストから好きな画面を表示出来る様にしたものです。

---

## テスト側のターゲットにも`Launcher`を作成

```swift
import XCTest

struct Launcher {
    var viewControllerName: String
    var userInfo: [AnyHashable: Any]?
    init(viewControllerName: String, userInfo: [AnyHashable: Any]? = nil) {
        self.viewControllerName = viewControllerName
        self.userInfo = userInfo
    }
    var env: [String: String] {
        var result: [String: String] = [LaunchKeys.viewController: viewControllerName]
        if let userInfo: [AnyHashable: Any] = userInfo {
            if let data: Data = try? JSONSerialization.data(withJSONObject: userInfo, options: []),
                let userInfoString: String = String(data: data, encoding: .utf8) {
                result[LaunchKeys.userInfo] = userInfoString
            }
        }
        return result
    }
    func launch() -> XCUIApplication {
        let app: XCUIApplication = XCUIApplication()
        app.launchEnvironment = env
        app.launch()
        return app
    }
}
```

アプリ側のターゲットにもあるのでややこしいですが別物です。
肝は`XCUIApplication`の`launchEnvironment`に渡す`env`プロパティを作る所です。ここでアプリ側に渡す値の整理を行なっています。

---

# 3. ページの要素を整理

![elements.png](https://qiita-image-store.s3.amazonaws.com/0/2846/ffc87d1d-b809-f313-92d1-2782e1ebecf7.png)


UIを貰った段階である程度の要素を整理します。これは何というラベルにしようとか何というボタンにしようとかそういう事ですね。その要素をテストコード側で再現する為に**Page Object Design Pattern**を参考にして要素のリストを作成しました。**Page Object Design Pattern**については[iOS Test Night #4](https://testnight.connpass.com/event/54316/)にて[根本さん](https://speakerdeck.com/tadashi0713/appiumkaraxcuitestnibian-e-sofalsetameniswiftwoxue-bishi-metahua)と[PoohSunnyさん](https://speakerdeck.com/poohsunny/xcuitestsurushi-falsetips-anatawozhu-keruxcuitesthe)が発表してますのでそちらも参照して頂ければと思います。

---

## `PageObjectsRepresentable`

```swift
protocol PageObjectsRepresentable {
  var app: XCUIApplication
  init(app: XCUIApplication)
}
```

`PageObjectsRepresentable`というプロトコルを作成しました。プロトコルだけだと`XCUIApplication`を保持しているだけに見えます。

---

## `PageObjectsRepresentable`を適合

```swift
struct ViewControllerPage: PageObjectsRepresentable {
  var app: XCUIApplication
  init(app: XCUIApplication) {
    self.app = app
  }

  var label: XCUIElement {
    return app.staticTexts["label"]
  }
}
```

先ほど作成した`PageObjectsRepresentable`を適合した`ViewControllerPage`という`struct`を作成しました。今回はただの`label`というプロパティがあるだけですが、本来なら更にたくさんのラベルやボタン、イベント等が並ぶと思います。

---

## テストに落とし込む

```swift
import XCTest

class LunchViewControllerTests: XCTestCase {
    var viewControllerName: String {
        return "ViewController"
    }

    func testLunchLabel() {
        let launcher = Launcher(viewControllerName: viewControllerName)
        let app = launcher.launch()
        let page = ViewControllerPage(app: app)
        XCTAssertTrue(page.label.exists)
        XCTAssertEqual(page.label.label, "Lunch")
    }
}
```

まず`Launcher`を作成してアプリを起動します。起動したらアプリを受け取るので、このアプリを`ViewControllerPage`に渡して`Page Objects`を作成します。テスト自体はこの`Page Objects`のプロパティのみに注力すれば良いのです。UIテストは最初何をすれば良いのか難しい所があるのですが、要素の有無のチェックやラベルやボタンのテキスト程度ならテスト出来るのでは無いでしょうか？出来る事から始めて少しずつテストを厚くしていけば良いと思います。

---

# 4. UIテストからモックを渡してテスト

![launch_with_mock_xctest.png](https://qiita-image-store.s3.amazonaws.com/0/2846/a25e5d7c-5ff7-73cc-9b20-9545b99f2c10.png)

# 適当なモデルの定義

```swift
struct HogeModel {
  var hoge: String
}
```

適当なモデルの定義をします。

---

# ViewControllerにモデルを保持

```swift
class HogeViewController: UIViewController {
  @IBOutlet weak var hogeLabel: UILabel!
  var hogeModel: HogeModel {
    didSet {
      hogeLabel.text = hogeModel.hoge
    }
  }
}
```

`ViewController`にモデルのインスタンスを持たせて、モデルが渡されたらラベルに値を表示する様なイメージのコードを書きました。

---

# Creatorを拡張

```swift
struct Creator: Creatable {
    func create<T>(_ identifier: String, userInfo: [AnyHashable : Any]?) -> T? {
        switch identifier {
        case "HogeViewController":
            guard let data = (userInfo?["MOCK_JSON"] as? String)?.data(using: .utf8),
                let json: [String: String] = (try? JSONSerialization.jsonObject(with: data, options: [])) as? [String: String],
                let hoge = json["hoge"] else {
                return nil
            }
            let model = HogeModel(hoge: hoge)
            return hogeViewController(with: model) as? T
        default:
            return nil
        }
    }
}

extension Creator {
    func hogeViewController(with hoge: HogeModel) -> HogeViewController {
        let viewController = HogeViewController()
        viewController.hogeModel = hoge
        return viewController
    }
}
```

先ほど作った`Creator`を拡張して`userInfo`で受け取った値からモデルを作成して一緒に`ViewController`を作成します。

---

# UIテストからJSON文字列をアプリに渡してテスト

```swift
func testModel() {
    let launcher = Launcher(viewControllerName: viewControllerName,
                    userInfo: ["MOCK_JSON": "{\"hoge\": \"fuga\"}"])
    let app = launcher.launch()
    let page = HogeViewControllerPage(app: app)
    XCTAssertTrue(page.hogeLabel.exists)
    XCTAssertEqual("fuga", page.hogeLabel.label)
}
```

最後にUIテスト側からJSON文字列をアプリに渡す事で、アプリはこのモックを表示させる事が出来ます。

---

ここまで作ってみてふと思ったんです。**ライブラリ化出来るんじゃない？**という事で

# `Lunch`というライブラリを作成しました🎉

![Logo.png](https://qiita-image-store.s3.amazonaws.com/0/2846/f8c94caa-cb54-25f3-aa69-7a7b394abacc.png)


[https://github.com/fromkk/Lunch](https://github.com/fromkk/Lunch)

- `Launcher`
- `Creatable`
- `LaunchKeys`
- `ViewControllerTestable`
- `PageObjectsRepresentable`

のみ定義されている小さいライブラリです。使い方は`LunchSample`を見ればここまで見て頂いている方なら分かるかと思います。
よかったらスター⭐️ください🙇🏻

---

# UIテストを導入してみて

UIテストを導入してみて感じた事ですが、一気に全部をテストする事はやはり難しいなと感じました。確実に出来る事から少しずつテストを厚くしていくのが良いと思います。また、優先度の高い画面からテストを書いて行くのが良さそうかなと思います。Page objectデザインパターンを利用すればUIテストをTDD的にも書きやすくなるかと思いますのでそういうアプローチも良いと思います。

---

# まとめ

UIテストへのハードルが少し下がりました。UIテストで要素を共通化する事で使い回しが簡単になりました。特定のViewControllerだけ開きたい時にスキーム編集だけでいいのでとても簡単になりました。副次的にインスタンス化する処理を共通化出来ました。

よかったら[Lunch](https://github.com/fromkk/Lunch)使ってさらによかったらスター⭐️下さい😇