iOSでURL遷移を自動テストする

表題のことがやる必要があったので、調べたら[こちらのサイト](http://swiftyjimmy.com/mock-uiapplication-swift/)で紹介されていました。
英語が得意な方はそちらの記事をご参照ください。


# この記事でやりたきこと/できるようになること
* `open(_:options:completionHandler:) - UIApplication`を利用する部分のテストコードを実現させる
    * アプリからURLリンクボタンを押下した時の挙動をテスト
    * URLスキームで別アプリを起動させる時の挙動をテスト

私は担当しているプロダクトで、URLスキームを使って別アプリを起動させるんですが、そのURLのパラメータ達を状況によって使い分けて、コードで結合させて作成しています。
このようなコード内でURLを作成している場合、ぜひ自動テストの対象にしてみてください。


# 環境
Swift4.2
Xcode10.1

# 用意するもの
### URLOpenerProtocol
もともとUIApplicationに用意されている`open(_:options:completionHandler:)`と同じ型のメソッドを定義します。
（`canOpenURL(_:) - UIApplication`はおまけなので、なくても大丈夫です）

``` Swift
// MARK: - URLOpenerProtocol
/// URLスキームを開くプロトコル
///
/// - Note: Mockに差し替えるためにUIApplicationから切り出し
protocol URLOpenerProtocol {
    func canOpenURL(_ url: URL) -> Bool
    func open(_ url: URL, options: [UIApplication.OpenExternalURLOptionsKey : Any], completionHandler completion: ((Bool) -> Void)?)
}
``` 
こちらをUIApplicationに採用させます。
UIApplicationクラスにあるメソッドしか定義していないため、エラーは出ません。
※Swiftのバージョンアップにより、UIApplicationのメソッドの名前や引数などが変更になった場合は変更する必要があります。

``` Swift
/// UIApplicationクラス拡張
extension UIApplication: URLOpenerProtocol { }
```

### DIコンテナ
アプリで使っているUIApplicationインスタンスをDIするために、DIコンテナを用意します。
デフォルトの設定では、`UIApplication.shared`を登録しておきます。
こうすることで、プロダクトコードでは、通常通りUIApplicationクラスのメソッドが呼ばれます。
SwiftのDIコンテナについては、[こちらの記事](https://qiita.com/kusumotoa/items/c924d5c67282fd11f664#di%E3%82%B3%E3%83%B3%E3%83%86%E3%83%8A%E3%81%A8%E3%81%AF)がわかりやすかったです。

``` Swift
// MARK: - di
/// DIコンテナ（グローバル）
internal let di: DIContainer = {
    let container = DIContainer()
    
    // デフォルトのDI設定を登録する
    container.register(URLOpenerProtocol.self) { return UIApplication.shared }
    
    return container
}()
```

#　テスト対象のコード部分の変更
 URLをする＝`open(_:options:completionHandler:)`を呼び出す側（VCなど）の実装を、DIコンテナ経由でUIApplicationのインスタンスを取得するように変更します。

``` Swift
// As-is
UIApplication.shared.open(transitonURL, options: [:], completionHandler: nil)
```

``` Swift
// To-be
di.resolve(URLOpenerProtocol.self)?.open(transitonURL, options: [:], completionHandler: nil)
```
# UIApplicationモック
テストの際に`open(_:options:completionHandler:)`を呼び出されたらやりたい処理を書きます。
ここでは、プロパティに遷移先のURLを保存しています。
これがテストの実測値になります。

``` Swift

/// UnitテストのためのUIApplicationの手動作成モック
final class URLOpenerMock: URLOpenerProtocol {
    
    /// URLスキーム遷移先URL
    var openingUrl: URL?
    
    /// URLスキーム遷移ができるかどうか
    internal var canOpen: Bool = true
    
    func canOpenURL(_ url: URL) -> Bool {
        return canOpen
    }
    
    func open(_ url: URL, options: [UIApplication.OpenExternalURLOptionsKey : Any], completionHandler completion: ((Bool) -> Void)?) {
        openingUrl = url
        completion?(canOpen)
    }
}
```
（`canOpenURL(_:) - UIApplication`はおまけなので、なくても大丈夫です）

# テストコード
### テスト開始時
DIコンテナにUIApplicationモックを登録します。
これで、その後に`open(_:options:completionHandler:)`を呼び出されたら、モックで書いた処理が動くようになります。

``` Swift
/// DIコンテナにモックを登録します
di.register(URLOpenerProtocol.self) { return self.urlOpernerMock! }
```
### テストケース内
期待値と、モック内に保存した遷移先URLを比較します。

``` Swift
// 期待値
let url = URL(string: "https://qiita.com/")
// 非同期処理なので10秒遅延
// urlOpener#open(transitonURL, options: [:], completionHandler: nil)が呼ばれることを検証
expect(urlOpenerMock.openingUrl).toEventually(equal(url), timeout: 10)
```

完成！

---
検索して出てきた結果が英語の記事ばかりだと、読むモチベーションが70%くらい減るポンコツエンジニアの私ですが、腹をくくって読むように心がけています。
英語のサイトを避けるなんてエンジニアとして失格だと先輩から聞いたので、失格になるよりはポンコツでいようと思います。

この記事に問題点や改善点があれば、遠慮なくご連絡ください。