Xcode PreviewsからSnapshotテストを自動生成する

Merpay Advent Calendar 2020 の6日目は、メルペイiOSチームの kenmaz がお送りします。


こんにちは。merpayのiOSチームのkenmazです。

この記事では「Xcode PreviewsからSnapshotテストを自動生成する」というテーマで、メルペイiOSチームで採用しているiOSアプリの開発スタイルや自動テストについての取り組みの現状、そこで直面した課題、そしてその解決方法について、具体例を交えながら紹介していきます。

背景

Xcode Previewsを用いた開発

メルペイのiOSチームでは、iOSアプリのほとんどのViewやUI部品をコードで記述し、Xcode Previewsを使ってレイアウトの確認を行う、という開発スタイルを採用しています。これによりiOS Simulatorなどを起動することなく、さまざまな状態のViewをXcode上でリアルタイムにプレビューできます。またStoryboard/xibは使用せず、コードで全てのViewを記述しています。これにより修正内容の競合の解決やソースコードレビューも簡単になり、チーム開発の効率も向上します。

Xcode Previewsを使った開発については、昨年のアドベントカレンダーのブログ記事や、iOSDC2020でも発表していますので、興味のある方は是非ご覧ください。

テスト自動化

メルペイiOSチームでは、開発効率の向上だけではなく、ソフトウェア品質の維持向上のために、以下のようなテスト自動化の取り組みも行っています。

テスト種別 説明
ユニットテスト UIを伴わない、ビジネスロジック単体のテスト。
スナップショットテスト View単位のテスト。さまざまな状態のViewのスナップショット画像をキャプチャして比較することで、描画が正しく行われているかをテストする。
UIテスト あらかじめ記述しておいたスクリプトに沿ってアプリを自動で起動して操作し、期待通りの振る舞いを行うかをテストする。

このように、各レイヤーごとに手厚くテストコードを記述しておくことで、機能改修が必要な場合であっても、デグレードを恐ることなく、自信をもって開発を進めることができます。

テスト自動化の課題

いずれのテストもプロダクションコードとは別にテストケースを一つ一つ記述していく必要があるので、それなりの開発工数が必要です。メルペイもアプリをリリースしてから2年近くが経過し、大規模になってきました。それに比例してテストコードの分量も増大し、開発のための負担も大きくなってきました。

なんとか手厚い自動テストを維持しつつ、テストコードの開発の負荷を下げる方法はないでしょうか?

そこで考えついたのが、Previewコードからスナップショットテストのコードを自動生成するというアイデアでした。

Xcode Previewsを用いた開発のポイントは、

各Viewのさまざまな状態をPreviewコードとして記述することで、Xcode上でその描画結果を一度に確認できる

というものでした。 自動テストのうちのひとつ、スナップショットテストは

各Viewのさまざまな状態をスナップショットテストコードとして記述し、その描画結果を画像としてキャプチャし比較することで、正しく描画が行われていることをテストする

というものでした。

「各Viewのさまざまな状態」はすでに Previewコード として記述されています。そこで、Previewコードが描画するプレビュー結果そのものを画像としてキャプチャし比較することで、スナップショットテストコードに転用可能なのでは、という一石二鳥的なアイデアを思いつきました。

メルペイのエキスパートチームの協力のもと、このアイデアについて実現方法の調査を行い、一部プロジェクトへの導入・運用を開始しました。

ここからは具体的な例を交えて詳細を説明していきます。

実例

プレビュー対象のビュー

メルペイiOSチームでは新規に画面を開発するときは、UIViewのサブクラスのコードを記述すると同時に、プレビュー用のコードも作成するようにしています。

ここでは例として、メルペイの「おくる・もらう」機能の「受け取り承認」画面の一部を構成する ConfirmationStateView というUIViewのサブクラスの開発について考えてみます。

以下にコードを抜粋します。

final class ConfirmationStateView: UIView, .. {
    struct Input {
        struct UserProfile {
            let screenName: String
            ...
        }
        ...
    }
    ...
    private lazy var screenNameLabel: UILabel = {
        let label = UILabel(...)
        ....
        return label
    }()
    ...
    func apply(input: Input) {
        screenNameLabel.text = input.profile.screenName
        ...
    }

このViewの上部には、受取人の名前を表示するためのscreenNameLabel: UILabelが配置してあり、名前の長さに応じてそのレイアウトが柔軟に変わるようにレイアウトが実装されているとします。

またこのViewには、Viewの状態を外部から変更するためのInput型とapply(input:)メソッドが定義されています。このViewをアプリに組み込むときはViewControllerなどで適切なInputオブジェクトを作成し、Viewのapplyメソッドを呼び出すことで、Viewを任意の状態に変更できるような設計になっています。

Previewコードの記述

さて、ではこのViewのレイアウトの実装が正しく動作するかをPreviewコードを記述して確認してみましょう。ここでは以下のようなPreviewコードを記述します。

struct ConfirmationStateViewPreview: PreviewProvider { //(1)

    struct Wrapper: UIViewRepresentable { //(2)
        let input: ConfirmationStateView.Input
        init(input: ConfirmationStateView.Input) {
            self.input = input
        }
        func makeUIView(context: UIViewRepresentableContext<Wrapper>) -> ConfirmationStateView {
            let view = ConfirmationStateView() //(3)
            view.apply(input: input) //(4)
            return view
        }
        func updateUIView(_ uiView: ConfirmationStateView, context: UIViewRepresentableContext<Wrapper>) {
        }
    }

    static var previews: some View { //(5)
        Group {
            Wrapper(
                input: .init(
                    profile: .init(
                        ...
                        screenName: "短い名前"
                    ),
                    ...
                )
            )
            Wrapper(
                input: .init(
                    profile: .init(
                        ...
                        screenName: "少し長い名前少し長い名前"
                    ),
                    ...
                )
            )
            Wrapper(
                input: .init(
                    profile: .init(
                        imageURL: nil,
                        screenName: "長い名前長い名前長い名前長い名前長い名前"
                    ),
                    ...
                )
            )
            ....
        }
}

このPreviewコードでは、まず (1) で PreviewProviderプロトコルに適合した ConfirmationStateViewPreview 型を定義しています。 Xcodeは PreviewProvider を含むSwiftコードが開かれていることを検知すると、ソースコードエディタの右側にプレビューを表示します。

次に (2) で、プレビューしたいビューをラップした Wrapper を定義しています。Xcode PreviewsでプレビューできるのはSwiftUIのViewだけです。しかし今回プレビューしたいのはUIViewのサブクラスなので、それをSwiftUIのビューとして扱えるように、UIViewRepresentableプロトコルに適合した型でラップしてあげる必要があります。

Wrapper型の内部では、makeUIView()メソッドで、(3)で今回プレビューしたい対象のビューである ConfirmationStateViewを初期化し、(4)でWrapperのコンストラクタ引数として指定された ConfirmationStateView.Inputを使って applyメソッドを呼び出してビューを任意の状態に変更できるように記述しています。

最後に(5)で previews プロパティを定義して、プレビューさせたい状態のビューを返しています。ここでは3種類の異なる長さのscreenNameをもったWrapperを作成して返すようにしています。Xcodeはこのpreviewsが返すViewをもとに、プレビューを描画します。

このPreviewコードをXcodeで開けば、上図のように、さまざまな状態のビューをプレビュー表示できます。他のパターンのプレビューを追加したければ、同じようなコードを追加するとリアルタイムに右側のプレビュー領域でその結果を確認できます。

スナップショットテストを手作業で書いてみる

次はテストコードを書いていきましょう。UIを伴わないビジネスロジックのテストであれば、テスト対象のメソッドを呼び出して、その返値が想定通りであるかを検証するユニットテストのコードを記述しますが、Viewのテストはどのように記述すればよいでしょうか。

メルペイiOSチームではそのような場合は、Viewのスナップショットテストを実装するようにしています。 たとえばPreviewコードと同様に、3種類の異なる長さの名前を設定したときのViewの表示結果を検証するテストケースはそれぞれ以下のように記述できます。

import XCTest
import SnapshotTesting

class ConfirmationStateViewTests: XCTestCase {

    func testShortName() {
        let view = ConfirmationStateView() //(1)
        let input = ConfirmationStateView.Input( //(2)
            profile: .init(
                screenName: "短い名前"
            ), ...
        )
        view.apply(input: input)
        assertSnapshot(matching: view, as: .image) //(3)
    }

    func testMiddleName() {
        let view = ConfirmationStateView()
        let input = ConfirmationStateView.Input(
            profile: .init(
                screenName: "少し長い名前少し長い名前"
            ), ...
        )
        view.apply(input: input)
        assertSnapshot(matching: view, as: .image)
    }

    func testLongName() {
        let view = ConfirmationStateView()
        let input = ConfirmationStateView.Input(
            profile: .init(
                screenName: "長い名前長い名前長い名前長い名前長い名前"
            ), ...
        )
        view.apply(input: input)
        assertSnapshot(matching: view, as: .image)
    }

それぞれのテストケースで記述していることは単純です。例えば testShortName メソッド では

  • (1) テスト対象のViewを初期化
  • (2) screenNameとして"短い名前"を指定してInputオブジェクトを作成し、applyメソッドを呼び出してViewを任意の状態に変更
  • (3) assertSnapshotメソッドを呼び出して、Viewが想定通りに描画されているかを検証

最後のassertSnapshotメソッドについては少し説明が必要でしょう。

メルペイiOSチームではスナップショットテストの実装に、SnapshotTesting というライブラリを使用しています(※)。assertSnapshotはそのライブラリが提供するメソッドのひとつです。

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

assertSnapshotメソッドは、第一引数に検証対象のViewを指定しておくと、初回のテスト実行時にのみ、検証対象のViewをビットマップ画像としてキャプチャし「参照画像」として所定のパスに保存します。再度テストを実行すると、今度は検証対象のViewをキャプチャした画像データと、既存の参照画像をピクセル単位で比較することで、Viewが想定通りに描画できたかを検証しています。

さて、あとは他のテストケースも同様に、テストコードをコピペして、screenName"少し長い名前...""長い名前..." など入れ替えて同じようなテストケースを記述していけば、、スナップショットテストは完成です。

とはいえ、これらの作業はやはり多少面倒でもあります。さらに直前にPreviewコードの方でも同じようなコードを書きました。なんとか再利用する方法はないでしょうか?

(※)なおメルペイiOSチームではiOSSnapshotTestCaseを使ったスナップショットテストも一部導入しています。詳細はこちらの記事をご覧ください。

SnapshotテストからPreviewを参照する

ここでPreviewコードと、Snapshotテストを比較してみると、同じようなコードが存在することに気づきます。いずれも次の2点点、

  1. テスト対象のViewの初期化
  2. Inputオブジェクトを作成し、applyメソッドを呼び出してViewを任意の状態にする

というところまでは同じです。

そこで、スナップショットのテストコード内で上記1, 2のコードを直接記述する代わりに、Previewコードですでに作成してあるViewを取得して、そのままスナップショットテストのテスト対象ViewとしてassertSnapshotメソッドに与えてみます。

すると上述のConfirmationStateViewTestsは、以下のように書き換えることができます。

import SwiftUI //(1)
import XCTest
import SnapshotTesting

class ConfirmationStateViewTests: XCTestCase {
    func testConfirmationStateViewPreview() {
        for preview in ConfirmationStateViewPreview._allPreviews { //(2)
            assertSnapshot(matching: preview.content, as: .image) //(3)
        }
    }
}

書き換えたあとは随分すっきりしました。

(1)では、スナップショットテスト内でPreviewコードを参照する必要があるため、SwiftUIモジュールをimportしています。

(2)では、Previewコードで定義したPreviewProviderプロトコルに準拠したConfirmationStateViewPreview型の_allPreviewsプロパティを参照することで、previewsプロパティが返す各Previewを表す_Preview型のオブジェクトを取得しています。 _allPreviews_Previewは接頭辞に_がついていることからもわかる通り、非公開APIです。今後のXcodeのアップデートによっては使用できなくなる可能性がある点には注意しておく必要があります。

最後に (3)で、preview.contentによって、各Previewに対応するSwiftUIのView、この例の場合はWrapperオブジェクトをAnyView型として取得しています。SnapshotTestingライブラリが提供するアサーションメソッドassertSnapshotは、SwiftUIのViewに対しても使用可能なので、preview.contentを直接渡すことで、最終的にPreviewコードで定義したViewをスナップショットテスト用のViewとして再利用できるようになります。

このように書き換えたConfirmationStateViewTestsを通常のテストコードとしてXcode上で実行すると、元のConfirmationStateViewTestsと同様に、初回実行時であればスナップショットテスト対象のViewを画像としてキャプチャしたものが、テストコードが存在するディレクトリ配下の__Snapshots__/ConfirmationStateViewTests/ディレクトリ以下にPNGファイルとして書き出されます。

さて、あとは他のPreviewコードに対しても同様のテストコードを書いていけば良いわけですが、メルペイではすでにたくさんのPreviewコードが存在するので、それらひとつひとつに対して手動でテストコードを書いていくのは手間がかかります。

テストコードでやっている事は、PreviewProvider._allPreviewsで各Previewを取得して、それをassertSnapshotメソッドに引数として与えているだけです。そこで、次はこのテストコードをSourceryを使って自動生成することを考えてみます。

Sourceryを使ってPreviewコードからSnapshotテストコードを自動生成する

https://github.com/krzysztofzablocki/Sourcery

SourceryはSwift言語向けのコードジェネレーターです。Sourceyを使うと、プロジェクト内にあるSwiftコードから条件に合うものを抽出し、その型情報をテンプレートに流し込んでソースコードを生成する、といったことができます。

今回は、プロジェクト内にあるすべてのSwiftコードから、PreviewProviderに適合した型を探し、_allPreviewsを使って各Previewを取得しassertSnapshotメソッドに渡すコードを生成して、上述したテストコードと同様のことを実現します。

Sourceryテンプレートは以下のようになります。

private func assertPreviewSnapshot<T: PreviewProvider>( //(1)
    _ target: T.Type,
    file: StaticString = #file, testName: String = #function, line: UInt = #line
) {
    for preview in T._allPreviews {
        assertSnapshot(matching: preview.content, as: .image, file: file, testName: testName, line: line)
    }
}

class PreviewSnapshotTests: XCTestCase {
{% for type in types.based.PreviewProvider %} //(2)
    func test{{type.name}}() { //(3)
        assertPreviewSnapshot({{type.name}}.self) //(4)
    }
{% endfor %}
}

_allPreviewsからPreviewを取得してassertSnapshotメソッドに渡すコードに関してはいずれのテストケースでも共通なので、(1)でassertPreviewSnapshotメソッドとして抽出しています。PreviewProviderに適合した型のメタタイプをメソッド引数として与えることで、そのPreviewProviderに含まれる各Previewのスナップショットテストを実行できるようになっています。

テストコード本体の生成に関して、ポイントとなるのが (2) の {{ types.based.PreviewProvider }} という記述です。このようにテンプレートに書いておくことで、Sourceryは対象となるSwiftコードに定義された全ての型情報をチェックし、PreviewProviderプロトコルに適合した型の情報のみをすべて取得します。取得したそれぞれの型情報に対して{{ type.name }}と記述することで、それらの型名が取得できます。ここでは、(3)でPreviewProviderごとにテストメソッドを定義し、(4)で {{ type.name }}.self と記述することで各Previewコードに定義したPreviewProviderのメタタイプをassertPreviewSnapshotメソッドの引数として渡せるようになっています。

あとはPreviewコードが含まれるターゲットのビルド時に、sourceryコマンドを実行して、Previewコードをビルドするたびに、スナップショットテストコードを自動生成するように設定しておきます。

Sourceryのテンプレートを活用した結果、例えばメルペイの「おくる・もらう」に関するPreviewコードをビルドすると、以下のようなスナップショットテストコードが自動生成されます。

「おくる・もらう」機能に関するすべてのPreviewコードに対して、それぞれテストケースが生成されていることがわかります。 このテストコードを実行すると、初回は以下のようなスナップショットテスト用の参照画像が生成されます。

以降はテストを実行するたびに、各Viewの描画結果が参照画像と一致するか比較し、差分がある場合はテストが失敗するので、意図せぬ変更やレイアウトのバグに即座に気づくことができるようになります。

また、動作確認のための新しいPreviewコードを書くたびに、対応するスナップショットテストケースが自動的に追加されるので、安心して新規画面を追加していけます。

CIへの組み込み

最後に、スナップショットテストの実行をCIに組み込んでおきます。メルペイiOSチームでは、新たなコミットがプッシュされたタイミングでスナップショットテストを実行するように設定しています。これにより、意図しないViewの変更があれば即座に気づくことができます。

また、スナップショット用の参照画像を更新するための Capture Snapshot 📸 というGitHubラベルも用意してあります。既存のViewを修正したり、新たなViewを追加したりする場合は、まず開発者はGitHub上でPull Requestを作成し、このラベルを設定します。するとCIマシンは、すべてのスナップショットテストに対して参照画像の再キャプチャを実行し、差分があった場合は参照画像を更新する新たなPull Requestを自動的に作成します。開発者はその内容を確認し、問題なければマージすることで、スナップショット用の参照画像を簡単に最新状態に保つことができます。

ここまでのまとめ

ここまでの流れをまとめてみましょう。

ViewコードとPreviewコードを作成するだけで、そのPreviewコードを元にしたスナップショットテストコードとその参照用画像が自動生成される仕組みを構築しました。

Viewコードの開発時はXcode Previewを使ってさまざまなViewの状態をチェックしながら開発を行います。さらにPreviewコードから、スナップショットコードを自動生成します。これによりPreviewコードは開発時のみの使い捨てコードではなく、反復的に検証可能なスナップショットテストとして再活用できます。

Previewコードをきちんと書けば書くほど、さまざまなテストケースを網羅したスナップショットテストが自動生成される、という一石二鳥な仕組みができたと言えます。

Tips

最後にPreviewコードからスナップショットテストを自動生成する仕組みを構築する際の、細かいコツや注意点について紹介していきます。

Previewコードとスナップショットテストのサイズを合わせる

SnapshotTestingのassertSnapshotメソッドはデフォルトではテストを実行したiOS Simulatorの画面サイズでビューをキャプチャします。一方Previewコードでは .previewLayout(.fixed(width: 320, height: 100)) などと指定することで、プレビュー時のViewのサイズを指定できます。 PreviewのサイズとSnapshotのサイズを合わせるには、テストコードを以下のように記述します。

    for preview in T._allPreviews {
        switch preview.layout {
        case .fixed(let width, let height): //(1)
            assertSnapshot(
                matching: preview.content.edgesIgnoringSafeArea(.all), //(2)
                as: .image(layout: .fixed(width: width, height: height)), //(3)
                ...
            )
        default:
            assertSnapshot(
                matching: preview.content,
                as: .image,
                ...
            )
        }
    }

(1)のswitch-case文でPreviewコードでサイズ指定が行われているかチェックし、サイズ指定がある場合は(3)でキャプチャ時のサイズをPreviewコードのサイズに合わせています。また同時に(2)でsafeAreaの影響を無視するようにしています。

細長いPreviewを正しくキャプチャできない

SnapshotTestingライブラリ内部では、assertSnapshotで指定したSwiftUIのViewをUIHostingControllerのrootViewに設定して、それを指定したサイズのUIWindowrootViewControllerにセットした上でUIGraphicsImageRendererを使ってviewを画面をキャプチャする、といったことを行っています。その際にsafeAreaInset.topが影響して、高さが44pt以下のPreviewを正しく表示できない、という問題に直面しました。

この問題については、テスト実行時にPreviewの高さをチェックして、44pt以下の場合はPreviewの下部にSpacerを追加してから、assertSnapshotメソッドにviewを渡す、という方法で回避することができます。

    for preview in T._allPreviews {
        switch preview.layout {
        case .fixed(let width, let height):
            //Workaround for thin horizontal previews
            let adjustedHeight = max(0, 44 - height)
            let view = VStack {
                preview.content.frame(width: width, height: height)
                Spacer().frame(width: width, height: adjustedHeight)
            }
            assertSnapshot(
                matching: view.edgesIgnoringSafeArea(.all),
                as: .image(layout: .fixed(width: width, height: height + adjustedHeight)), //
                ...
            )

以下のように細長いPreviewも正しくキャプチャできました。

スナップショットテスト実行環境の違いによる描画結果の差分

iOS SimualatorやMacの言語設定によっては、同じコードを実行しても描画結果が微妙に変わってしまうことがあります。例えば言語設定に日本語が追加されていない場合に漢字が中国語フォントで描画されてしまったり、アクセシビリティの設定でフォントサイズが変更されている場合などです。そのようなトラブルを防ぐために、スナップショットテストの参照画像のキャプチャやテストの実行については、基本的にCIサーバー上だけで行うようにしています。

Xcode12以降でPreviewが正しく表示されない場合の原因追跡方法

これはXcode Previews自体の問題ですが、Preview対象のコード自体にレイアウトの不整合があると、Xcode Previewsがクラッシュして正しくプレビュー表示されないことがあります。Xcode11までは画面上部の(i)ボタンを押せば直接クラッシュログが確認できたのですが、Xocde12からは操作が変更されており、

画面上部のDiagnosticsボタンをタップし、

表示されるポップアップ画面下部からGenerate Report > Reveal in Finderを選択し、

しばらく待ったのちにFinderで表示されるフォルダの中から preview-diagnostics-xxxx のような名前のフォルダを探して、その中のCrashLogsフォルダ内にようやく.crashファイルが見つかる、という面倒な操作が必要になっています。

Appleへはフィードバック済みなのですが、今後のXcodeのアップデートで改善すると良いですね。

まとめ

以上「PreviewコードからSnapshotテストを自動生成する」というテーマで、メルペイiOSチームでの事例を紹介しました。ここで紹介したテクニックはUIKitベースのアプリだけではなく、SwiftUIアプリの開発においても活用できるものなので、興味のある方はぜひ試してみてください。

謝辞

今回紹介した仕組みの構築は、メルペイエキスパートチームの @kateinoigakukun の強力なサポートがあり実現できました。またチームメンバーの @yusuke0518@mime29 には実プロジェクトへの導入作業に大きく貢献していただきました。Thanks!


明日のMerpay Advent Calendar 2020 執筆担当は、 エキスパートチームの kateinoigakukun さんです。引き続きお楽しみください。