はじめに
この記事はand factory.inc Advent Calendar 2022 1日目の記事です。
and factory iOSエンジニアのy-okuderaです!
最近、SwiftUIでSnapshotテストをやってみています。
非同期でリモートの画像を取得するコンポーネントを含むSnapshotテストをするときに外部に依存しないように、ローカルの画像を使う方法を調べてみました。
対象読者
Swiftで、非同期で画像を取得している画面のSnapshotテストをしている方もしくはこれからする方。
説明しないこと
本投稿の中に記載するソースコードは、一部TCA(The Composable Architecture)を使用していますが、TCAの詳細に関する説明はしません。
TCAのリポジトリにサンプルや説明が充実していますし、Qiitaにも良記事がたくさんありますので、それらを参考にしてください。
swift-snapshot-testingでできること
iOSでSnapshotテストをするためのライブラリはいくつか有名なものがありますが、今回はPoint-Freeのswift-snapshot-testingというパッケージを使用しています。
Installationを読めば、SwiftPMで簡単に導入することができます。
swift-snapshot-testingパッケージのSnapshotTestingライブラリでは、Viewのスナップショットテストはもちろんですが、URLRequestのスナップショットやバイナリデータのスナップショットもサポートされているようです。
(本記事では、画面以外のスナップショットのテストは扱いません。 )
以下のように、assertSnapshotを実行することで、スナップショット未保存であれば、保存されます。
保存済みの場合は、テスト実行時の値と比較されて一致する場合はテスト成功、一致しない場合はテスト失敗となります。
assertSnapshot(
matching: targetView,
as: .image(layout: .device(config: .iPhone13))
)
非同期でリモートの画像を取得する画面をテストする
テスト対象の画面
Qiita APIを使用して、以下のような記事を検索するSearchItemsViewを実装しました。
こちらのSnapshotテストをしてみます。
テスト対象の画面の初期化処理
SearchItemsViewはTCAで実装していて、以下のように初期化時にStateの初期化もします。
init(store: Store<SearchItemsCore.State, SearchItemsCore.Action>)
SearchItemsView(
store: Store<SearchItemsCore.State, SearchItemsCore.Action>(
initialState: SearchItemsCore.State(
searchQuery: "Swift", // ①
items: [Item].stub // ②
... // 読み込み中のフラグなど一部State省略
),
reducer: SearchItemsCore()
)
)
Stateの値を元にSwiftUIでレンダリングされる実装になっているので、Snapshotテストでチェックしたい状態になるようにinitialStateで値を設定します。
①のsearchQueryと②のitemsの値がそれぞれ以下の図のように画面の表示に使用されています。
Snapshotテストは見た目が変わると失敗となるので、毎回同じ値を設定する必要があります。
そのため、①、②の部分は静的な値を設定して、WebAPIに依存しないようにします。
Entityの定義
前述の Item
という構造体の定義は以下のようになっています。
import Foundation
public struct Item: Decodable, Equatable, Sendable {
public let id: String
public let title: String
public let user: User
// ... 一部プロパティ省略
public init(
id: String,
title: String,
user: User
) {
self.id = id
self.title = title
self.user = user
}
}
Item
は、プロパティにUser
という構造体を持っているので、そちらの定義も確認していきます。
import Foundation
public struct User: Decodable, Equatable, Sendable {
public let id: String
public let name: String
public let profileImageUrl: String
// ... 一部プロパティ省略
public init(
id: String,
name: String,
profileImageUrl: String
) {
self.id = id
self.name = name
self.profileImageUrl = profileImageUrl
}
}
EntityのStubを定義
ItemとUserの定義を確認することができたので、次に、Snapshotテストで使うためのStubをExtensionで定義していきます。
// MARK: - Stub
extension Item {
public static func stub(id: String) -> Self {
.init(
id: id,
title: "長いタイトル長いタイトル長いタイトル長いタイトル長いタイトル",
user: .stub // 後述で定義します
)
}
}
画面では、Arrayで複数のItemを扱うので、Array用のStubも用意しておきます。
[Item]
のExtensionは、 extension Array where Element == Item { }
という形で書くことができます。
// MARK: - Stub
extension Item {
// ... 前述のため省略
}
extension Array where Element == Item {
public static var stub: Self {
[
.stub(id: "1"),
.stub(id: "2"),
.stub(id: "3"),
.stub(id: "4"),
.stub(id: "5"),
.stub(id: "6"),
.stub(id: "7"),
.stub(id: "8"),
.stub(id: "9"),
]
}
}
Item
のStubは定義できたので、次にUser
のStubを定義します。
// MARK: - Stub
extension User {
public static var stub: Self {
.init(
id: "103695",
name: "長い名前長い名前長い名前長い名前長い名前",
profileImageUrl: "https://qiita-image-store.s3.amazonaws.com/0/103695/profile-images/1527266276"
)
}
}
ここまでで、Item
とUser
のStubを定義することができました。
テキスト部分だけでなく、非同期で取得するイメージも静的な値を使いたい
Stubもできたし、Snapshotテストをしようと思いましたが、非同期で取得する画像って、URLだけ静的な値でも意味ないのでは?と思いました。
SearchItemsViewは、Kingfisherを使用して、 item.user.profileImageUrl
を指定して画像を取得しています。
もし、サーバがメンテナンス中だったり、APIのトークンの有効機嫌が切れてしまっていたら、画像を取得できずViewが一致しないので、テスト失敗となってしまいます。
本来UI上は問題ないのに、Snapshotテストが失敗してしまうということはサーバに依存しているということになるので、依存を排除したいです。
(勿論、実際のURLで画像が取得できることはテストされるべきですが、Snapshotテストからは切り離したいという意図です。)
Assetsの画像からURLを生成して、代用する
サーバへの依存を排除するために、User
のStubを変更します。
profileImageUrlへローカルの画像URLを設定して、それをKingfisherで読み込むようにしていきます。
まずは、Asset Catalogにテスト用の画像を追加します。
SnapshotテストやXcode Previewでしか使用しない画像であれば、Development Assetsに定義することでArchiveに含めないようにでき、リリース用のアプリに無駄なリソースを含めないようにできます。
テスト用にcachesDirectoryに画像を書き込み、ローカルURLを取得します。
import UIKit
extension URL {
static func localURLForXCAsset(
name: String,
in bundle: Bundle,
fileManager: FileManager = .default
) -> URL? {
guard let cacheDirectory = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first else {
return nil
}
let url = cacheDirectory.appendingPathComponent("\(name).png")
let path = url.path
if !fileManager.fileExists(atPath: path) {
guard let image = UIImage(named: name, in: bundle, with: nil), let data = image.pngData() else {
return nil
}
fileManager.createFile(atPath: path, contents: data, attributes: nil)
}
return url
}
}
このURLのExtensionは、以下を参考にさせていただきました。
ローカルの画像URLを生成できるようになったので、User
のStubを更新します。
// MARK: - Stub
extension User {
public static var stub: Self {
let profileImageUrl = URL.localURLForXCAsset(name: "sportman_icon", in: .module)?.absoluteString ?? ""
return .init(
id: "103695",
name: "長い名前長い名前長い名前長い名前長い名前",
profileImageUrl: profileImageUrl
)
}
}
Snapshotテストを実行してみる
ようやくStubが用意できたので、最後にSnapshotテストを書いて、実行してみます。
複数のデバイスサイズのSnapshotを生成したいので、まずはデバイスの種類を表現するenumを定義します。
import SnapshotTesting
public enum SnapshotDevices: String, CaseIterable {
case iPhone13
case iPhone8Plus
case iPhoneSe
public var layout: SwiftUISnapshotLayout {
switch self {
case .iPhone13:
return .device(config: .iPhone13)
case .iPhone8Plus:
return .device(config: .iPhone8Plus)
case .iPhoneSe:
return .device(config: .iPhoneSe)
}
}
}
Snapshotテストのコードは以下のようになります。
import ComposableArchitecture
@testable import SearchItemsFeature
import SnapshotTesting
import SwiftUI
import TestHelper
import XCTest
final class SearchFeatureTests: XCTestCase {
override func setUp() {
// 全て新しくSnapshotを撮る場合は、trueにします
isRecording = true
}
override func tearDown() {}
func testSearchItemsView() throws {
XCTContext.runActivity(named: "itemsが空ではない") { _ in
let store = Store(
initialState: .init(
searchQuery: "長い検索語長い検索語長い検索語長い検索語長い検索語",
currentPage: 1,
isLoading: false,
isLoadingPage: false,
items: .mock,
webBrowserState: nil
),
reducer: SearchItemsCore()
)
let view = NavigationView { // ... ①
SearchItemsView(
store: store
)
}
SnapshotDevices.allCases.forEach {
assertSnapshot(
matching: view,
as: .image(layout: $0.layout),
named: "itemsが空ではない.\($0.rawValue)"
)
}
}
}
}
SearchItemsViewをただassertSnapshot
の引数に使用しただけでは、ナビゲーションバーが無い状態のSnapshotになってしまうので、①のところでNavigationViewで囲っています。
これは、Snapshotテストに限らず、Xcode Previewでも同様なので、よく使われます。
生成されたSnapshotはこちらです。
ちゃんとAsset Catalogの画像が読み込まれました!
解決できていないこと
テスト用の画像をAsset Catalogに追加して、それを実際に使うことはできましたが、1つ解決できていないことがあります。
Swift Packageで構成しているアプリで、Asset CatalogがSwift Package内にある場合、Development Assetsに定義する方法がわかりませんでした。可能かどうかもよくわかっていません。
ご存じの方いらっしゃいましたら、コメントで教えていただけると幸いです
おわりに
非同期でリモートの画像を取得するコンポーネントを含むSnapshotテストをするときに外部に依存しないように、ローカルの画像を使う方法を調べてみました。
Snapshotテスト自体まだまだ勉強中なので、引き続きキャッチアップしていこうと思います。
最後まで見ていただいてありがとうございました。
明日のAdvent Calendarの記事もお楽しみに