ios-snapshot-test-caseを快適に運用する5つのtips

iOSアプリ開発においてViewのスナップショット画像を撮影比較する便利なツールがあります。
[ios-snapshot-test-case](https://github.com/uber/ios-snapshot-test-case)

この記事では、[ios-snapshot-test-case](https://github.com/uber/ios-snapshot-test-case)を快適に運用する5つのtipsを紹介します。
またこの記事で紹介するサンプルプロジェクトは[こちら](https://github.com/watanavex/SnapshotSample)にあります。

尚、このツールの概要・導入・活用術については@imaizume さんのこちらのスライドが大変学びが多くお勧めです。
https://speakerdeck.com/imaizume/practical-snapshot-testing

## アニメーションを無効にする

アニメーションがあるとスナップショット画像が安定せず、画像に差分が発生 (XCTest失敗) してしまいます。
そこで、テストの時はアニメーションを無効化しておくのがオススメです。

こちらのAPIを利用します。

```Swift
class func setAnimationsEnabled(_ enabled: Bool)
```
https://developer.apple.com/documentation/uikit/uiview/1622420-setanimationsenabled

テストの時のみアニメーションを無効にする方法を2つ紹介します。

#### 方法1: 実行引数
Xcodeの`Edit Scheme...`から
<img width="1117" alt="スクリーンショット 2020-08-11 16.09.04.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/14646/a9518b8f-ce1b-9769-6185-f7836d4e29d4.png">

Testの実行引数を指定します。
文字列 `disable-animations` は任意でなんでも結構です。
![Monosnap 2020-08-11 16-09-40.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/14646/2490dc46-eaef-c6a9-df73-e4571e28bb81.jpeg)

デバック実行時の環境変数を上書きしてしまうため、以下の設定も必要です。
特に `$(SOURCE_ROOT)` などの環境変数を展開するために `Expand Variables Based On`でアプリターゲットします。

![Monosnap 2020-08-11 16-54-45.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/14646/4b5d6471-5fe4-140a-2bd4-9126058646d2.jpeg)

`AppDelegate`で実行引数に`disable-animations`が含まれていたら、アニメーションを無効化します

```Swift
import UIKit

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        if ProcessInfo.processInfo.arguments.contains("disable-animations") {
            UIView.setAnimationsEnabled(false)
        }
        return true
    }

}

```

#### 方法2: テスト専用のAppDelegateを実装する
UIApplicationやAppDelegateのクラスを動的に指定するこちらのAPIを利用します。

```Swift
func UIApplicationMain(_ argc: Int32, 
                     _ argv: UnsafeMutablePointer<UnsafeMutablePointer<Int8>?>, 
                     _ principalClassName: String?, 
                     _ delegateClassName: String?) -> Int32
```

https://developer.apple.com/documentation/uikit/1622933-uiapplicationmain

`main.swift`というファイルを追加し、以下のように実装します。
クラス名やtarget名は適宜変更してください。
ミソはTestターゲットのAppDelegateがあればソレを採用する。なければ、アプリターゲットのAppDelegateを採用するようになっている点です。

```Swift
import UIKit

UIApplicationMain(CommandLine.argc,
                  CommandLine.unsafeArgv,
                  nil,
                  NSStringFromClass(NSClassFromString("SnapshotSampleTests.AppDelegate") ?? AppDelegate.self)
)

```

アプリターゲットのAppDelegateから`@UIApplicationMain`アノテーションを削除します。

```Swift
  import UIKit

- @UIApplicationMain
  class AppDelegate: UIResponder, UIApplicationDelegate {
```

TestターゲットにAppDelegate.swiftを追加し、アニメーションを無効化します。

```Swift
import UIKit

class AppDelegate: UIResponder, UIApplicationDelegate {
    
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        UIView.setAnimationsEnabled(false)
        return true
    }
    
}
```

## 言語設定

日本語のテキストを扱うときは、ローカル環境とCI環境でフォントが異なり、スナップショットに差分が出ることがしばしばあります。
そのため、テスト実行時の言語設定を施しておくと、スナップショットテストが安定します。

Xcodeの`Edit Scheme...`から
<img width="1117" alt="スクリーンショット 2020-08-11 16.09.04.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/14646/a9518b8f-ce1b-9769-6185-f7836d4e29d4.png">

`Japanese`を指定します

![Monosnap 2020-08-11 16-30-38.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/14646/62e113b5-b217-cdba-430d-6f849ec3a45d.jpeg)

## CIの成果物に差分画像を含めておく

Bitrise等のCI環境でスナップショットテストを実行し画像に差分が発生した際に、どんな差分だったのか？そのままでは確認するすべがありません。
(CI環境でのみ画像に差分が発生することは多いです)
そこでCIの成果物にスナップショットテストの差分画像を含めておくことをお勧めすます。

例えば、bitriseでしたらこのような設定で可能です。
テスト失敗時に成果物を公開するように
`is_always_run: true` や `run_if: ".IsBuildFailed"` などを指定する必要があります。

```yaml
    - xcode-test@2:
        inputs:
        - project_path: "$BITRISE_PROJECT_PATH"
        - simulator_device: iPhone SE (2nd generation)
        - scheme: "$BITRISE_SCHEME"
    - zip-directory-and-export-its-path@1:
        is_always_run: true
        run_if: ".IsBuildFailed"
        inputs:
        - include_directory: 'true'
        - directory_to_zip: "./SnapshotSampleTests/FailureDiffs"
    - deploy-to-bitrise-io@1:
        is_always_run: true
        run_if: ".IsBuildFailed"
        inputs:
        - notify_user_groups: none
        - is_enable_public_page: 'true'
        - deploy_path: "$ZIP_FILE"
```

<img width="1129" alt="スクリーンショット 2020-08-11 14.51.37.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/14646/9e961757-1216-5efc-4908-becf79b5b5dc.png">

## RecordModeの切り替えは共通にしておく

スナップショットを撮影する`FBSnapshotTestCase`クラスは `recordMode`フラグを持っています。このフラグが`true`の場合はリファレンス画像を更新します。`false`の場合は画像の差分をチェックします。

[参考](https://github.com/uber/ios-snapshot-test-case#creating-a-snapshot-test)

SDKのメジャーバージョン更新時など、全ての画像を一括更新かけることもありますので、このフラグはグローバルな物を代入するようにしておくと便利です。
[実装例](https://github.com/watanavex/SnapshotSample/pull/5/files)

## 便利メソッドの作り方

dark modeとlight mode両方のスナップショットを撮影する便利メソッドの実装例です。
[参考](https://github.com/watanavex/SnapshotSample/pull/6)

```Swift
extension FBSnapshotTestCase {
    
    func snapshotBothMode(_ viewController: UIViewController, file: StaticString = #file, line: UInt = #line) {
        let window = UIWindow(frame: UIScreen.main.bounds)
        window.rootViewController = viewController
        window.makeKeyAndVisible()

        FBSnapshotVerifyView(window, identifier: "light", file: file, line: line)
        window.overrideUserInterfaceStyle = .dark
        FBSnapshotVerifyView(window, identifier: "dark", file: file, line: line)
    }
    
}
```

引数にファイル名と行番号が含まれているのがミソです。
このファイル名と行番号が指定された箇所がテスト失敗として扱われます。
これは標準の`XCAssert`が備えている機能ですので、スナップショットテスト以外にも応用が可能です。
https://developer.apple.com/documentation/xctest/1500669-xctassert

#### ファイル名と行番号が指定した場合

独自メソッド `snapshotBothMode`の呼び出し箇所でテスト失敗となります
<img width="1404" alt="スクリーンショット 2020-08-11 15.01.41.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/14646/7981d53f-8223-34d9-0280-e13ced4d614a.png">

#### ファイル名と行番号が指定しない場合
独自メソッド `snapshotBothMode`の内部でテスト失敗となります
<img width="1406" alt="スクリーンショット 2020-0x8-11 15.02.11.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/14646/36099ed0-c0e4-aa9f-ce4d-ca2e34282fd0.png">

## Dangerでスナップショットを強制する

せっかく`ios-snapshot-test-case`を導入しても、スナップショットを比較するテストの実装を忘れてしまうと意味がありません。

そこで、Pull-Requestの静的チェックツール`Danger`でスナップショットテストの実装をチェックしてみましょう。
[参考](https://github.com/watanavex/SnapshotSample/pull/7)

スナップショットの実装をチェックする最低限の設定をしたサンプルです。
https://github.com/watanavex/SnapshotSample/blob/e0a061a825a96d5cf5c950bac3ed77b545afcdde/Dangerfile

Pull-Requestではこのようにワーニングが発生します。
<img width="782" alt="スクリーンショット 2020-08-11 15.08.15.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/14646/feb5e181-7439-b76a-8bb0-f33c46cacb0f.png">

https://github.com/watanavex/SnapshotSample/pull/8