MoTLab -GO Inc. Engineering Blog-MoTLab -GO Inc. Engineering Blog-

iOS アプリに Snapshot Test を導入検討してみた話

ChallengeWeekiOSテスト
June 16, 2022

タクシーアプリ「GO」の iOS アプリを開発している久利です。

今回は Snapshot Test の導入検討した話をご紹介します。


これは MoT Engineer Challenge Week 2022 Spring の記事です。

はじめに

2021年度下期より、エンジニアのスキルアップを促進するための取り組みとして Engineer Challenge Week 施策が導入されました。詳しくは、「取締役エンジニアとして考える新しい個人と組織の成長の形 - Engineer Challenge Week」に記載されてるので是非読んでみてください。

iOS チームでは、チャレンジしたいことを挙げていき、大きく3つのカテゴリーに分けました。

An image from Notion

今回は全員が同じ日程でスタートする訳ではなく、2つの日程に分かれて実施するため、個人で完結出来そうな項目を選びチャレンジすることにしました。

私は、以前から気になっていた Snapshot Test を導入することにチャレンジしてみました。

Snapshot Test とは

Snapshot Test とは、変更前の出力結果と変更後の出力結果を見比べて、差分がないこと(もしくはあること)を確認するテストです。

今回は比較対象をレイアウトとして、画面のスクリーンショットを前後比較したいと考えていました。

なぜ Snapshot Test

GO では Unit Test を書く文化があり、Unit Test のひな形を自動生成したりと、書きやすい環境を整えロジックの品質を担保する取り組みを日頃から行っています。

レイアウトの確認に関しては、実装時にパターンごとのスクリーンショットを Pull Request に載せ、レビュワーに確認してもらっていました。

An image from Notion

Pull Request

しかしながら、人が目で確認をする以上は抜け漏れが発生してしまったり、意図しない変更を検知できず QA で発見されたり、最悪はそのまま市場に出てしまう可能性もあります。

また、パターンごとに動作を確認してスクリーンショットを撮るのも時間がかかる作業でした。レイアウトをテストする方法として UI Test もありますが、メンテナンスのコストが高いイメージがあります。

スクリーンショットを比較するだけれあれば、メンテナンスのコストを抑えつつ、レイアウトのリグレッションテストとして活用できそうだなと考えました。

Snapshot Test の導入

導入に関しては、Snapshot Test で利用するライブラリの選定とプロジェクトへの導入を行いました。

ライブラリの選定

iOS で Snapshot Test を実現するには、iOSSnapshotTestCaseswift-snapshot-testing を使うのが一般的かと思います。

GO では一部の機能を SwiftUI で実装していることもあり、SwiftUI に対応している swift-snapshot-testing を選択しました。

導入

swift-snapshot-testing を CocoaPods で導入し、Snapshot Test を実行する Test Bundle の追加を XcodeGen で行いました。

targets:
	AppSnapshotTests:
	  platform: iOS
	  sources:
	    - path: AppSnapshotTests
	      excludes:
	        - Tests/__Snapshots__ # スクリーンショットが格納されるディレクトリをプロジェクトから除外
	  type: bundle.unit-test
	  settings:
	    PRODUCT_BUNDLE_IDENTIFIER: jp.app.SnapshotTests
	    BUNDLE_LOADER: $(TEST_HOST)
	  dependencies:
	    - target: App
	
schemes:
  Debug:
    build:
      targets:
        App: [run, archive]
        SnapshotTests: [test]
    run:
      config: Debug
    test:
      testPlans:
        - path: SnapshotTests/SnapshotTests.xctestplan
          defaultPlan: true

Snapshot Test の実装

View は状態によって様々なレイアウトに更新されると思います。今回リグレッションテストとしてSnapshot Test を活用したいと考えていたため、レイアウトのパターンを網羅する必要がありました。

GO では 一部のView で Xcode Previews を使い、パターン別にプレビューから確認出来るようにしています。Xcode Previews の活用については、Xcode Previews をより使いやすくするための3つの工夫でも紹介しているので、よかったら読んでみてください。

有効活用出来ないかと考えていたところ、メルペイさんのXcode PreviewsからSnapshotテストを自動生成するを拝見し、Xcode Previews から Snapshot Test のコードを生成出来ることを知りました。

  • PreviewProviderプロトコルに準拠した Preview の _allPreviews プロパティを参照し、各Previewを表す_Preview型のオブジェクトを取得
  • _Preview型の content を Snapshot Test のアサーションメソッドに渡す

※記事にもある通り、_が付いているものは非公開APIになっているので、今後利用できなくなる可能性があります。

Xcode Previews を元に、以下のような実装を行いました。

// PreFixedFareDispatchRequestWarningViewController_Preview.swift

import SwiftUI

struct PreFixedFareDispatchRequestWarningViewController_Preview: PreviewProvider {
    static var previews: some SwiftUI.View {
        Group {
            Wrapper(inputs: [.add(warnings: [.selectedOtherPayment(.normal)])])
                .previewDisplayName("警告が1つの場合")

            Wrapper(inputs: [.add(warnings: [.selectedOtherPayment(.normal),
                                             .unavailablePreFixedFareCompany(.normal)])])
                .previewDisplayName("警告が2つの場合")
        }
    }
    static var platform: PreviewPlatform? = .iOS
}
//  PreFixedFareDispatchRequestWarningViewControllerTest.swift

import SnapshotTesting
import XCTest
@testable import MOV

final class PreFixedFareDispatchRequestWarningViewControllerTest: XCTestCase {
    func testPreFixedFareDispatchRequestWarningViewController() {
        for preview in PreFixedFareDispatchRequestWarningViewController_Preview._allPreviews {
            SnapshotConfig.DeviceName.allCases.forEach { deviceName in
                assertSnapshot(matching: preview, as: deviceName, file: #file, testName: #function, line: #line)
            }
        }
    }
}

Snapshot Test を動かす

Snapshot Test のコードができたので動かします。

新規に追加した Snapshot Test に関しては、比較画像がないため failed - No reference was found on disk. Automatically recorded snapshot: … と表示されます。

An image from Notion

この状態で再度実行するとテストは成功し、取得したスクリーンショットが比較用のディレクトリに格納されます。

An image from NotionAn image from Notion

失敗した場合

Snapshot Test が失敗すると failed - Snapshot does not match reference. と表示され、テストが失敗します。

スクリーンショットが格納されるディレクトリに Failure のディレクトリが作成され、失敗したスクリーンショットが格納されます。メッセージに現在の画像と、失敗した画像のパスが表示されるので、こちらを使って差分比較することができます。

今回は reg-cli を使用してレポートを作成し確認してますが、もう少しスマートな方法で失敗した際の確認をできないか現在調査中です。

An image from NotionAn image from NotionAn image from Notion

reg-cli

An image from Notion

差分を確認し意図した変更であった場合は、func setUp() にある isRecording を true に変更し、再度実行することでスクリーンショットを更新できます。

工夫した点

Test Plan の作成

近い将来の多言語化対応を見据えて、言語ごとに Snapshot Test を実行したいなと考えています。

対応言語が増えた際に、Test Plan の Config を増やしてあげれば、1回の実行で複数言語の スクリーンショットを撮ることが可能になります。

An image from NotionAn image from Notion

ファイル名の命名規約

アサーションメソッド のラッパーを作成し、同じ命名規約でスクリーンショットが作成されるようにしています。今回はメソッド名の後に プレビューの displayName、端末名、言語設定がサフィックスとして付くようにしています。

import SwiftUI
import SnapshotTesting

func assertSnapshot(matching preview: _Preview, as device: SnapshotConfig.DeviceName? = nil, record recording: Bool = false, timeout: TimeInterval = 5, file: StaticString, testName: String, line: UInt) {
    let language = Locale.preferredLanguages.first ?? ""
    let fileName: String = {
        if let previewName = preview.displayName {
            return "(\(previewName)-\(device?.rawValue ?? "")-\(language)"
        } else {
            return "\(device?.rawValue ?? "")-\(language)"
        }
    }()

    switch preview.layout {
    case .device:
        if let device = device {
            assertSnapshot(matching: preview.content.edgesIgnoringSafeArea(.all), as: .image(layout: .device(config: device.viewImageConfig)), named: fileName, file: file, testName: testName, line: line)
        } else {
            assertSnapshot(matching: preview.content.edgesIgnoringSafeArea(.all), as: .image(layout: .device(config: SnapshotConfig.DeviceName.iPhone8.viewImageConfig)), named: fileName, file: file, testName: testName, line: line)
        }
    case .fixed(let width, let height):
        assertSnapshot(matching: preview.content.edgesIgnoringSafeArea(.all), as: .image(layout: .fixed(width: width, height: height)), named: fileName, file: file, testName: testName, line: line)
    case .sizeThatFits:
        assertSnapshot(matching: preview.content.edgesIgnoringSafeArea(.all), as: .image, named: fileName, file: file, testName: testName, line: line)
    @unknown default:
        assertSnapshot(matching: preview.content.edgesIgnoringSafeArea(.all), as: .image, named: fileName, file: file, testName: testName, line: line)
    }
}
An image from Notion

さいごに

今回 Engineer Challenge Week を使って、以前から気になっていた Snapshot Test を触ることができました。Xcode Previews を使って Snapshot Test を作成するあたりは、テストとして必要な実装がほぼ無く、メンテナンス性が高くて良いなと感じています。

Snapshot Test を動かすことはできましたが、いくつかの課題がありまだ運用には乗せられていません。

  • 実行するタイミングや運用方法の精査
  • 実行するシミュレーターが違うと差分が発生するので、固定化する仕組み or CI で同じ環境で実行する等の考慮が必要
  • テストが失敗した際に差分チェック方法

引き続き調査していき、次は「iOS アプリで Snapshot Test を運用してみた」というタイトルで記事を書ければなと思っています。


We're Hiring!

📢
Mobility Technologies ではともに働くエンジニアを募集しています。

興味のある方は 採用ページ も見ていただけると嬉しいです。

Twitter @mot_techtalk のフォローもよろしくお願いします!