「GigaViewer for Apps」 iOS アプリにおける VRT とユニットテスト

iOS アプリエンジニアの id:maiyama4 です。『Inside GigaViewer for Apps』連載7回目は、出版社向けマンガビューワのアプリ版である「GigaViewer for Apps」(以下 GigaApps)の iOS アプリのテストについて、Visual Regression Testing(以下 VRT)に重点を置いて紹介します。

VRT

VRT は、アプリケーションのコードを変更した際に UI の差分を検知することで、意図しない表示くずれが発生していないかを確認するためのテストです。変更前の UI のスナップショットを参照画像として保存しておき、変更後のスナップショットと比較します。ここで VRT が差分を検知することで、UI が想定外に変更されていても気付くことができます。

本章では GigaApps で実施している VRT について、具体的な実装まで踏み込んで説明します。

VRT の導入経緯

VRT は UI を伴う開発において一般的に有用な手法ですが、GigaApps では特に導入を検討したい理由がありました。

GigaApps は1つのコードベースで複数のメディアに対してアプリを提供するマルチテナントアプリです。そのため、「様々なマンガアプリを素早く開発できる「GigaViewer for Apps」のしくみ iOS 編 」(以下、参照記事)で詳しく説明されているように、共通の画面や UI コンポーネントを複数のアプリから利用しています。共通とはいえ、アプリごとにブランドカラーも異なりますし、表示される UI 要素や文言をカスタマイズしたいこともあります。このカスタマイズは、コンポーネント外部からの DI や内部で処理の分岐により実現されています。 そのような作りになっている関係上、たとえば特定のメディア向けの機能開発で共通コンポーネントを変更し、そのメディアで想定通りの表示になっていることを確認したとしても、別のメディアでは表示くずれが発生する可能性があります。

メディア間で意図しない影響が及んでしまうことの懸念は、アプリを提供するメディアの数が増えるにつれて大きくなっていきます。2025年5月現在、GigaApps が提供するメディアは2つですが、今後メディアを増やしていくことを目指しています。UI の観点でこの懸念を軽減するための手法として VRT を導入することにしました。

VRT のツール

VRT のためのツールとして pointfreeco/swift-snapshot-testing を利用しています。swift-snapshot-testing は、アプリの画面のスナップショットの撮影と、過去に撮影した参照画像との比較をしてくれるライブラリです。選定理由は、用途に対して機能が必要十分だったことと、iOS アプリで使えるほかの同様のツールと比較して現在も開発が活発であることが挙げられます。

swift-snapshot-testing を利用した VRT は、ユニットテストと同様に XCTestCase を継承したクラスの中にテストケースを書いていきます。メソッドの中で assertSnapshot という関数を呼ぶことでスナップショットを検証します。詳しいテストの書き方は後述します。

VRT 対象の選定

テストはリグレッションを防ぎますが、実行時間やメンテナンスの手間を考えるとそれ自体がコストです。特に、VRT は一般的なユニットテストと比較しても、成否を不安定にする要素が多いことに注意する必要があります。GigaApps での VRT の主な目的は開発中のメディア間での意図しない影響による表示くずれを防ぐことですので、カバレッジを目指すのではなく、メディア間の分岐が多い画面のみに VRT を書くように、チームで相談しつつテスト対象の画面を選定しました。

VRT 対象の画面の一例は、作品詳細画面です。実装は共通ですが、アプリの画面を見てみるとコミックガルド+と少年ジャンプ+では、ブランドカラーやサムネイルのアスペクト比、チケットの利用可否やエピソードの購入に必要なコイン / ポイントの表記などが異なります。この分岐は、前掲した参照記事の「アプリに合わせたカスタマイズ」というセクションで紹介されている手法により行われています。

コミックガルド+ 少年ジャンプ+

VRT を実行するデバイス

swift-snapshot-testing による VRT では、スナップショット撮影に利用するデバイスを指定できます。たとえば、iPhone で VRT が通っても実は iPad では表示くずれが起きていたということを防ぐためには、いくつかのデバイスで VRT を行う必要があります。特に、GigaApps の作品詳細画面の上部のように、サイズクラスによる明示的なレイアウトの出し分けは、VRT のテストケースとして網羅したい箇所です。

iPhone iPad

すべての iOS / iPadOS デバイスで VRT を行えば網羅率は高くなりますが、デバイスを増やすほどにテストの実行時間や参照画像の容量が増えるというコストもあります。GigaApps では、画面の縦と横のサイズクラスの組み合わせを網羅できるように以下の4つのデバイスで VRT を実行しています。

  • iPhone 13 mini Portrait (縦 : regular / 横 : compact)
  • iPhone 13 mini Landscape (縦 : compact / 横 : compact)
  • iPhone 13 Pro Max Landscape (縦 : compact / 横 : regular)
  • iPad Pro 11 Portrait (縦 : regular / 横 : regular)

メディアごとの設定を反映するためのモジュール構成

参照記事の「マルチモジュール」セクションでも触れたように、GigaApps では画面はフィーチャーモジュールとして切り出され、そのモジュールが各メディアから利用される構成になっています。前項で例に挙げた作品詳細画面は ScreenSeriesDetail というモジュールで実装されています。作品詳細画面は GigaApps が提供するアプリである少年ジャンプ+とコミックガルド+の両方で利用されているため、ScreenSeriesDetail にそれぞれのアプリケーションターゲットから依存しています。

前述したように、作品詳細画面はメディア間で UI が異なる部分が多く、これは参照記事の「アプリに合わせたカスタマイズ」セクションに記載したローカライズインジェクション、アセットインジェクション、Needle での DI といった方法で、アプリケーションターゲットから ScreenSeriesDetail モジュールをカスタマイズすることで実現しています。GigaApps における VRT の主目的はメディア間での意図しない影響を検知することですので、それらのメディア特有の要素を反映させたスナップショットで VRT を実行することが必須です。

そのために、ScreenSeriesDetail に直接 VRT のテストターゲットを依存させるのではなく、各アプリケーションターゲットごとの VRT のテストターゲットを作る必要があります。しかし、メディア間では多くのテストケースが共通ですので、同じテストケースをアプリケーションの数だけ用意する設計だと、メンテナンス性が悪くなってしまいます。そこで、メディア間で共通の VRT 実装を書く GigaAppsSnapshotTests というモジュールを用意して、それに対してアプリケーションごとの VRT テストターゲットから依存するようにします。

このような構成にすることで、テストケースは GigaAppsSnasphotTests の中に1箇所書くだけで、メディアごとのテストターゲットではスナップショットにアセット・文言・設定値を反映させて VRT を行うことができます。同じテストケースを、コミックガルド+のテストターゲット ComicGardoSnapshotTests と少年ジャンプ+のテストターゲット JumpPlusSnapshotTests でそれぞれ実行すると、以下のようにブランドカラー、「コイン」「ポイント」の文言とアイコン、サムネイルのアスペクト比などがメディア向けにカスタマイズされたスナップショットが撮影されます。

コミックガルド+ 少年ジャンプ+

具体的な VRT の書き方は次の項で説明します。

VRT の実装

作品詳細画面の上部を例に、VRT の書き方を示します。

GigaApps では、Xcode Preview のバリエーションをそのまま VRT のテストケースとして利用しています。Preview を VRT から利用するためのインタフェースとして、Preview Provider を準拠させる protocol を追加します。

public protocol SnapshotIdentifiable {
    var displayName: String { get }
}

public protocol SnapshotPreviewProvider {
    associatedtype Preview: View
    associatedtype PreviewPattern: SnapshotIdentifiable

    static var previewPatterns: [PreviewPattern] { get }

    @ViewBuilder @MainActor static func makePreview(pattern: PreviewPattern) -> Preview
}

作品詳細画面の上部も含めて VRT 対象になるような画面は、受け取った入力に応じて表示を変えます。その入力のパターンを PreviewPattern 型として表し、その配列を previewPatterns プロパティに持ちます。さらに、受け取ったPreviewPattern 型の入力を元に Preview を組み立てる処理を makePreview 関数に書きます。このように、Xcode Preview の書き方を protocol に従う形で決めておくことで、入力のパターンを VRT からも利用できます。PreviewPattern はそのパターンの説明である displayName を持つように、 SnapshotIdentifiable protocol に準拠しています。

SnapshotPreviewProvider に準拠している、作品詳細画面の上部の Xcode Preview の例を以下に示します。VRT の説明のため、本筋に関係ない部分を大幅に省略しています。

struct SeriesDetailOverviewArea_Previews: PreviewProvider, SnapshotPreviewProvider {
    struct PreviewPattern: SnapshotIdentifiable {
        let displayName: String
        let title: String
        let authorName: String
        // 作品詳細の描画に必要な他のプロパティ
        // ...
    }

    static let previewPatterns: [PreviewPattern] = [
        .init(
            displayName: "パターン1",
            title: "タイトル1",
            authorName: "著者1",
            // ...
        ),
        .init(
            displayName: "パターン2",
            title: "タイトル2",
            authorName: "著者2",
            // ...
        ),
        // 他のパターン
        // ...
    ]

    static func makePreview(pattern: PreviewPattern) -> some View {
        SeriesDetailOverviewArea(
            title: pattern.title,
            authorName: pattern.authorName,
            // 作品詳細の描画に必要な他のプロパティ
            // ...
        )
        .previewDisplayName(pattern.displayName)
    }

    static var previews: some View {
        Group {
            ForEach(previewPatterns, id: \.displayName) { pattern in
                makePreview(pattern: pattern)
            }
        }
    }
}

複雑に見えますが、VRT のために SnapshotPreviewProvider protocol に準拠しているだけで、ここまでは通常の Xcode Preview です。

SnapshotPreviewProvider protocol に準拠した Preview を利用して VRT を実行するためのクラスとして、 GigaAppsSnapshotTests を以下のように実装します。このクラスは作品詳細画面に限らず、いろいろな画面の VRT に利用できます。このクラスについても、わかりやすいように実際のものを簡略化して示しています。

open class GigaAppsSnapshotTests<SnapshotPreviews: SnapshotPreviewProvider>: XCTestCase {
    open var mediaName: String? { nil }
    open var snapshotDevices: [SnapshotDevice] = [
        .init(displayName: "iPhone-13-Mini-Portrait", config: .iPhone13Mini(.portrait)),
        .init(displayName: "iPhone-13-Mini-Landscape", config: .iPhone13Mini(.landscape)),
        .init(displayName: "iPhone-13-Pro-Max-Landscape", config: .iPhone13ProMax(.landscape)),
        .init(displayName: "iPad-Pro-11-Portrait", config: .iPadPro11(.portrait)),
    ]

    @MainActor
    func testPreviewSnapshots(
        file filePath: StaticString = #filePath,
        testName: String = #function
    ) {
        guard let mediaName else { return }

        for pattern in SnapshotPreviews.previewPatterns {
            for device in snapshotDevices {
                assertSnapshot(
                    of: UIHostingController(rootView: SnapshotPreviews.makePreview(pattern: pattern)),
                    as: .image(on: device.config, perceptualPrecision: perceptualPrecision),
                    named: "\(mediaName)-\(pattern.displayName)-\(device.displayName)",
                    file: filePath,
                    testName: testName
                )
            }
        }
    }
}

VRT を行う対象の SnapshotPreviewProvider を型パラメータ SnapshotPreviews に渡します。その previewPatterns プロパティを元に makePreview した SwiftUI.ViewUIHostingController に渡すことで、スナップショットの撮影と参照画像との比較をしています。その際に、前の項で触れたように縦・横のサイズクラスの組み合わせを網羅した4つのデバイスで撮影しています。デバイスを表現するために、以下の SnapshotDevice という構造体を作って、swift-snapshot-testing が提供している型である ViewImageConfig をラップしています。

public struct SnapshotDevice {
    public let displayName: String
    public let config: ViewImageConfig

    public init(displayName: String, config: ViewImageConfig) {
        self.displayName = displayName
        self.config = config
    }
}

ラップしている目的は、デバイスの名前として displayName を指定するためです。この displayNameassertSnapshot 関数の named パラメータに使われています。named に渡した文字列は、保存される参照画像のファイル名の一部として使われるため、VRT のパターン内でユニークになる必要があります。メディア x 入力パターン x デバイスを特定できるように、のちほどアプリケーションのテストターゲットから値を指定する mediaName と、入力パターンを表す PreviewPatterndisplayName、デバイスを表す SnapshotDevicedisplayName が入るようにしています。

実際に VRT を書く際には GigaAppsSnapshotTests を継承することになるため、もし特定の画面では VRT を実行するデバイスを増やしたり変更したりしたいということがあれば、open で宣言されている snapshotDevices を継承先のクラスで override することで実現できます。

それでは、作品詳細画面の VRT を実装していきます。まずは GigaAppsSnapshotTests モジュールに以下のようにテストの実体を書きます。

// GigaAppsSnapshotTests

open class SeriesDetailOverviewAreaSnapshotTests: GigaAppsSnapshotTests<SeriesDetailOverviewArea_Previews> {
    @MainActor
    func testSeriesDetailOverviewArea() {
        testPreviewSnapshots()
    }
}

GigaAppsSnapshotTests の型パラメータに作品詳細の Xcode Preview を指定し、testPreviewSnapshots を呼んでいるだけです。Preview からパターンを取得してきて参照画像との比較をする処理は GigaAppsSnapshotTeststestPreviewSnapshots がやってくれるため、これだけで SeriesDetailOverviewAreaSnapshotTests の基本的な実装は完了です。画面によっては、たとえば画像キャッシュの準備など、Preview を正しく表示するための前処理や後処理を setUptearDown メソッドに書くこともあります。

SeriesDetailOverviewAreaSnapshotTests は直接テストとして実行されるのではなく、メディアごとの文言やアセット、設定値を反映させた各アプリケーションの VRT ターゲットにより継承され、実行されます。少年ジャンプ+とコミックガルド+それぞれに対応するテストクラスを以下のように書きます。

// JumpPlusSnapshotTests

final class JumpPlusSeriesDetailOverviewAreaSnapshotTests: SeriesDetailOverviewAreaSnapshotTests {
    override var mediaName: String? { "JumpPlus" }
}
// ComicGardoSnapshotTests

final class ComicGardoSeriesDetailOverviewAreaSnapshotTests: SeriesDetailOverviewAreaSnapshotTests {
    override var mediaName: String? { "ComicGardo" }
}

ここでは継承元の GigaAppsSnapshotTests にて利用される mediaName プロパティを指定しているだけです。VRT の処理の実体を親クラスにまとめることにより、簡単にメディアごとの VRT ができるようになっています。

参照画像の作成・更新

前項ではすでに参照画像がある前提で説明しましたが、新しい画面に VRT を実装するときや、既存の VRT に新しいテストケースを追加するときは、対応する参照画像を作成しなければなりません。また、既存の VRT の対象となっている画面に UI の変更が入った場合は参照画像を更新する必要があります。

swift-snapshot-testing の assertSnapshot には record というパラメータを渡すことができ、これを true にすることで参照画像を生成してくれます。ただし、エンジニアが手元のコードで record の値を書き換えて参照画像を生成、コミットして push するのは面倒ですし、スナップショット画像は生成される環境によって微妙に異なることがあるため、参照画像生成は安定した CI 環境で行うことが望ましいでしょう。

そのため、VRT の実行時に参照画像を生成するか否かを外部から環境変数として渡せるようにしています。先ほど示した GigaAppsSnapshotTests は簡略化したもので、実際には以下のように IS_RECORDING_SNAPSHOTS という環境変数を読み込む処理が入っています。

open class GigaAppsSnapshotTests<SnapshotPreviews: SnapshotPreviewProvider>: XCTestCase {
    // ...

+   private var record: Bool {
+       ProcessInfo.processInfo.environment["IS_RECORDING_SNAPSHOTS"] == "YES"
+   }

    @MainActor
    func testPreviewSnapshots(
        file filePath: StaticString = #filePath,
        testName: String = #function
    ) {
        guard let mediaName else { return }

+       if record { XCTExpectFailure("Recording snapshots", strict: false) }

        for pattern in SnapshotPreviews.previewPatterns {
            for device in snapshotDevices {
                assertSnapshot(
                    of: UIHostingController(rootView: SnapshotPreviews.makePreview(pattern: pattern)),
                    as: .image(on: device.config, perceptualPrecision: perceptualPrecision),
                    named: "\(mediaName)-\(pattern.displayName)-\(device.displayName)",
+                   record: record,
                    file: filePath,
                    testName: testName
                )
            }
        }
    }
}

PR ごとに走る通常の CI では IS_RECORDING_SNAPSHOTS は設定しませんが、参照画像生成のためのワークフローを別に作成し、そちらでは xcodebuild でのテスト実行時のコマンドライン引数に IS_RECORDING_SNAPSHOTS=YES とします。これにより、record パラメータが trueassertSnapshot が実行され、参照画像が生成されます。生成された画像を CI 環境から push することで、ワークフローを起動するだけで参照画像の生成できるようになっています。

参照画像の生成においては差分検証の必要がないため、XCTExpectFailure を使って常にテストを成功扱いにしています。

参照画像の置き場所

swift-snapshot-testing では、たとえば Strategy for storing snapshots · pointfreeco/swift-snapshot-testing · Discussion #504 での議論のように、参照画像はレポジトリに直接コミットするフローが想定されているようです。小さなプロジェクトではこれで問題ない場合もありますが、GigaApps では1つの VRT のテストケースに対してメディアの数だけ参照画像が必要になるため、画像の容量が増えやすいです。GigaApps は iOS / Android のコードを同一レポジトリで扱っているため利用する人数が多く、レポジトリが重くなる影響が大きいですし、重くなるのを避けるために Git LFS を使う場合には課金額に影響が出てしまう懸念がありました。

そこで、参照画像は専用の別レポジトリにまとめる運用にしました。前の項で参照画像の生成のワークフローについて説明しましたが、そのワークフローで生成された参照画像を GigaApps ではなく giga-apps-reference-snapshots-ios という別レポジトリに反映するようにしています。GigaApps で VRT を実行する際には、CI で giga-apps-reference-snapshots-ios をクローンすることで参照画像を取得します。

もちろんこの運用では giga-apps-reference-snapshots-ios の容量が増えてしまうのですが、GigaApps の本体レポジトリと違ってエンジニアがローカルにクローンする必要がないことと、CI でも最新の参照画像のみ取得すれば十分なのでシャロークローンをすればよくクローン時間が伸びづらいと予想されることから、参照画像の容量増大の影響を抑えられています。

VRT の運用フロー

直近の項で説明してきた参照画像周りの扱いも含めて、全体的な VRT のフローについて紹介します。

VRT は通常のユニットテストと同様、GigaApps に PR が出されるたびに実行しています。VRT 対象の画面の UI が変化すると、参照画像と差分が出るので VRT は失敗します。この際は、参照画像生成のワークフローを実行し、giga-apps-reference-snapshots-ios に新しいスナップショット画像を反映する PR を作ります。この PR 上では、以下のように変更前後の画像の差分を見ることができます。

もしこの差分が意図したものでなければ、ここで UI のリグレッションに気付けます。意図した通りの差分であれば、giga-apps-reference-snapshots-ios の PR をマージすることで参照画像が更新されます。その後、元の GigaApps PR の CI を実行し直すと、参照画像は最新のものとなっているため VRT が通ります。

ユニットテスト

ここまで GigaApps の VRT について述べてきましたが、もちろん CI では一般的なユニットテストも実行しています。標準的な内容ですので、簡単にではありますが、ユニットテストの取り組みについても紹介します。

どの部分にユニットテストを書いているか

ダウンロード機能や閲覧履歴機能など、裏側の処理を受け持つモデル層には、基本的にロジックを検証するユニットテストを書いています。たとえば端末上でマンガを閲覧した履歴を管理するクラスのユニットテストでは、閲覧履歴の挿入や削除などをした後に履歴を取得すると想定通りの履歴が正しい順で返ってくるかなどを検証しています。

UI 層について、参照記事の「画面構築と遷移」セクションでまとめている通り、GigaApps の画面は Builder / Router / Screen / View / ViewModel から構成されます。画面のほとんどのロジックは ViewModel が持っているため、画面のテストの多くは ViewModel に対して書かれたものです。

ViewModel のテストでは、ユーザーの操作に対して画面に表示されるデータが正しく変化するかを ViewModel の状態を見て検証したり、画面遷移が想定通り行われるかを、モックした Router のメソッドが想定通りの引数を渡されつつ呼ばれるかを確認することで検証しています。また、アナリティクスイベントの送信も ViewModel で行っているため、操作に対して想定通りのイベントが送られているかもチェックしています。

多くはありませんが、nalexn/ViewInspector を使って View のテストをしている箇所もあります。ViewInspector は SwiftUI の View をたどり、表示されている要素を検証できます。View がある状態のときに、表示されているべき要素が想定通りの内容で表示されていることを確認したいときに役立ちます。VRT と異なり、メディア特有の要素の検証や細かい表示崩れの検知はできませんが、特に状態のバリエーションが多い View に対しては有効です。

モノレポでの CI 実行

GigaApps は iOS / Android アプリを1つのレポジトリにまとめて開発しています。このようなモノレポ構成では、たとえば iOS アプリを修正して PR を出したときには、両 OS ではなく iOS の CI のみを実行する必要があります。そのためには、CI にてどのファイルが修正されたのかを見て実行するワークフローを変えるしくみが必要です。

GigaApps の iOS アプリでは CI サービスとして CircleCI を利用しており、CircleCI の path-filtering という orb を使ってこのしくみを実現しています。より詳しくは、同僚の id:tokizuoh の「モノレポにおけるpath-filtering利用時でもGitHub ステータスのRequiredを機能させたい!」を参照してください。

終わりに

この記事では GigaViewer for Apps の iOS アプリのテストの取り組みについて紹介しました。GigaApps は共通コンポーネントを複数のメディアから利用しているためメディア間で意図しない影響が及んでしまう可能性があることと、それを UI の観点で防ぐために導入した VRT の実装方法について詳しく説明しました。また、ユニットテストについても、テストしている観点やモノレポでの CI の工夫について簡単に触れました。

連載企画『Inside GigaViewer for Apps』では、今後も多様なテーマで過去取り組んできた技術的チャレンジを紹介していきます。次回は GigaViewer for Apps の Android アプリのテストについて紹介します。