📸

pointfreeco/swift-snapshot-testing の基本的な使い方とUI変更後のスナップショット更新方法

に公開

はじめに

この記事では、

https://github.com/pointfreeco/swift-snapshot-testing

の基本的な使い方と、UI変更後にスナップショットを更新する方法の知見を共有します。

具体的には以下の内容を扱います:

  • 基本的な使い方とテスト実行の流れ
  • UIViewController と SwiftUI View でのデバイス指定やダークモード対応
  • スナップショット更新の具体的な方法(1件更新/複数更新)
  • record パラメータの使い分け

なお、この記事の内容は執筆時点の最新バージョン 1.18.7 に基づいており、READMEと実際の動作確認を情報源としています。

使い方

基本

SnapshotTesting モジュールをimportして、 assertSnapshot 関数を呼ぶだけです。

import SnapshotTesting
import Testing

@MainActor
struct MyViewControllerTests {
  @Test func myViewController() {
    let vc = MyViewController() // ✅ UIKitのUIViewもUIViewControllerも、SwiftUIのViewもなんでも可

    assertSnapshot(of: vc, as: .image)
  }
}

上記の例では、 assertSnapshot で as の値として .image を使い、画像として比較を行なっていますが、他にも、 .recursiveDescription を使い、プロパティやサブビューの階層構造を比較することができたり、 .raw を使い、URLリクエストを比較することができたりするようで、とても多機能になっています。
詳細は、READMEの Snapshot Anything を参照してみてください。

テスト実行

テストを初回実行すると、スナップショットがリファレンスとしてディスクに保存されます。
この時点では、テスト結果は失敗となりますが、これは正常な動作です。気にせず2回目のテスト実行を繰り返します。
2回目以降のテスト実行で、保存されたリファレンスと実行時の値が比較され、一致すればテスト成功、相違があればテスト失敗となります。

assertSnapshot(of: vc, as: .image) の .image プロパティの詳細

READMEやドキュメントではっきり見つからなかったので、手元で動作確認したものを載せています。
より適切な使い方があれば、ご指摘いただけると幸いです。

UIViewController の場合

image(on:drawHierarchyInKeyWindow:precision:perceptualPrecision:size:traits:)

定義

A snapshot strategy for comparing view controller views based on pixel equality.

static func image(
    on config: ViewImageConfig,
    drawHierarchyInKeyWindow: Bool = false,
    precision: Float = 1,
    perceptualPrecision: Float = 1,
    size: CGSize? = nil,
    traits: UITraitCollection = .init()
) -> Snapshotting<UIViewController, UIImage>

Available when Value is UIViewController and Format is UIImage .

使用例

assertSnapshot(of: vc, as: .image)
// デバイスを指定したい場合
assertSnapshot(of: vc, as: .image(on: .iPhoneSe))
// デバイスの向きも指定したい場合
assertSnapshot(of: vc, as: .image(on: .iPhoneSe(.portrait)))
assertSnapshot(of: vc, as: .image(on: .iPhoneSe(.landscape)))
// ライトモード、ダークモードを指定したい場合
assertSnapshot(of: vc, as: .image(traits: .init(userInterfaceStyle: .light)))
assertSnapshot(of: vc, as: .image(traits: .init(userInterfaceStyle: .dark)))

SwiftUIのViewの場合

image(drawHierarchyInKeyWindow:precision:perceptualPrecision:layout:traits:)

定義

A snapshot strategy for comparing SwiftUI Views based on pixel equality.

static func image(
    drawHierarchyInKeyWindow: Bool = false,
    precision: Float = 1,
    perceptualPrecision: Float = 1,
    layout: SwiftUISnapshotLayout = .sizeThatFits,
    traits: UITraitCollection = .init()
) -> Snapshotting<VStack<EmptyView>, UIImage>

Available when Value conforms to View and Format is UIImage .

使用例

assertSnapshot(of: view, as: .image)
// デバイスを指定したい場合
assertSnapshot(of: view, as: .image(layout: .device(config: .iPhoneSe)))
// デバイスの向きも指定したい場合
assertSnapshot(of: view, as: .image(layout: .device(config: .iPhoneSe(.portrait))))
assertSnapshot(of: view, as: .image(layout: .device(config: .iPhoneSe(.landscape))))
// ライトモード、ダークモードを指定したい場合
assertSnapshot(of: view, as: .image(traits: .init(userInterfaceStyle: .light)))
assertSnapshot(of: view, as: .image(traits: .init(userInterfaceStyle: .dark)))

UI変更後にスナップショットを更新する方法

前述の「使い方>テスト実行」で記述したように、2回目以降のテスト実行ではリファレンスと比較するだけで、リファレンス自体を更新することはありません。
意図的にリファレンスを更新するためには、手動で物理的にリファレンスを削除する以外にも、コードベースで更新を制御する方法を採ることができます。

READMEの Usage の

You can record a new reference by customizing snapshots inline with the assertion, or using the withSnapshotTesting tool:

の直後に、具体例の記載があります。

1件だけスナップショットを更新したい場合

assertSnapshot の引数に、 record: .all を追加します。

assertSnapshot(of: vc, as: .image, record: .all)

まとめてスナップショットを更新したい場合

Swift Testingフレームワークを使っている場合、次のように書くことができます。

- @Suite("SampleView Snapshot Tests")
+ @Suite("SampleView Snapshot Tests", .snapshots(record: .failed))
struct SampleViewSnapshotTests {
    @Test("iPhone Dark Mode")
    func iPhoneDarkMode() {
        let view = SampleView()

        assertSnapshot(
            of: view,
            as: .image(
                layout: .device(config: .iPhone13Pro),
                traits: .init(userInterfaceStyle: .dark)
            )
        )
    }

    @Test("iPad Landscape Light Mode")
    func iPadLandscapeLightMode() {
        let view = SampleView()

        assertSnapshot(
            of: view,
            as: .image(
                layout: .device(config: .iPadPro11(.landscape)),
                traits: .init(userInterfaceStyle: .light)
            )
        )
    }
}

record: に設定する値は、次の4種類があり、用途に合わせて利用できます。

  • .all
    • すべてのスナップショットを記録し直します。
  • .failed
    • 失敗したテストのスナップショットのみを記録します。
    • 精度のしきい値(precision)を使用するテストで便利です。合格したテストは、微妙に異なるがしきい値内のスナップショットを再記録しません。
  • .missing
    • ディスクに存在しないスナップショットのみを記録します。
  • .never
    • スナップショットを一切記録しません。スナップショットが存在しない場合、テストは失敗します。
    • このオプションは、CI上でテストを実行する際に適しています。テストの再試行時に、予期せずスナップショットが生成されてテストが意図せず成功してしまうことを防ぎます。

用途に分けて次のような使い方がいいかもしれません(知見募集中)。

状況 必要なパラメータ 備考
新規テスト追加 なし 初回実行で自動的にスナップショット作成
UIを部分的に変更 .failed(一時的) 失敗したテストのみ更新
UIを全面的に変更 .all(一時的) すべてのスナップショットを更新
閾値を使うテスト .failed(継続) 合格テストの不要な再記録を防ぐ
CI環境での実行 .never 意図しないスナップショット生成を防止
通常の検証 なし デフォルト動作

改めて、更新する流れを述べると次のようになります。

  1. プロダクトコードでUIを変更する。
  2. スナップショットテストが失敗する。
  3. スナップショットを更新する:
    • 1件だけ更新する場合: 該当のassertSnapshotにrecord: .allを追加する。
    • 複数まとめて更新する場合: @Suiteに.snapshots(record: .failed)や.snapshots(record: .all)を追加する。
  4. スナップショットテストを実行する。新しいスナップショットが作成・更新される。
    • diffでスナップショットが更新されていることが確認できます。
    • ✅ この時点では、テスト結果は失敗となりますが、これは正常な動作です。気にせず次の操作へ進みます。
  5. 追加したrecordパラメータを削除し、元のコードに戻す。
  6. スナップショットテストを実行する。テストが成功する。

おわりに

以上、 pointfreeco/swift-snapshot-testing を使ってみた知見の共有でした。
このライブラリは今後も更新されていくことが予想され、使い方が変動していくものと思われます。その際は、改めてREADMEやドキュメント、コード内のドキュメントコメントを読み直して、使い方を把握し直していこうと思います。
また、READMEの Plug-ins に記載されたプラグインについても、他の方の知見も参考にして活用できたらいいなと思っています。

参考情報

https://github.com/pointfreeco/swift-snapshot-testing
https://swiftpackageindex.com/pointfreeco/swift-snapshot-testing/1.18.7/documentation/snapshottesting

プラグイン Prefire の活用例:
https://zenn.dev/prevent/articles/339b960116cfe6

Discussion