12
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

and factory.incAdvent Calendar 2022

Day 1

SnapshotTestingでURLで取得するリモートの画像の代替品としてAsset Catalogの画像を使用する

Last updated at Posted at 2022-11-30

はじめに

この記事は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のスナップショットやバイナリデータのスナップショットもサポートされているようです。
(本記事では、画面以外のスナップショットのテストは扱いません。:bow:

以下のように、assertSnapshotを実行することで、スナップショット未保存であれば、保存されます。
保存済みの場合は、テスト実行時の値と比較されて一致する場合はテスト成功、一致しない場合はテスト失敗となります。

assertSnapshot(
    matching: targetView,
    as: .image(layout: .device(config: .iPhone13))
)

非同期でリモートの画像を取得する画面をテストする

テスト対象の画面

Qiita APIを使用して、以下のような記事を検索するSearchItemsViewを実装しました。
こちらのSnapshotテストをしてみます。

スクリーンショット 2022-11-30 19.37.06 2.png

テスト対象の画面の初期化処理

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の値がそれぞれ以下の図のように画面の表示に使用されています。
スクリーンショット 2022-11-30 19.37.06 3 2.png

Snapshotテストは見た目が変わると失敗となるので、毎回同じ値を設定する必要があります。
そのため、①、②の部分は静的な値を設定して、WebAPIに依存しないようにします。

Entityの定義

前述の Itemという構造体の定義は以下のようになっています。

Item.swift
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という構造体を持っているので、そちらの定義も確認していきます。

Item.swift
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で定義していきます。

Item.swift - ②
// 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 { } という形で書くことができます。

Item.swift - ③
// 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を定義します。

User.swift - ②
// 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"
        )
    }
}

ここまでで、ItemUserのStubを定義することができました。

テキスト部分だけでなく、非同期で取得するイメージも静的な値を使いたい

Stubもできたし、Snapshotテストをしようと思いましたが、非同期で取得する画像って、URLだけ静的な値でも意味ないのでは?と思いました。

SearchItemsViewは、Kingfisherを使用して、 item.user.profileImageUrlを指定して画像を取得しています。

もし、サーバがメンテナンス中だったり、APIのトークンの有効機嫌が切れてしまっていたら、画像を取得できずViewが一致しないので、テスト失敗となってしまいます。
本来UI上は問題ないのに、Snapshotテストが失敗してしまうということはサーバに依存しているということになるので、依存を排除したいです。

(勿論、実際のURLで画像が取得できることはテストされるべきですが、Snapshotテストからは切り離したいという意図です。)

Assetsの画像からURLを生成して、代用する

サーバへの依存を排除するために、UserのStubを変更します。
profileImageUrlへローカルの画像URLを設定して、それをKingfisherで読み込むようにしていきます。

まずは、Asset Catalogにテスト用の画像を追加します。
スクリーンショット 2022-11-30 21.51.10.png

SnapshotテストやXcode Previewでしか使用しない画像であれば、Development Assetsに定義することでArchiveに含めないようにでき、リリース用のアプリに無駄なリソースを含めないようにできます。
スクリーンショット 2022-11-30 22.07.06.png

テスト用にcachesDirectoryに画像を書き込み、ローカルURLを取得します。

URL+.swift
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を更新します。

User.swift - ③
// 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を定義します。

TestHelper/SnapshotDevices.swift
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テストのコードは以下のようになります。

SearchFeatureTests.swift
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の画像が読み込まれました!

スクリーンショット 2022-11-30 22.51.50.png

スクリーンショット 2022-11-30 23.12.36.png

解決できていないこと

テスト用の画像をAsset Catalogに追加して、それを実際に使うことはできましたが、1つ解決できていないことがあります。

Swift Packageで構成しているアプリで、Asset CatalogがSwift Package内にある場合、Development Assetsに定義する方法がわかりませんでした。可能かどうかもよくわかっていません。

ご存じの方いらっしゃいましたら、コメントで教えていただけると幸いです:bow:

おわりに

非同期でリモートの画像を取得するコンポーネントを含むSnapshotテストをするときに外部に依存しないように、ローカルの画像を使う方法を調べてみました。
Snapshotテスト自体まだまだ勉強中なので、引き続きキャッチアップしていこうと思います。

最後まで見ていただいてありがとうございました。
明日のAdvent Calendarの記事もお楽しみに:santa:

12
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?