メルペイiOSチームのスナップショットテストを効率化した話

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

こんにちは。メルペイiOSチームのtakeshiです。 2019年12月に「メルペイ iOS にスナップショットテストを導入した話 | メルカリエンジニアリング」でご紹介したとおり、メルペイiOSチームではスナップショットテストを導入し画面のレイアウト確認を行っていました。 その後の運用でいくつかの課題も見つかりました。

この記事ではメルペイiOSチームがスナップショットテストを運用していく中で見つけた課題とそれをどう解決したのか、また効率的にスナップショットテストを開発フローへ導入する方法についてお話します。

スナップショットテストとは

まずはじめにスナップショットテストという用語について整理します。 スナップショットテストを一言で表せば「画面レイアウトのテスト」です。

iOSに限らず、クライアントアプリを開発する上で重要になるものが、画面のレイアウトが正しく表示されているかどうかです。 このレイアウトの確認はUnit Testでは確認することが難しいものです。 レイアウトを確認するためには、アプリを操作し、画面をキャプチャする必要がありました。

画面をそのまま画像として出力し、それを確認・比較できれば、アプリを操作する手間を省き自動化することができます。

この画面の見た目に焦点を当てたテストをスナップショットテストといいます。

Snapshotという用語には「ある時点でのデータを抜き出す」という意味があるので、それをクライアントアプリの画面レイアウトに対して用いている格好になります。

出力するデータは画像だけでなく、View階層を表すファイルであることもあります。

スナップショットテストとその他のテストの比較

ここでスナップショットテストの位置づけをUnitテスト、UIテスト、マニュアルテストと比較してみましょう。

種類 目的 実行方法 扱うデータ レイアウト確認ができるか否か
Unitテスト ロジックを担保する テストコードを実装 モックデータ
スナップショットテスト Viewのレイアウトを担保する テストコードを実装 モックデータ
UIテスト アプリを操作し動作を確認 テストコードを実装 実際のユーザーとほぼ同じ環境でデータを用意
マニュアルテスト アプリを操作し動作を確認 人の手でアプリを操作 実際のユーザーとほぼ同じ環境でデータを用意

Unitテストはロジックを担保するために作成するもので、レイアウト確認には適していません。 UIテストやマニュアルテストでは、アプリを操作し画面を確認します。 ただし、アプリを操作するためのデータは実際のユーザーとほぼ同じ環境で作成しなければいけません。

メルペイでは@vvakameさんがMerpay Advent Calendar 2020 4日目に「テスト用お客さまデータ作りツール user-tkool の近況」で公開したように、テストに必要なお客さまデータを簡単に作成できる仕組みが整っています。

またテストの環境がクライアントに閉じず、通信状態やサーバーの状態に依存するといった不確実性を伴います。

スナップショットテストはコードからViewをレンダリングして画像に出力することで、アプリを起動しなくても画面の確認・比較をするテストです。 そのため、モックデータを差し込めるので、安定した同じデータを使ってViewをテストできます。

現状のスナップショットテストの方法

現状のメルペイiOSチームでは「メルペイ iOS にスナップショットテストを導入した話 | メルカリエンジニアリング」で解説したとおり、iOSSnapshotTestCaseというOSSを利用していました。 Uberが管理するOSSで、使い方の詳細は上記Tech Blogを参照ください。

swift-snapshot-testing

iOSSnapshotTestCaseと同様にスナップショットテストのライブラリーとしてもう一つ有名なのが、swift-snapshot-testingです。

このライブラリーは関数型言語とSwiftがテーマのWebマガジンPoint-Freeが運営しているOSSです。

基本的な使い方はiOSSnapshotTestCaseと同じです。 一度撮影のためのテストを実行すると画面を画像に出力します。 その画像を比較用の画像としてコミットしておきます。 2回目以降テスト時に、その比較用の画像とテスト時に作成された画像を比較し、差異がなければテスト成功、あればテスト失敗となる仕組みです。

iOSSnapshotTestCaseとの違いは次のようなものがあります。

  • Swiftで実装
  • 比較用の画像がない場合、自動で作成する
  • 各端末の画面サイズのプリセットが用意されている
    • iPhoneSE, 8, 8 Plus, X, XsMax, Xr, iPadMini, iPad Pro など
    • iPadは縦向きや横向きの指定も可能
  • 画像だけでなく、View階層をTextファイルに出力も可能
  • Dynamic Type対応
  • SwiftUI対応

見えてきた課題点

2019年12月ごろに導入して半年ほど経った頃、スナップショットテストの運用でさまざまな課題が見えてきました。 大まかに2つあります。

  • 実行時間の増加
  • 出力画像に微妙に差がある問題

これらの問題がswift-snapshot-testingを組み合わせることで解決できそうでした。 問題の詳細とどのように解決したのかを解説します。

実行時間の増加

iOSSnapshotTestCaseはスナップショットを撮る画面のViewサイズは自分で用意しなければいけません。 複数端末のスクリーンショット画像が必要だったため、次のような操作で各画面の画像を撮影していました。

まずはじめにメルペイiOSチームではテストコードの親クラスにWindowを保持させ、そのサイズをUIScreen.main.boundsに設定します。

class SnapshotTestCase: FBSnapshotTestCase {
    var window: UIWindow!
    override func setUp() {
        super.setUp()
        ...
        window = UIWindow(frame: UIScreen.main.bounds)

このwindowrootViewControllerプロパティにスクリーンショットテストの対象画面のView Controllerを設定し、windowに対してスクリーンショットを撮っていました。

そしてCircleCIで必要なだけの端末種別それぞれに対してスナップショットテストを実行していました。 対象端末はiPhone SE, 6s, 7, 8, Xs, Xr, X, 11 Pro, iPadの10種類です。

当然、実行端末が増えるごとにテストの時間も伸びていきます。

こちらは2020年9月のUnitテストとスナップショットテストのCircleCI上の結果です。 Unitテストのテストケースの数は2013ケース、対してスナップショットテストケースの数は206ケースでした。

テストケース数は10分の1ですが、スナップショットテストのほうが時間がかかっていることがわかります。 テストの時間が長引くと待ち時間が発生し、開発効率が悪くなってしまいます。 できれば短くしたいと思っていたところ、swift-snapshot-testingでは各端末サイズのプリセットがあることが判明したので、それを利用しました。

swift-snapshot-testingで実行時間を短縮

swift-snapshot-testingには各端末サイズのプリセットが用意されています。 スナップショットテストのコードに端末の情報を渡せば、その端末のサイズごとに画面の画像を確認できるのです。 この機能を利用すれば、テストの実行は一度で済むので大幅な実行時間の短縮が望めます。

どのようにswift-snapshot-testingで各端末のスナップショットテストを実装できるか、具体的にコードをみてみましょう。

ViewImageConfigというView画面のサイズやsafe areaのインセット、UITraitCollectionを表す型が提供され、ツール側で各画面のプリセットがあらかじめ定義されています。 そこでDeviceNameという型を作って各端末サイズを取り出せるようにします。

public enum SnapshotConfig {
    public enum DeviceName: String, CaseIterable {
        case iPhoneSe = "iPhone-SE"
        case iPhone8 = "iPhone-8"
        case iPhone8Plus = "iPhone-8-Plus"
        case iPhoneX = "iPhone-X"
        case iPhoneXsMax = "iPhone-Xs-Max"
        case iPhoneXr = "iPhone-XR"
        case iPadPro11Portrait = "iPad-Pro-11-portrait"
        case iPadPro11Landscape = "iPad-Pro-11-landscape"
    }
extension SnapshotConfig.DeviceName {
    public var viewImageConfig: ViewImageConfig {
        switch self {
        case .iPhoneSe:
            return .iPhoneSe
        case .iPhone8:
            return .iPhone8
        case .iPhone8Plus:
            return .iPhone8Plus
        case .iPhoneX:
            return .iPhoneX
        case .iPhoneXsMax:
            return .iPhoneXsMax
        case .iPhoneXr:
            return .iPhoneXr
        case .iPadPro11Portrait:
            return .iPadPro11(.portrait)
        case .iPadPro11Landscape:
            return .iPadPro11(.landscape)
        }
    }
}

実際のテストコードは次のとおりです。

final class SettingMenuViewControllerSnapshotsTests: ViewSnapshotsTestCase {

    func testViewController() {
        let vc = SettingMenuViewController(argument: .init(), dependencyRegistry: MockMerpayDependencyRegistry())
        let nav = NavigationController(rootViewController: vc)
        SnapshotConfig.DeviceName.allCases.forEach { deviceName in
            assertSnapshot(matching: nav, as: .image(on: deviceName.viewImageConfig, precision: SnapshotConfig.precision), named: deviceName.rawValue)
        }
        assertSnapshot(matching: nav, as: .recursiveDescription(on: .iPhoneX), named: SnapshotConfig.recursiveDescription)
    }
}

SnapshotConfig.DeviceName.allCases.forEachで指定の端末をそれぞれ回し、assertSnapshot関数を呼んでスナップショットテストを実行します。 すると次のように、各端末サイズのスナップショット画像が出力されます。

これを利用して、一回のテスト実行で、複数端末のスナップショットが取得できます。

実行端末を削減して実行時間を短縮

swift-snapshot-testingを導入したことで、一回の実行で複数端末のスナップショット画像が撮れるようになりました。 そこで、2020年9月、現状10端末で実行していたテストのJobを1端末に削減しました。

すると、今まで17分ほどかかっていたテストが8分ほどと、約半分の時間で済むようになりました。

出力画像に微妙に差がある問題

テスト時に出力される画像に、一定の条件で微妙な差が生じてしまいテストが失敗してしまう問題がありました。

たとえば、次のような画面でテストが失敗しました。 違いがわかりますでしょうか?

銀行のアイコンの下がほんのりと赤く出力されていることがわかると思います。

このように、期待される画像と異なる出力が行われ、テストが失敗してしまうことがしばしば起こり、開発の効率が悪くなってしまいました。

この問題はiOSSnapshotTestCaseの公式でもIssueがあげられています。 Test results depends on Mac device #109

どうやらXcode 11よりシュミレーターのプロセスにGPUが使われるようになり、GPUベンダーによって処理が異なるので出力画像に差異が出てきてしまったようです。 透過部分をもつ画像でよくこの問題が起こっていました。 メルペイiOSチームではCIサービスにCircleCIを利用していますが、テスト実行時のmacOSが毎回変わってしまっているため、対処が難しくなってしまいました。

画像比較のしきい値を設定してエラーを回避する

iOSSnapshotTestCaseとswift-snapshot-testingの両方の画像出力実装を比較したところ、Objective-CとSwiftの違いはあれ、実装内容はほぼ同じでした。 つまり、スナップショットテストにswift-snapshot-testingを利用しても問題は解決しません。

また、問題が起こっていたのは透過部分をもつ画像を表示する画面が多かったため、Method Swizzlingを使ってUIImageに対して透過しないような処理を行いましたが、変わらず画像の差異は起こり続けました。

対応を検討した末、最終的には画像比較のしきい値を設けることにしました。

iOSSnapshotTestCaseとswift-snapshot-testingは名前は異なりますが、画像の差異を許容する引数をテスト関数に持っています。 これを使って、多少画像に差異があってもテストがパスするようにしました。

各ライブラリーのしきい値の表は次のとおりです。

ライブラリー名 引数名 意味
iOSSnapshotTestCase overallTolerance 画像全体のピクセル単位の失敗を許容するパーセンテージ。指定した数値以上に差異があればTestは失敗する
swift-snapshot-testing precision 画像全体のピクセル単位の一致していなければならないパーセンテージ。指定した数値以上にマッチしなければTestは失敗する

例えば、10%の差異を許容するコードを考えます。 iOSSnapshotTestCaseで次のようなコードを書きます。

FBSnapshotVerifyView(view, overallTolerance: 0.1)

この場合、比較用の画像とテスト時に作成された画像の差異が10%を超えた場合にテストが失敗します。

一方swift-snapshot-testingでのサンプルコードは次のとおりです。

assertSnapshot(matching: nav, as: .image(on: .iPhone8, precision: 0.9))

この場合、比較用の画像とテスト時に作成された画像が90%以上一致すればテストに合格します。 iOSSnapshotTestCaseと同じ言い方をすれば、10%未満の画像差異があっても許容します。

iOSSnapshotTestCaseとswift-snapshot-testingでしきい値を指定する引数の意味が正反対なことに注意する必要があります。

実際にメルペイでは10%までの差を許容することにしました。 今のところうまくいっています。

View階層をテキスト化

画像比較にしきい値を設けたことで、意図しない画面の更新をプログラム的に判断することが難しくなりました。 画像が変更されたことは目視で確認するしかないでしょうか? 幸いswift-snapshot-testingには.recursiveDescriptionというView階層をテキストファイルに出力する機能を持っています。 これを使えば、意図しないViewの変更があった場合にテストが失敗して検知できます。

コードはこのように書きます。

assertSnapshot(matching: view, as: .recursiveDescription)

これを実行すると次のような内容のテキストファイルが出力されます。

<MerpayMercariWalletKit.SalesZeroPopupView; frame = (0 0; 239 322); layer = <CALayer>>
   | <UIStackView; frame = (16 24; 207 282); layer = <CALayer>>
   |    | <DesignSystem.Label; baseClass = UILabel; frame = (0 0; 207 44); text = '売上金がありません
銀行チャージがおすすめです'; userInteractionEnabled = NO; layer = <_UILabelLayer>>
   |    | <UIImageView; frame = (0 76; 207 70); opaque = NO; userInteractionEnabled = NO; layer = <CALayer>>
   |    | <UIStackView; frame = (0 178; 207 104); layer = <CALayer>>
   |    |    | <DesignSystem.Button; baseClass = UIButton; frame = (0 0; 207 48); opaque = NO; layer = <_TtC12DesignSystemP33_3B99BE1263844EC4300A411B8A84C7E711ButtonLayer>>
   |    |    |    | <UIImageView; frame = (0 0; 0 0); clipsToBounds = YES; hidden = YES; opaque = NO; userInteractionEnabled = NO; tintColor = UIExtendedSRGBColorSpace 0.999996 1 1 1; layer = <CALayer>>
   |    |    |    | <UIButtonLabel; frame = (68 15; 71 18); text = '詳しく見る'; opaque = NO; userInteractionEnabled = NO; layer = <_UILabelLayer>>
   |    |    | <DesignSystem.Button; baseClass = UIButton; frame = (0 56; 207 48); opaque = NO; layer = <_TtC12DesignSystemP33_3B99BE1263844EC4300A411B8A84C7E711ButtonLayer>>
   |    |    |    | <UIImageView; frame = (0 0; 0 0); clipsToBounds = YES; hidden = YES; opaque = NO; userInteractionEnabled = NO; tintColor = UIExtendedSRGBColorSpace 0.890196 0.27451 0.239216 1; layer = <CALayer>>
   |    |    |    | <UIButtonLabel; frame = (82 15; 43 18); text = '閉じる'; opaque = NO; userInteractionEnabled = NO; layer = <_UILabelLayer>>

ただし、.recursiveDescriptionを使う場合の注意点があります。 View階層は同じ画面でも端末の種類やOSのバージョンによって、異なる結果が出力されます。 常に同じ結果を得るために、実行端末とOSバージョンを固定する必要があります。 メルペイではテストの実行時に次のようなassertを使って、期待する端末とOSでテストが実行されているかをチェックしています。

assert(UIDevice.current.name == "iPhone 11 Pro", "Please run the test by iPhone 11 Pro")
assert(UIDevice.current.systemVersion.hasPrefix("14.2"), "Please run the test by 14.2")

これで画像比較にしきい値を設けても、意図しないViewの変更を検知できるようになりました。

swift-snapshot-testingの苦手な分野

便利に見えるswift-snapshot-testingも撮影するのが苦手な画面があります。 それは画面全体のWindowをキャプチャすることです。

公式のIssueでもUnable to snapshot modal UIViewController #279として上がっています。 基本的に公式ではWindow全体をキャプチャするAPIは提供しておらず、自分で拡張する他なさそうです。

extension Snapshotting where Value: UIViewController, Format == UIImage {
   static var windowedImage: Snapshotting {
     return Snapshotting<UIImage, UIImage>.image.asyncPullback { vc in
       Async<UIImage> { callback in
         UIView.setAnimationsEnabled(false)
         let window = UIApplication.shared.windows[0]
         window.rootViewController = vc
         DispatchQueue.main.async {
           let image = UIGraphicsImageRenderer(bounds: window.bounds).image { ctx in
             window.drawHierarchy(in: window.bounds, afterScreenUpdates: true)
           }
           callback(image)
           UIView.setAnimationsEnabled(true)
         }
       }
     }
   }
 }

swift-snapshot-testingの導入方針

swift-snapshot-testingの特徴を調べたのでどのようにチームに導入するかを考えました。 複数端末を一度で取得できる一方で、Windowを全体を撮るのは苦手です。

メルペイではポップアップや、ハーフモーダルなどで表示するコンポーネントが多くあり、Window全体のスナップショットテストが必要でした。

そこでiOSSnapshotTestCaseとswift-snapshot-testingを併用して利用することにしました。次のように各ライブラリーによってスナップショットテストの目的を分けました。

  • iOSSnapshotTestCase: Window全体のスナップショットテスト
  • swift-snapshot-testing: View自体のスナップショットテスト

swift-snapshot-testing用のtargetを追加し、View自体のテストをそのtargetに追加していく運用にしました。 そしてすでにiOSSnapshotTestCaseで実装しているテストコードで移行できるものは、swift-snapshot-testingで書き直していくことにしました。

swift-snapshot-testingでDynamic Typeをテストする

swift-snapshot-testingを導入したことで、さまざまな恩恵がありました。 そのひとつは端末の設定でフォントサイズが変えられるDynamic Typeのスナップショットテストができるようになったことです。

以前はマニュアルテストでしかDynamic Typeができなかったのですが、swift-snapshot-testingのおかげてスナップショットテストを実行できるようになりました。

では具体的にどうやってテストを作るかをみてみましょう。

メルペイでは、UIContentSizeCategoryの各サイズを配列で保持し、スナップショットテストを作成しています。

public enum SnapshotConfig {
    ...
    public static let allContentSizes =
        [
            "extra-small": UIContentSizeCategory.extraSmall,
            "small": .small,
            "medium": .medium,
            "large": .large,
            "extra-large": .extraLarge,
            "extra-extra-large": .extraExtraLarge,
            "extra-extra-extra-large": .extraExtraExtraLarge,
            "accessibility-medium": .accessibilityMedium,
            "accessibility-large": .accessibilityLarge,
            "accessibility-extra-large": .accessibilityExtraLarge,
            "accessibility-extra-extra-large": .accessibilityExtraExtraLarge,
            "accessibility-extra-extra-extra-large": .accessibilityExtraExtraExtraLarge,
    ]
}

allContentSizesforEachで回して各コンテンツサイズのキャプチャを撮ります。

final class SalesZeroPopupViewSnapshotsTest: ViewSnapshotsTestCase {
    func testViewController() {
        let view = SalesZeroPopupView()
        view.apply(input: .update(argument: argument))
        let previewContainer = PreviewContainer(view: view, fillHorizontal: true, backgroundColor: UIColor.lightGray)
        previewContainer.frame = CGRect(x: 0, y: 0, width: 288, height: 400)
        SnapshotConfig.allContentSizes.forEach { name, contentSize in
            assertSnapshot(
              matching: previewContainer,
              as: .image(precision: SnapshotConfig.precision, traits: .init(preferredContentSizeCategory: contentSize)),
              named: "SalesZeroPopup-\(name)"
            )
        }
        assertSnapshot(matching: view, as: .recursiveDescription, named: "SalesZeroPopupView-recursiveDescription")
    }
}

assertSnapshot関数を呼び出す際にas引数に画面の情報を渡すのですが、その際にUIContentSizeCategoryを渡すことでDynamic Typeのキャプチャを撮影します。

撮影された結果はこのようになります。

このようにして、Dynamic Typeのスナップショットテストを行っています。

開発フロー導入の手助け

スナップショットテストもUnitテストと同様に開発フローに入れていかなければ、品質の良いコードを開発することはできません。 ただし、プロダクションコードを実装しつつ、テストコードも作るのは大変なものです。

そこで私はテンプレートを用意して、すぐにスナップショットテストコードを実装できるようにしました。

ここからファイルを作成して、対象画面のView Controllerを作成すればすぐにスナップショットテストが作れます。

final class SampleSnapshotTestViewControllerSnapshotsTest: ViewSnapshotsTestCase {
    func testViewController() {
        // create your View Controller
        //let vc = SampleSnapshotTestViewController(argument: .init(), dependencyRegistry: MockMerpayDependencyRegistry())
        let nav = NavigationController(rootViewController: vc)
        nav.launch()
        SnapshotConfig.DeviceName.allCases.forEach { deviceName in
            assertSnapshot(matching: nav, as: .image(on: deviceName.viewImageConfig, precision: SnapshotConfig.precision), named: deviceName.rawValue)
        }
        assertSnapshot(matching: nav, as: .recursiveDescription(on: .iPhoneX), named: SnapshotConfig.recursiveDescription)
    }
}

また、メルペイiOSチームではXcode 11から登場したPreviewsの機能を使って、ViewをUIKitからSwiftUIに変換しViewを確認する仕組みがある程度確立していました。 さらに、swift-snapshot-testingではSwiftUIで作成した画面に対してのスナップショットテストもできます。 そこでメルペイiOSチームのkenmazさんがXcode Previewsとswift-snapshot-testingを組み合わせてプレビューから直接スナップショットテストを作成する方法を導入しました。 詳しくはMerpay Advent Calendar 2020 6日目のXcode PreviewsからSnapshotテストを自動生成する をご覧ください。

テストコードを書きやすい環境にして、開発フローに入れる仕組みを整えることが重要です。

まとめ

この記事では去年導入したスナップショットテストを今年はどのように効率化したのかをお伝えしました。 swift-snapshot-testingを導入したことで、一度の実行で複数端末サイズのスナップショットを取得できるようになりました。 出力画像に微妙に差がある問題は、しきい値を下げつつ、swift-snapshot-testingの.recursiveDescriptionを利用することで意図しない変更を検知することで解決しました。 テンプレートの作成やPreviewsからスナップショットテストを生成することでテストコードを書きやすい環境も整えました。

引き続きスナップショットテストの運用を続けていき、品質の高いアプリ開発を目指します。

明日のMerpay Advent Calendar 2020 執筆担当は、Bankチームの Karuna さんです。引き続きお楽しみください。