iOSアプリのプレビュー、スナップショットテストを使いこなして開発効率を上げる
この記事は、LUUPのTVCM放映に合わせた一足早い「Luup Developers Advent Calendar 2024」の8日目の記事です。
はじめに
こんにちは、iOSアプリエンジニアの茂呂(@slightair)です。
この記事ではLUUPのiOSアプリ開発で利用しているプレビューやスナップショットテストを実現する上でのテクニックについて紹介します。
LUUPアプリの設計
まず、LUUPアプリの設計について簡単に説明します。
LUUPアプリではシンプルなMVPアーキテクチャを採用しています。
過去の記事でもアーキテクチャやモジュール構成について触れているので[1]、気になる方はあわせてお読みください。
各種ビジネスロジックを実装するServiceクラスが複数あり、例えばポートに関すること、車両に関することなどの粒度で責務を分けています。
ServiceクラスにはFirestoreからのドキュメント取得、Cloud Functionsの関数呼び出しなどを実装することが多いです。
実装の抽象化と依存性注入
LUUPアプリでは、ネットワーク経由のリソース取得や各種設定値の読み書き、ビジネスロジックの呼び出しなどはベタに実装するのではなく、Protocolを使って抽象化し、利用する側はインターフェイスを通して実装を触るようにしています。
これはアプリ本体の実行だけでなく、プレビューやテスト実行などの状況にあわせて、振る舞いを差し替えてコードを実行できるようにするためです。
アプリの実行時はともかく、プレビューやテスト実行時は実際のリソースにアクセスする必要がないばかりか、アクセスしてしまうと不都合となることがほとんどです。
そのため、呼び出し側が依存する実装を差し替えるだけで動作を変えられるようにしておくと様々な状況に対応できます。
これは依存性注入(Dependency Injection, DI)と呼ばれる手法です。
LUUPのプロジェクトでは各Serviceを取得するためのServiceProviderというクラスを用意しています。
場面にあわせたServiceという依存を注入しやすくする仕組みです。
LUUPではシンプルな実装を用いていますが、プロジェクトによってはDIライブラリーを採用して、付属するDIコンテナをこのような形で利用していると思います。
// PortService
public protocol PortService {
func getPort(documentId: String) async throws -> Entities.Port
...
}
final class PortServiceImpl: PortService {
func getPort(documentId: String) async throws -> Entities.Port {
// ポート情報を取得するコード
}
}
// 利用
let portService: PortService = serviceProvider.portService
let startPort = try await portService.getPort(documentId: startPortDocumentId)
Viewを実装しているとView内のボタンや要素をタップした際の動作を指定することになりますが、これも直接動作を記述するのではなくCallbackオブジェクトのような振る舞いをまとめるものを定義し、状況にあわせて差し替えられるようにしておくのも便利です。
例えば、画面のレイアウトを確認するプレビューを実行する際はボタンが押されたときに動かなくもよいので、「何もしない」振る舞いオブジェクトをViewに渡すということができます。
struct MenuRootView: View {
typealias ViewState = LoadingViewState<MenuViewContent>
struct Callback {
let onTapMenuItem: (MenuViewData.Item) -> Void
let onTapLogout: () -> Void
static let none = Callback(
onTapMenuItem: { _ in },
onTapLogout: {}
)
}
@ObservedObject var state: ViewState
let callback: Callback
var body: some View {
...
}
...
}
// 利用
let rootView = MenuRootView(
state: viewState,
callback: .init(
onTapMenuItem: { [weak self] item in
self?.didTapMenu(item: item)
},
onTapLogout: { [weak self] in
self?.presenter.logout()
}
)
)
#Preview {
MenuRootView(
state: .init(loadingState: .loading),
callback: .none
)
}
実行モードにあわせた使い分け
実装の抽象化と依存性注入の方法を見たところで、LUUPのアプリ開発中にそれぞれの実行モードでどのように使い分けているかを紹介します。
プレビュー
iOSアプリの場合、画面のレイアウトや振る舞いに時間をかけることになると思います。
実装中の画面を確認する際、アプリを実際に動かすよりもプレビューが使えると素早く修正を回せます。
Xcode Previewsが使えるようになってからは、iOS開発でもアプリを動かすことなく画面単位で確認できるようになりました。
Previewマクロが使えるようになってからさらに便利になり、UIViewやUIViewControllerであってもPreviewProviderを実装せずに利用できます。
プレビューを利用した開発をよりよく回すために、画面実装時に都合の良いデータ、Entityなどを定義しておくとよいです。
例えばポート情報を表示する画面であれば、ネットワーク経由で取得できるポート情報のプレビュー用サンプルを定義しておくということです。
情報により画面内容が大きく変わるのであれば、ケースにあわせたサンプルを複数種類作っておくのもよいでしょう。
プレビューの例
ユニットテスト
ユニットテスト時には基本的にネットワークリクエストなどの副作用が起きないようにします。
ユニットテストを走らせることで外部に影響を与えたり、反対に外部の状況によってテスト結果が変わるのは望ましくないでしょう。
プレビューの時と同じようにテストで確認したい状況にあわせたモックを依存性注入するとよいでしょう。
テストに期待するデータが取得できたり、書き込みできたかのように振る舞うモックに差し替えます。
前述したServiceクラスなどのモック生成にLUUPのプロジェクトではMockoloというツールを利用しています。
抽象化しているProtocolにコメントで@mockable
というマークをつけておくことで、モックに必要な機能を備えたMockクラスの実装を生成してくれます。
モック実装の手間を省くツールにはいろいろあると思うのでプロジェクトにあったものを採用すればよいと思います。
スナップショットテストとVRT
LUUPプロジェクトではswift-snapshot-testingを使って画面ごとのスナップショット画像を作成しています。
リリースなどのタイミングで各画面のスナップショット画像をまとめたレポートを作成し、前リリース時のレポートと比較して意図しない画面差分がないか確認する VisualRegressionTest(VRT) を実行しています。
画像生成までをswift-snapshot-testingで行い、出力された画像をreg-suitに渡してAWS S3にレポートを保存、閲覧できるようにしています。
スナップショットテストにはプレビューで利用しているサンプルをそのまま利用できることが多いです。
ViewController単位でスナップショットを作成しているので、ViewControllerの実装によって都合の良いServiceのモックを指定することもあります。
スナップショットテストでは日本語と英語、ライトとダークモードでの描画結果を出力しています。
言語の違いやライト・ダークモードでの表示崩れ、意図しない描画結果がないか確認しています。
class MenuViewControllerSnapshotTests: XCTestCase {
func testMenuViewControllerSignIn() async {
let vc = MenuViewController(
signOut: nil,
serviceProvider: ServiceProvider.mock(
authService: AuthServicePreview(
currentUserUid: "<currentUserUid>"
),
menuService: MenuServicePreview(),
userService: UserServicePreview(
currentUser: .preview
)
)
)
await vc.loadContents()
ciAssertSnapshot(of: vc, size: .largeHeight)
}
}
スナップショットテストの出力例
アプリ
アプリ向けの実装が一番単純で、ネットワークリクエストをしたり必要なストレージに読み書きしたり、普通の呼び出しを実装します。
ネットワークリクエストの部分を抽象化しておけば、「サーバーのAPI実装も並行して進めているからAPIが叩けるようになるまでアプリの実装ができないよ」ということも回避できます。
サーバーとのやりとりの仕様さえ事前に決めておけば期待した振る舞いをするモックを用意できるので、実際のAPIが利用できるまではモックを利用してアプリの開発を進めることができます。
依存の差し替えではどうしても対応できない場合
基本的にはどの実行モードであっても同じコードを使って依存注入により動作を変えるのが望ましいですが、どうしても難しい場面があります。
そのような場合に利用できるテクニックがいくつかあるので紹介します。
Info.plistに書かれた情報を使う
ひとつめはInfo.plistに書かれた情報を使うことです。
これは、本番環境と開発環境の接続先の切り替えなどで利用しているプロジェクトが多いのではないでしょうか。
ビルドターゲットの設定などで値を変えるのが容易なのでよく利用されていると思います。
アプリケーションのコードからはBundle
のインスタンスから取得できます。
ユニットテストなどビルドターゲットが分かれているモードの場合利用しやすいです。
アプリ起動時に渡された環境変数を使う
アプリ起動時に渡された環境変数には様々な情報が含まれています。Xcodeでの設定で独自の値も追加できます。
アプリケーションのコードからはProcessInfo
のインスタンスから取得できます。
例えば、XCTestBundlePath
には実行しているTestBundleのパスが入っているので、SnapshotTestsという名前でスナップショットテストを構成していればその文字列が入っているかどうかで判別できます。
var isSnapshotTest: Bool {
guard let path = ProcessInfo.processInfo.environment["XCTestBundlePath"] else { return false }
return path.contains("SnapshotTests")
}
XcodePreviewsやXCTestの実行中であるかどうかも環境変数から判断できます。
var isXcodePreviews: Bool {
ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
}
var isXCTesting: Bool {
ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil
}
特別なことをしていることを強調する
実行モードにあわせてアプリケーションの実装としては特別なことをやっている場合、それに気づけるような一工夫をしておくとよいと思います。
単純ですが、メソッドや変数名へ明らかに通常のロジックでは呼ばないであろうことを示す命名をします。
例えば以下のようなものです。forDevelopment
やforTesting
のような、通常では使って欲しくなさそうなくどい命名をします。
private let skipSetUpSystemFunctionsForSnapshotTesting: Bool
private let useMockMapViewForDevelopment: Bool
チームのメンバーだけでなく、あとからコードを見た自分に対しての警告にもなるので、単純でも有用だと思っています。
おわりに
これらのテクニックはよく知られているものだったり、単純なものだと思いますが、LUUPアプリの開発での整理の目的で紹介しました。
プロジェクトの技術スタックやチームのスキルにあわせてバランスの良い手法を採用するのが大事だと思います。
もしこの記事を読み、LuupでのiOSアプリ開発に興味をお持ちいただけたら、弊社の採用ページもご覧ください。
Discussion