はじめに
SwiftUIのPreview機能を活用して、実装したPreview用のコードをそのままScreenshot撮影自動化に利用できる方法の紹介です。
わざわざテスト用のコードを実装することなく、既存の記述をほぼそのままCIとして組み込めるので、かなり簡単に対応できると思います。
これを使って、画像diffから意図しないUI変更を検知したり、簡単なUIカタログのようなものを作ることもできます。
完成形
先に完成形を記載しておきます
Previewの実装
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画面での実際の表示はこんな感じ
テストコードの実装
テスト用の実装はこれだけでOKです
class PreviewScreenshotSampleTests: FBSnapshotTestCase {
override func setUp() {
super.setUp()
// ここは、今回利用したFBSnapshotTestCaseのための実装です
self.recordMode = true
}
func test() {
// Previewableに適合した型の、screenshotメソッドを呼び出すだけです。
ContentView_Previews.Context.screenshot(self)
}
}
画像はこんな感じで出力されます
Previewで表示されていたものと同じ画面が撮影できているのがわかると思います。
上記の通り、 Previewable
に適合した型の preview
プロパティから、スクリーンショットを撮影したい状態のViewを返してあげるだけで、SwiftUIのPreviewとUnitTest両方から同じ状態の画面を参照することができました。
以下では仕組みの方の解説をします。
ライブラリの準備
今回は ios-snapshot-test-case を利用してスクリーンショットを撮影しました。
特にライブラリに依存した実装はないので、お好みのライブラリでスクリーンショットを撮影できるはずです。
ios-snapshot-test-case用の設定としてSchemeに以下の環境変数を設定します。
これにより、指定したディレクトリに画像が保存されます。
Preiewableの定義
実装はこれだけです。詳細はコメントに記述しています。
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)
}
}
}
}
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)
}
}
以上で必要な実装は終わりです。