SwiftUIのPreview実装をそのまま使って、Screenshot撮影を自動化する

# はじめに

SwiftUIのPreview機能を活用して、実装したPreview用のコードをそのままScreenshot撮影自動化に利用できる方法の紹介です。
わざわざテスト用のコードを実装することなく、既存の記述をほぼそのままCIとして組み込めるので、かなり簡単に対応できると思います。
これを使って、画像diffから意図しないUI変更を検知したり、簡単なUIカタログのようなものを作ることもできます。

# 完成形

先に完成形を記載しておきます

### Previewの実装

```swift
struct ContentView_Previews: PreviewProvider {
    // Previewableに適合したenumを定義して、previewプロパティで状態に応じたViewを返すだけです
    enum Context: String, Previewable {
        case red
        case green
        case blue
        
        var preview: some View {
            switch self {
            case .red:
                return ContentView()
                    .foregroundColor(.red)
            case .green:
                return ContentView()
                    .foregroundColor(.green)
            case .blue:
                return ContentView()
                    .foregroundColor(.blue)
            }
        }
    }
    
    static var previews: some View {
        // Previewableに定義してある便利関数で、各caseごとのViewをGroupで括った上で返却します
        Context.groupedAllContext
    }
}
```

Preview画面での実際の表示はこんな感じ

<img width="200" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/41486/dfe9d61a-2e92-9e49-858a-e3f8e825d338.png"/>


### テストコードの実装

テスト用の実装はこれだけでOKです

```swift
class PreviewScreenshotSampleTests: FBSnapshotTestCase {
    override func setUp() {
        super.setUp()
        // ここは、今回利用したFBSnapshotTestCaseのための実装です
        self.recordMode = true
    }

    func test() {
        // Previewableに適合した型の、screenshotメソッドを呼び出すだけです。
        ContentView_Previews.Context.screenshot(self)
    }
}
```

画像はこんな感じで出力されます:rocket::rocket::rocket:
Previewで表示されていたものと同じ画面が撮影できているのがわかると思います。

<img width="700" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/41486/7230a61a-5ba5-9563-fc53-f65db66a2bd1.png"/>

上記の通り、 `Previewable` に適合した型の `preview` プロパティから、スクリーンショットを撮影したい状態のViewを返してあげるだけで、SwiftUIのPreviewとUnitTest両方から同じ状態の画面を参照することができました。

以下では仕組みの方の解説をします。

# ライブラリの準備
今回は [ios-snapshot-test-case](https://github.com/uber/ios-snapshot-test-case) を利用してスクリーンショットを撮影しました。
特にライブラリに依存した実装はないので、お好みのライブラリでスクリーンショットを撮影できるはずです。


ios-snapshot-test-case用の設定としてSchemeに以下の環境変数を設定します。
これにより、指定したディレクトリに画像が保存されます。
<img width="700" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/41486/1b52a49e-bb62-2980-0dfe-a6d0a633eaf8.png"/>

# Preiewableの定義

実装はこれだけです。詳細はコメントに記述しています。

```swift
protocol Previewable: CaseIterable, Hashable, RawRepresentable {
    associatedtype Preview: View
    var preview: Preview { get }
}

// Groupを使ってPreiewを分けたり、Stackを使って縦に並べるための便利関数です。
// 無くても問題ありません
extension Previewable where Self.AllCases: RandomAccessCollection, Self.RawValue == String {
    static var groupedAllContext: some View {
        Group {
            ForEach(Self.allCases, id: \.self) {
                // Stringのenumで定義しておけば、case名がそのままPreiew内に表示されるようにしています
                $0.preview.previewDisplayName($0.rawValue)
            }
        }
    }

    static var stackedAllContext: some View {
        VStack {
            ForEach(Self.allCases, id: \.self) {
                $0.preview.previewDisplayName($0.rawValue)
            }
        }
    }
}
```

```swift
extension Previewable where Self.AllCases: RandomAccessCollection, Self.RawValue == String {
    static func screenshot(_ testCase: FBSnapshotTestCase) {
        // 定義されてある全てのケースを1つずつ撮影していく
        Self.allCases.forEach { (ctx) in
            ctx.screenshot(for: testCase)
        }
    }

    func screenshot(_ testCase: FBSnapshotTestCase) {
        let window = UIWindow(frame: UIScreen.main.bounds)
        // previewプロパティから対象の状態のViewを生成します
        window.rootViewController = UIHostingController(rootView: self.preview)
        window.makeKeyAndVisible()
        let expectation = testCase.expectation(description: "")
        DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
            // ここで任意のスクリーンショット撮影処理を実行します。
            // 今回はFBSnapshotTestCaseのメソッドを使うため、画面に表示されたぐらいのタイミングで、UIViewの型でSwiftUIの画面を取り出して受け渡します
            // identifierにrawValueを渡すことで、出力されるファイルの名前を定義したcaseの名前になるようにしています
            testCase.FBSnapshotVerifyView(window.rootViewController!.view, identifier: self.rawValue)
            expectation.fulfill()
        }
        testCase.wait(for: [expectation], timeout: 5.0)
    }
}
```

以上で必要な実装は終わりです。

# サンプルコード
https://github.com/chocoyama/PreviewScreenshotSample