タクシーアプリ「GO」の iOS アプリを開発している久利です。
今回は Snapshot Test の導入検討した話をご紹介します。
これは MoT Engineer Challenge Week 2022 Spring の記事です。
2021年度下期より、エンジニアのスキルアップを促進するための取り組みとして Engineer Challenge Week 施策が導入されました。詳しくは、「取締役エンジニアとして考える新しい個人と組織の成長の形 - Engineer Challenge Week」に記載されてるので是非読んでみてください。
iOS チームでは、チャレンジしたいことを挙げていき、大きく3つのカテゴリーに分けました。
今回は全員が同じ日程でスタートする訳ではなく、2つの日程に分かれて実施するため、個人で完結出来そうな項目を選びチャレンジすることにしました。
私は、以前から気になっていた Snapshot Test を導入することにチャレンジしてみました。
Snapshot Test とは、変更前の出力結果と変更後の出力結果を見比べて、差分がないこと(もしくはあること)を確認するテストです。
今回は比較対象をレイアウトとして、画面のスクリーンショットを前後比較したいと考えていました。
GO では Unit Test を書く文化があり、Unit Test のひな形を自動生成したりと、書きやすい環境を整えロジックの品質を担保する取り組みを日頃から行っています。
レイアウトの確認に関しては、実装時にパターンごとのスクリーンショットを Pull Request に載せ、レビュワーに確認してもらっていました。
Pull Request に載せてるスクリーンショットの例
しかしながら、人が目で確認をする以上は抜け漏れが発生してしまったり、意図しない変更を検知できず QA で発見されたり、最悪はそのまま市場に出てしまう可能性もあります。
また、パターンごとに動作を確認してスクリーンショットを撮るのも時間がかかる作業でした。レイアウトをテストする方法として UI Test もありますが、メンテナンスのコストが高いイメージがあります。
スクリーンショットを比較するだけれあれば、メンテナンスのコストを抑えつつ、レイアウトのリグレッションテストとして活用できそうだなと考えました。
導入に関しては、Snapshot Test で利用するライブラリの選定とプロジェクトへの導入を行いました。
iOS で Snapshot Test を実現するには、iOSSnapshotTestCase か swift-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
View は状態によって様々なレイアウトに更新されると思います。今回リグレッションテストとしてSnapshot Test を活用したいと考えていたため、レイアウトのパターンを網羅する必要がありました。
GO では 一部のView で Xcode Previews を使い、パターン別にプレビューから確認出来るようにしています。Xcode Previews の活用については、Xcode Previews をより使いやすくするための3つの工夫でも紹介しているので、よかったら読んでみてください。
有効活用出来ないかと考えていたところ、メルペイさんのXcode PreviewsからSnapshotテストを自動生成するを拝見し、Xcode Previews から 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 に関しては、比較画像がないため failed - No reference was found on disk. Automatically recorded snapshot: … と表示されます。
この状態で再度実行するとテストは成功し、取得したスクリーンショットが比較用のディレクトリに格納されます。
取得したスクリーンショット
Snapshot Test が失敗すると failed - Snapshot does not match reference. と表示され、テストが失敗します。
スクリーンショットが格納されるディレクトリに Failure のディレクトリが作成され、失敗したスクリーンショットが格納されます。メッセージに現在の画像と、失敗した画像のパスが表示されるので、こちらを使って差分比較することができます。
今回は reg-cli を使用してレポートを作成し確認してますが、もう少しスマートな方法で失敗した際の確認をできないか現在調査中です。
reg-cli で出力したレポート
レポートの詳細
差分を確認し意図した変更であった場合は、func setUp() にある isRecording を true に変更し、再度実行することでスクリーンショットを更新できます。
近い将来の多言語化対応を見据えて、言語ごとに Snapshot Test を実行したいなと考えています。
対応言語が増えた際に、Test Plan の Config を増やしてあげれば、1回の実行で複数言語の スクリーンショットを撮ることが可能になります。
アサーションメソッド のラッパーを作成し、同じ命名規約でスクリーンショットが作成されるようにしています。今回はメソッド名の後に プレビューの 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)
}
}
今回 Engineer Challenge Week を使って、以前から気になっていた Snapshot Test を触ることができました。Xcode Previews を使って Snapshot Test を作成するあたりは、テストとして必要な実装がほぼ無く、メンテナンス性が高くて良いなと感じています。
Snapshot Test を動かすことはできましたが、いくつかの課題がありまだ運用には乗せられていません。
引き続き調査していき、次は「iOS アプリで Snapshot Test を運用してみた」というタイトルで記事を書ければなと思っています。
興味のある方は 採用ページ も見ていただけると嬉しいです。
Twitter @mot_techtalk のフォローもよろしくお願いします!