9
4

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.

PropertyWrapperを利用したDIコンテナの利用とRxSwift+MVVMでの処理&UnitTestに関する事例解説

Last updated at Posted at 2022-11-04

1. はじめに

昨年に公開した記事の中で自前でDIコンテナを作ってみる試みとRxSwiftを利用した構成への適用を試してみるというタイトルで、実務を通じてDIコンテナを自作した際のTIPS等を簡単ではありますがまとめました。

この記事の中でも少しだけ触れていますが、この記事を書いたタイミングでは残念ながら実践適用を見送った自作DIコンテナ構築のアイデアとしまして、 【実装MEMO】PropertyWrappersの機能を利用したDependency Injectionのコードに触れた際の備忘録 にて紹介している方法もアイデアの1つとしてありました。

iOSアプリ開発においてDI機構を整備するために、下記の様なOSS製ライブラリを利用される事が多いと思います。

本記事では、OSS製ライブラリを利用せずにPropertyWrapperを活用したDIに関する事例の紹介と同時に、 「以前に解説したPropertyWrapperを利用したDIコンテナを自作した際にテストコードをどう書くか?」 という点についても簡単に触れたものになります。

※1. 基本的にはSwiftLeeさんの記事でご紹介されている方針や手法と基本的には同様かと思います。

※2. DIコンテナを利用した構造と関係するユニットテストに関する事例については、下記の記事でも紹介されています。

2. 解説に利用するサンプル実装例に関する簡単なご紹介

※ 自分のPC環境でBuildしたい場合は、READMEの記載も参考にして頂けますと幸いです。

基本的な構成については、iOSアプリ側については 「RxSwift + MVVM + UIKit」 を利用したアーキテクチャ構成で実装されており、バックエンド側については 「Server Side Kotlin & Spring Boot」 を利用した認証・認可処理やAPIからの表示データ取得処理機構についても簡単ではありますが提供しています。

2-1. iOS側で実装されている画面例

サインイン処理後の画面については、基本的にはUICollectionViewをベースにしたレイアウト構造となっています。サインイン処理完了直後に表示されるTOP画面については、UICollectionViewCompositionalLayout&NSDiffableDataSourceを利用した実装となっていますが、これだけでは難しいレイアウトを構築する場合には、従来通りのUICollectionViewLayoutクラスを継承した独自クラスをUICollectionViewに適用している部分や、自前で実装するには難易度が高い表現を実現するために、UICollectionViewのレイアウト実装に関連するOSSライブラリを活用した部分もあります。

【Vol1. サインイン画面&サインアップ画面】

sample_thumbnail1.jpg

【Vol2. UICollectionViewCompositionalLayout&NSDiffableDataSourceを利用した複雑な画面】

この画面における全体構成についてはUICollectionViewCompositionalLayout & DiffableDataSourceを利用した形にしています。 例外として、少し工夫が必要であったバナー表示部分とバウンドがかかる横スクロール部分についてはRxSwiftでの実装やライブラリを活用し、それぞれの画面をContainerViewを利用して表示する形をとりました。

sample_thumbnail2.jpg

sample_thumbnail2_appendix1.png

sample_thumbnail2_appendix2.png

【Vol3. UICollectionViewLayoutを継承したレイアウトを適用した画面】

この画面における全体構成についてはUICollectionViewCompositionalLayoutでは少し実現しにくそうな処理や動きであったため、従来通りのUICollectionViewLayoutクラスを継承し、ユニークな独自のレイアウト属性を定義したものをUICollectionViewに適用する形をとりました。

sample_thumbnail3.jpg

2-2. バックエンド側処理に関する補足とアクセストークン取得処理時の流れ

バックエンド側の実装におきましても、Dockerを利用した開発環境やSwaggerを利用したAPI定義書の簡単な例を準備しております。

余力がある方は是非このリポジトリの「Backend」ディレクトリ内に格納しているKotlin(SpringBoot)プロジェクトをビルド後に http://localhost:8080/swagger-ui.html へアクセスして頂きますと、アプリサンプル内で利用しているエンドポイント一覧を確認したり、Simulator内でバックエンド側での処理と連携した動作確認をする事ができる様にしていますので、是非試して頂けますと幸いです。

【Kotlin + Spring Boot + JPAを利用したMySQLに格納されているデータを取得する処理のイメージ】

spring_boot_code_image.png

【Swaggerのボトムアップ・アプローチ(ソースコードからAPI定義所を書き起こす)のポイント】

swagger_definition_example.png

【アクセストークン取得処理時の流れ】

今回の記事におけるメインの部分ではありませんが、バックエンド側から取得するアクセストークンをiOSアプリ側でどの様に利用しているかという点を簡単に図解にまとめたものが下記になります。

jwt_authenticated_flow.png

基本的にはサインイン処理を実行する事でアクセストークンを取得し、表示データ取得時のAPIリクエストを実行時にリクエストヘッダーに付与する処理が必要になる点についても注意が必要になります。(アクセストークンを取得することなくAPIリクエストを実行した場合にはサインイン画面へリダイレクトされます。)

【iOS側でのAPIリクエスト処理を共通定義しているクラスのコード抜粋】

APIRequestManager.swift
import Foundation
import RxSwift

// MARK: - Enum

// MEMO: APIリクエストに関するEnum定義
enum HTTPMethod {
    case GET
    case POST
    case PUT
    case DELETE
}

// MEMO: APIエラーメッセージに関するEnum定義
enum APIError: Error {
    case error(String)
}

// MEMO: APIリクエストの状態に関するEnum定義
enum APIRequestState {
    case none
    case requesting
    case success
    case error
}

final class APIRequestManager {
    
    // MEMO: API Mock ServerへのURLに関する情報
    static let host = "http://localhost:8080/api"
    static let version = "v1"

    private let session = URLSession.shared

    // MARK: - Singleton Instance

    static let shared = APIRequestManager()

    private init() {}

    // MARK: - Function

    func executeAPIRequest<T: Decodable>(endpointUrl: String, withParameters: [String : Any] = [:], httpMethod: HTTPMethod = .GET, responseFormat: T.Type) -> Single<T> {

        var urlRequest: URLRequest
        switch httpMethod {
        case .GET:
            urlRequest = makeGetRequest(endpointUrl)
        case .POST:
            urlRequest = makePostRequest(endpointUrl, withParameters: withParameters)
        default:
            fatalError()
        }
        return handleDataTask(T.self, request: urlRequest)
    }

    // MARK: - Private Function

    private func handleDataTask<T: Decodable>(_ dataType: T.Type, request: URLRequest) -> Single<T> {

        return Single<T>.create(subscribe: { singleEvent in

            // MEMO: API通信結果のハンドリング処理では、成功または失敗かのいずれかのイベントを1度だけ流すことを保証する形にする
            let task = self.session.dataTask(with: request) { data, response, error in
                // MEMO: 通信状態等に起因するエラー発生時のエラーハンドリング
                if let error = error {
                    singleEvent(.failure(error))
                    return
                }
                // MEMO: Debug用にエラー発生時のJSONを出力する
                //self.displayErrorForDebug(targetResponse: response, targetData: data)
                // MEMO: ステータスコードの精査及びエラーハンドリング
                if let response = response as? HTTPURLResponse, case 400...500 = response.statusCode {

                    // 👉 アクセストークンの有効期限が切れてしまった際などの場合、すなわちステータスコードが403(Access Denied)の場合にはサインイン画面へ強制的に表示させるようにする
                    if response.statusCode == 403 {
                        // MEMO: ここにCoodinatorの処理を強制的に加えるのは果たして良いのかは迷うところです。
                        // → しかもこの中で無理やりDispatchQueueで処理するような処理だから強引っちゃ強引な感じがある
                        print("403 Error Occurs in", request)
                        DispatchQueue.main.async {
                            let signinCoodinator = SigninScreenCoordinator()
                            signinCoodinator.start()
                        }
                        singleEvent(.failure(APIError.error("Error: StatusCodeが403です。")))
                    } else {
                        singleEvent(.failure(APIError.error("Error: StatusCodeが200~399以外です。")))
                    }
                    return
                }

                // MEMO: 取得データの内容の精査及びエラーハンドリング
                guard let data = data else {
                    singleEvent(.failure(APIError.error("Error: レスポンスが空でした。")))
                    return
                }
                // MEMO: 取得できたレスポンスを引数で指定した型の配列に変換して受け取る
                do {
                    let hashableObjects = try JSONDecoder().decode(T.self, from: data)
                    singleEvent(.success(hashableObjects))
                } catch {
                    singleEvent(.failure(APIError.error("Error: JSONからのマッピングに失敗しました。")))
                }
            }
            task.resume()

            return Disposables.create {
                task.cancel()
            }
        })
    }

    // API Mock ServerへのGETリクエストを作成する
    private func makeGetRequest(_ urlString: String) -> URLRequest {
        guard let url = URL(string: urlString) else {
            fatalError()
        }
        var urlRequest = URLRequest(url: url)
        urlRequest.httpMethod = "GET"
        urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type")

        // 👉 [GET]でのAPIリクエスト時に保持しているアクセストークンをHeaderへ付与している。
        let authraizationHeader = KeychainAccessManager.shared.getAuthenticationHeader()
        if !authraizationHeader.isEmpty {
            urlRequest.addValue(authraizationHeader , forHTTPHeaderField: "Authorization")
        }
        return urlRequest
    }

    // API Mock ServerへのPOSTリクエストを作成する
    private func makePostRequest(_ urlString: String, withParameters: [String : Any] = [:]) -> URLRequest {
        guard let url = URL(string: urlString) else {
            fatalError()
        }
        var urlRequest = URLRequest(url: url)
        urlRequest.httpMethod = "POST"
        // MEMO: Dictionaryで取得したリクエストパラメータをJSONに変換している
        do {
            let requestBody = try JSONSerialization.data(withJSONObject: withParameters, options: [])
            urlRequest.httpBody = requestBody
        } catch {
            fatalError("Invalid request body parameters.")
        }
        urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type")

        // 👉 [POST]でのAPIリクエスト時に保持しているアクセストークンをHeaderへ付与している。
        let authraizationHeader = KeychainAccessManager.shared.getAuthenticationHeader()
        if !authraizationHeader.isEmpty {
            urlRequest.addValue(authraizationHeader , forHTTPHeaderField: "Authorization")
        }
        return urlRequest
    }

    // MEMO: デバッグ用にエラー時に出力されるJSONを表示する
    private func displayErrorForDebug(targetResponse: URLResponse?, targetData: Data?) {
        if let debugResponse = targetResponse as? HTTPURLResponse {
            print("StatusCode:", debugResponse.statusCode)
            if let debugData = targetData {
                let debugJson = String(data: debugData, encoding: String.Encoding.utf8) ?? "No Response found."
                print("ErrorResponse:", debugJson)
            }
        }
    }
}

3. DIコンテナ部分の実装とユニットテストを書く事ができる形にするための手順

ポイントとなる部分は、各ドメイン層における依存関係の登録をする際に一緒にProtocol名も渡している点になるかと思います。こうすることで、Protocol名をキーにして実装クラスを管理するような形を取ることができるので、もし各ドメイン側で必要な実装クラスとプロトコル名に誤りがある場合には、「名前との紐付けがおかしいですよ!」ということでクラッシュが発生するような形にしています。

3-1. PropertyWrapperを利用した「依存性の注入(Dependency Injection)」の実装

DIコンテナの大元になる部分はDictionary側で定義されている変数であるprivate var dependencies: [(key: Dependencies.Name, value: Any)] = []の部分がポイントになります。

「大きなシングルトンインスタンスの中に、Infrastructure⇄Repository⇄ViewModel⇄ViewControllerという責務の順番を意識した上でプロトコル名プロトコルに準拠するクラスのインスタンスが1:1対応をする様な形で格納されている」 という風に考えていくとイメージしやすいのではないかと思います。

【1. Dependencies.swiftによるDIコンテナに関する定義】

Dependencies.swift
import Foundation

// MEMO: Swift5.1から登場した「Property Wrappers」を利用したDependency Injectionの実装例
// https://stackoverflow.com/questions/61316547/nested-dependency-injection-through-property-wrapper-crashes
// 補足: Property Wrappersについて
// https://dev.classmethod.jp/articles/property-wrappers/

enum Dependencies {

    // MARK: - Struct (for Name of Dependencies)

    struct Name: Equatable {
        let rawValue: String
        static let `default` = Name(rawValue: "__default__")
        static func == (lhs: Name, rhs: Name) -> Bool { lhs.rawValue == rhs.rawValue }
    }

    // MARK: - Class (for Container)

    final class Container {

        private var dependencies: [(key: Dependencies.Name, value: Any)] = [] {
            didSet {
                // MEMO: UnitTest実行時にDIコンテナへの追加&削除処理が正しく実行されているかを確認する
                if isTesting() {
                    print(dependencies)
                }
            }
        }

        static let `default` = Container()

        // MARK: - Function

        // MEMO: 依存関係があるものを登録する
        func register(_ dependency: Any, for key: Dependencies.Name = .default) {
            dependencies.append((key: key, value: dependency))
        }

        // MEMO: 引数に与えた名前を元にDIを実行する
        func resolve<T>(_ key: Dependencies.Name = .default) -> T {

            // Debug.
            //dump(dependencies)

            // MEMO: filterとfirstをするよりもfirst(where:)の方がパフォーマンスが良い
            // https://qiita.com/shtnkgm/items/928630d692cf1e5b0846
            let instanceObjectValue = dependencies
                // MEMO: 引数のkeyと一致する&型がTに設定している条件に合致する場合はその値(.value)だけを利用する
                .first { (dependencyTuple) -> Bool in
                    dependencyTuple.key == key && dependencyTuple.value is T
                }
                .flatMap{ (_, value) in
                    value
                }

            // MEMO: 名前に対応する型でダウンキャストを実施し、名前と依存関係を正しく対応させないとクラッシュが発生する形にしている
            guard let instance = instanceObjectValue as? T else {
                fatalError("Could not cast value of type 'Any' to expected type.")
            }
            return instance
        }

        // MEMO: 依存関係があるものをKey値を元にして削除する
        func remove(for key: Dependencies.Name = .default) {
            dependencies.removeAll(where: { $0.key == key })
        }

        // MEMO: 依存関係があるものを全て削除する
        func reset() {
            dependencies.removeAll()
        }
    }

    // MARK: - @propatyWrapper (for Struct for Dependency Injection)

    @propertyWrapper
    struct Inject<T> {

        private let dependencyName: Name
        private let container: Container

        // 設定した名前を元に依存関係の解決を実施する
        var wrappedValue: T { container.resolve(dependencyName) }

        // MARK: - Initializer

        init(_ dependencyName: Name = .default, on container: Container = .default) {
            self.dependencyName = dependencyName
            self.container = container
        }
    }
}

【2. Dependencies.swiftによるDIコンテナへの依存関係の登録や解除に関する処理定義】

Dependencies.swift
import Foundation

final class DependenciesDefinition {

    // MARK: - Function

    // MEMO: PropertyWrapperを利用したDependencyInjectionを実施する
    func inject() {

        let container = Dependencies.Container.default

        // MEMO: 命名で厳密に縛りたい場合は第2引数にこのような名前をつけてあげる
        // 👉 DI実行側 → container.register(RealmAccessManager.shared, Dependencies.Name(rawValue: "RealmAccessProtocol"))
        // 👉 DI定義側 → @Dependencies.Inject(Dependencies.Name(rawValue: "RealmAccessProtocol")) private var realmAccessManager: RealmAccessProtocol
        // ※ Dependencies.Nameの対応を正しく定義していないとクラッシュが発生するような形にしている

        // MEMO: Infrastructure層の登録
        let managers: Array<(implInstance: Any, protocolName: Any)> = [
            (
                implInstance: APIRequestManager.shared,
                protocolName: APIRequestProtocol.self
            ),
            (
                implInstance: RealmAccessManager.shared,
                protocolName: RealmAccessProtocol.self
            ),
            (
                implInstance: KeychainAccessManager.shared,
                protocolName: KeychainAccessProtocol.self
            )
        ]
        let _ = managers.map{ manager in
            container.register(
                manager.implInstance,
                for: Dependencies.Name(rawValue: TypeScanner.getName(manager.protocolName))
            )
        }

        // MEMO: Repository層の登録
        let repositories: Array<(implInstance: Any, protocolName: Any)> = [
            (
                implInstance: CurrentApplicationUserRepository(),
                protocolName: ApplicationUserRepository.self
            ),
            (
                implInstance: MainScreenRepository(),
                protocolName: MainRepository.self
            ),
            (
                implInstance: RequestAnnouncementDataRepository(),
                protocolName: AnnouncementRepository.self
            ),
            // ・・・(省略: 以後似た様な処理が続く)・・・
        ]
        let _ = repositories.map{ repository in
            container.register(
                repository.implInstance,
                for: Dependencies.Name(rawValue: TypeScanner.getName(repository.protocolName))
            )
        }

        // MEMO: ViewModel層の登録
        let viewModels: Array<(implInstance: Any, protocolName: Any)> = [
            (
                implInstance: MainViewModel(),
                protocolName: MainViewModelType.self
            ),
            (
                implInstance: AnnouncementViewModel(),
                protocolName: AnnouncementViewModelType.self
            ),
            (
                implInstance: TopBannerViewModel(),
                protocolName: TopBannerViewModelType.self
            ),
            // ・・・(省略: 以後似た様な処理が続く)・・・
        ]
        let _ = viewModels.map{ viewModel in
            container.register(
                viewModel.implInstance,
                for: Dependencies.Name(rawValue: TypeScanner.getName(viewModel.protocolName))
            )
        }
    }

    func reset() {
        let container = Dependencies.Container.default
        container.reset()
    }

    func injectIndividualMock(mockInstance: Any, protocolName: Any) {
        let container = Dependencies.Container.default
        container.register(
            mockInstance,
            for: Dependencies.Name(rawValue: TypeScanner.getName(protocolName))
        )
    }

    func removeIndividualMock(protocolName: Any) {
        let container = Dependencies.Container.default
        container.remove(for: Dependencies.Name(rawValue: TypeScanner.getName(protocolName)))
    }
}

【3. AppDelegate.swiftによるDIコンテナへ依存関係の登録処理をする部分の抜粋】

AppDelegate.swift
import UIKit

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

        // MEMO: Dependency Injection用の処理を初期化する
        if isTesting()  {
            // 注意: Test実行時はDIコンテナへの登録処理をしない
            // 👉 ●●●Spec内のbeforeEachではテストコードを動作させるのに必要な責務をDIコンテナへ登録
            // 👉 ●●●Spec内のafterEachではテストコードを動作させるのに登録した責務を削除する
            print("Build for Unit Testing Starting...")
        } else {
            print("Build for Debug/Production Starting...")
            let productionDependency = DependenciesDefinition()
            productionDependency.inject()
        }

        // 補足: もしログアウト処理等で再度DIコンテナへの依存関係を登録し直す場合は下記の様な処理を追加すれば良い
        let productionDependency = DependenciesDefinition()
        productionDependency.reset()
        productionDependency.inject()

        // ・・・(省略: もしこの他に必要な処理があれば記載する)・・・

        return true
    }

    // ・・・(省略)・・・
}

// MARK: - Global Function For Testing

func isTesting() -> Bool {
    return NSClassFromString("XCTest") != nil
}

3-2. RxSwiftを利用したMVVMパターンでお知らせ一覧を取得して表示する処理例

ここで紹介しているのは、お知らせ一覧情報をAPIから取得して画面に表示する際における 「Infrastructure(API) ⇄ Repository ⇄ ViewModel ⇄ ViewController間における処理コードの抜粋例」 となります。

@Dependencies.Inject(Dependencies.Name(rawValue: "プロトコル名")) private var クラス内で利用する変数名: プロトコル名の部分に注目すると、該当するクラスで利用する責務の関係が分かる様な形を取っている部分がポイントになるかと思います。

【1. Infrastructure層部分のコード抜粋】

APIRequestManager.swift
import Foundation
import RxSwift

// MARK: - Protocol
protocol APIRequestProtocol {
    func getAnnoucements() -> Single<AnnouncementListResponse>
}

// MARK: - APIRequestManagerProtocol
extension APIRequestManager: APIRequestProtocol {

    // お知らせ一覧表示用のAPIリクエスト処理の実行
    func getAnnoucements() -> Single<AnnouncementListResponse> {

        let annoucementListsEndPoint = EndPoint.announcement.getBaseUrl()
        return executeAPIRequest(
            endpointUrl: annoucementListsEndPoint,
            httpMethod: HTTPMethod.GET,
            responseFormat: AnnouncementListResponse.self
        )
    }
}

【2. Repository層部分のコード抜粋】

AnnoucementRepositoryImpl.swift
import Foundation
import RxSwift

// MARK: - Protocol
protocol AnnoucementRepository {

    // お知らせ一覧表示用のAPIリクエストを実行する
    func requestAnnouncementDataList() -> Single<AnnouncementListResponse>
}

final class AnnoucementRepositoryImpl: AnnoucementRepository {

    // MARK: - Properties
    @Dependencies.Inject(Dependencies.Name(rawValue: "APIRequestProtocol")) private var apiRequestManager: APIRequestProtocol

    // MARK: - AnnoucementRepository
    func requestAnnouncementDataList() -> Single<AnnouncementListResponse> {
        return apiRequestManager.getAnnoucements()
    }
}

【3. ViewModel層部分のコード抜粋】

AnnouncementViewModel.swift
import Foundation
import RxSwift
import RxCocoa

protocol AnnouncementViewModelInputs {

    // 初回のデータ取得をViewModelへ伝える
    var initialFetchTrigger: PublishSubject<Void> { get }

    // PullToRefreshでのデータ更新をViewModelへ伝える
    var pullToRefreshTrigger: PublishSubject<Void> { get }

    // APIRequestStateを元に戻す処理の実行をViewModelへ伝える
    var undoAPIRequestStateTrigger: PublishSubject<Void> { get }
}

protocol AnnouncementViewModelOutputs {

    // JSONから取得した表示用データを格納する
    var announcementItems: Observable<Array<AnnouncementEntity>> { get }

    // 取得処理の実行結果を格納する
    var requestStatus: Observable<APIRequestState> { get }
}

protocol AnnouncementViewModelType {
    var inputs: AnnouncementViewModelInputs  { get }
    var outputs: AnnouncementViewModelOutputs { get }
}

final class AnnouncementViewModel: AnnouncementViewModelInputs, AnnouncementViewModelOutputs, AnnouncementViewModelType {

    var inputs: AnnouncementViewModelInputs { return self }
    var outputs: AnnouncementViewModelOutputs { return self }

    // MARK: - Properties (for AnnouncementViewModelInputs)

    let initialFetchTrigger: PublishSubject<Void> = PublishSubject<Void>()

    let pullToRefreshTrigger: PublishSubject<Void> = PublishSubject<Void>()

    let undoAPIRequestStateTrigger: PublishSubject<Void> = PublishSubject<Void>()

    // MARK: - Properties (for AnnouncementViewModelOutputs)

    var announcementItems: Observable<Array<AnnouncementEntity>> {
        return _announcementItems.asObservable()
    }

    var requestStatus: Observable<APIRequestState> {
        return _requestStatus.asObservable()
    }

    // MARK: - Properties

    private let disposeBag = DisposeBag()

    // MEMO: 中継地点となるBehaviorRelayの変数(Outputの変数を生成するための「つなぎ」のような役割)
    // → BehaviorRelayの変化が起こったらObservableに変換されてOutputに流れてくる
    private let _announcementItems: BehaviorRelay<Array<AnnouncementEntity>> = BehaviorRelay<Array<AnnouncementEntity>>(value: [])
    private let _requestStatus: BehaviorRelay<APIRequestState> = BehaviorRelay<APIRequestState>(value: .none)

    // MEMO: このViewModelで利用するRepository
    @Dependencies.Inject(Dependencies.Name(rawValue: "AnnouncementRepository")) private var announcementRepository: AnnouncementRepository

    // MARK: - Initializer

    init() {

        // ViewModel側の処理実行トリガーと連結させる
        initialFetchTrigger
            .subscribe(
                onNext: { [weak self] _ in
                    guard let self = self else { return }
                    self.executeAnnouncementDataRequest()
                }
            )
            .disposed(by: disposeBag)
        pullToRefreshTrigger
            .subscribe(
                onNext: { [weak self] _ in
                    guard let self = self else { return }
                    self.executeAnnouncementDataRequest()
                }
            )
            .disposed(by: disposeBag)
        undoAPIRequestStateTrigger
            .subscribe(
                onNext: { [weak self] in
                    guard let self = self else { return }
                    self._requestStatus.accept(.none)
                }
            )
            .disposed(by: disposeBag)
    }

    // MARK: - Private Function

    private func executeAnnouncementDataRequest() {
        _requestStatus.accept(.requesting)
        announcementRepository.requestAnnouncementDataList()
            .subscribe(
                onSuccess: { [weak self] data in
                    guard let self = self else { return }
                    self._requestStatus.accept(.success)
                    self._announcementItems.accept(data.result)
                },
                onFailure: { [weak self] error in
                    guard let self = self else { return }
                    self._requestStatus.accept(.error)
                }
            )
            .disposed(by: disposeBag)
    }
}

【4. ViewController層部分のコード抜粋】

AnnouncementViewController.swift
import UIKit

final class AnnouncementViewController: UIViewController {

    // ... (※Dependency Injection部分のみ抜粋) ...
    // MEMO: お知らせ表示状態をハンドリングするViewModel
    @Dependencies.Inject(Dependencies.Name(rawValue: "AnnouncementViewModelType")) private var viewModel: AnnouncementViewModelType

    // ... 以下省略 ...
}

4. 実際にテストコードを記載するイメージと実装コードにおけるユニットテストの解説

基本的にはRxSwiftでの処理を前提としたコードなので、RxBlockingRxTestを利用したテストコードを前提としたものになります。DIコンテナ実装はPropertyWrapperを利用しているので下記の様なイメージで、テストコード内で利用したい責務クラスを個別に追加&削除ができる形にしておく事がポイントになるかと思います。また、テストコードで利用するMock化したクラスについては、コマンドラインでMock自動生成処理ができる様にSwiftyMockyを利用しています。

【1. SwiftyMockyの導入とMock自動生成イメージ】

introduction_of_swiftymocky.png

【2. コードにおける要点解説】

① DIコンテナ部分における追加実装:

final class DependenciesDefinition {

    // MARK: - Function

    // MEMO: PropertyWrapperを利用したDependencyInjectionを実施する
    func inject() {
        // 👉 実際にアプリを動作させる際に必要な責務をDIコンテナに登録する処理が入る 
    }

    // 👉 テストに必要なMock化した責務を登録するためのメソッド
    func injectIndividualMock(mockInstance: Any, protocolName: Any) {
        let container = Dependencies.Container.default
        container.register(
            mockInstance,
            for: Dependencies.Name(rawValue: TypeScanner.getName(protocolName))
        )
    }

    // 👉 テストに必要なMock化した責務を削除するためのメソッド
    func removeIndividualMock(protocolName: Any) {
        let container = Dependencies.Container.default
        container.remove(for: Dependencies.Name(rawValue: TypeScanner.getName(protocolName)))
    }
}

② ViewModelにおけるテストコード例:

@testable import VisualEffectTraceExample

import Nimble
import Quick
import RxBlocking
import RxSwift
import SwiftyMocky
import XCTest

final class FeaturedArticleViewModelSpec: QuickSpec {

    // MARK: - Override

    // MEMO: ViewModelクラス内のInput&Outputの変化が検知できていることを確認する
    override func spec() {

        // ----------
        // ポイント①: テストを実行するための準備
        // 👉 DIコンテナをインスタンス化&このクラスに必要な責務に対してのMockをインスタンス化する
        // ----------
        let testingDependency = DependenciesDefinition()
        let featuredArticleUseCase = FeaturedArticleUseCaseMock()

        // MARK: - initialFetchTriggerを実行した際のテスト

        // MEMO: サーバーから表示内容を取得する場合
        describe("#initialFetchTrigger") {
            context("サーバーからの取得処理が成功した場合") {
                let featuredArticleAPIResponse = getFeaturedArticleAPIResponse()

                // ----------
                // ポイント②: テスト前に実行する処理
                // 👉 Mock化した必要な責務が想定している返り値を定義する
                // ----------
                beforeEach {
                    testingDependency.injectIndividualMock(
                        mockInstance: featuredArticleUseCase,
                        protocolName: FeaturedArticleUseCase.self
                    )
                    featuredArticleUseCase.given(
                        .execute(
                            willReturn: Single.just(featuredArticleAPIResponse)
                        )
                    )
                }

                // ----------
                // ポイント③: テスト後に実行する処理
                // 👉 Mock化した必要な責務をDIコンテナから削除する
                // ----------
                afterEach {
                    testingDependency.removeIndividualMock(
                        protocolName: FeaturedArticleUseCase.self
                    )
                }

                it("viewModel.outputs.featuredArticleItemsが取得データと一致する&viewModel.outputs.requestStatusがAPIRequestState.successとなること") {
                    let target = FeaturedArticleViewModel()
                    target.inputs.initialFetchTrigger.onNext(())
                    expect(try! target.outputs.featuredArticleItems.toBlocking().first()).to(equal(featuredArticleAPIResponse.result))
                    expect(try! target.outputs.requestStatus.toBlocking().first()).to(equal(APIRequestState.success))
                }
            }
            context("サーバーからの取得処理が失敗した場合") {
                beforeEach {
                    testingDependency.injectIndividualMock(
                        mockInstance: featuredArticleUseCase,
                        protocolName: FeaturedArticleUseCase.self
                    )
                    featuredArticleUseCase.given(
                        .execute(
                            willReturn: Single.error(CommonError.invalidResponse("データの取得に失敗しました。"))
                        )
                    )
                }
                afterEach {
                    testingDependency.removeIndividualMock(
                        protocolName: FeaturedArticleUseCase.self
                    )
                }
                it("viewModel.outputs.featuredArticleItemsが取得データが空配列&viewModel.outputs.requestStatusがAPIRequestState.errorとなること") {
                    let target = FeaturedArticleViewModel()
                    target.inputs.initialFetchTrigger.onNext(())
                    expect(try! target.outputs.featuredArticleItems.toBlocking().first()).to(equal([]))
                    expect(try! target.outputs.requestStatus.toBlocking().first()).to(equal(APIRequestState.error))
                }
            }
        }

        // MARK: - undoAPIRequestStateTriggerを実行した際のテスト

        describe("#undoAPIRequestStateTrigger") {
            context("エラー画面表示からリトライ処理を実施する準備としてAPIRequestStateを.errorから.noneに変更する場合") {
                beforeEach {
                    testingDependency.injectIndividualMock(
                        mockInstance: featuredArticleUseCase,
                        protocolName: FeaturedArticleUseCase.self
                    )
                    featuredArticleUseCase.given(
                        .execute(
                            willReturn: Single.error(CommonError.invalidResponse("データの取得に失敗しました。"))
                        )
                    )
                }
                afterEach {
                    testingDependency.removeIndividualMock(
                        protocolName: FeaturedArticleUseCase.self
                    )
                }
                it("viewModel.outputs.requestStatusがAPIRequestState.noneとなること") {
                    let target = FeaturedArticleViewModel()
                    target.inputs.initialFetchTrigger.onNext(())
                    target.inputs.undoAPIRequestStateTrigger.onNext(())
                    expect(try! target.outputs.requestStatus.toBlocking().first()).to(equal(APIRequestState.none))
                }
            }
        }
    }

    private func getFeaturedArticleAPIResponse() -> FeaturedArticleAPIResponse {

        // JSONファイルから表示用のデータを取得する
        guard let path = Bundle(for: type(of: self)).path(forResource: "featured_article_data", ofType: "json") else {
            fatalError()
        }
        guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else {
            fatalError()
        }
        guard let featuredArticleAPIResponse = try? JSONDecoder().decode(FeaturedArticleAPIResponse.self, from: data) else {
            fatalError()
        }
        return featuredArticleAPIResponse
    }
}

4-1. TOP画面表示前のサインイン処理に関するユニットテスト事例

【1. サインイン処理時のRepository層におけるユニットテスト例】

RequestSigninRepositorySpec.swift
@testable import VisualEffectTraceExample

import Nimble
import Quick
import RxBlocking
import RxSwift
import SwiftyMocky
import XCTest

final class RequestSigninRepositorySpec: QuickSpec {

    // MARK: - Override

    override func spec() {
        
        // MEMO: Testで動かす想定のDIコンテナのインスタンスを生成する
        let testingDependency = DependenciesDefinition()

        let apiRequestManager = APIRequestProtocolMock()
        let target = RequestSigninRepository()

        describe("RequestSigninRepository") {

            // MARK: - requestSigninを実行した際のテスト

            describe("#requestSignin") {

                // MEMO: API通信処理成功時の想定
                context("APIから正常にレスポンスが返却される場合") {
                    let mailAddress: String = "fumiya.sakai@example.com"
                    let rawPassword: String = "testcode1234"
                    let signinSuccessResponse = SigninSuccessResponse(
                        result: "OK",
                        token: "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJmdW1peWFzYWMiLCJleHAiOjE2NTgxMzAyMDB9.UQnRp8gM-qhWN8lfsYIEasluc7MjHjRYjwutLALSr8rzXxaAaaG6cJ7GmDK1ERg068KdAGRoih2-CQFy9B_ibA"
                    )

                    // Mockに差し替えたメソッドが返却する値を定める
                    beforeEach {
                        testingDependency.injectIndividualMock(
                            mockInstance: apiRequestManager,
                            protocolName: APIRequestProtocol.self
                        )
                        apiRequestManager.given(
                            .requestSiginin(
                                mailAddress: .value(mailAddress),
                                rawPassword: .value(rawPassword),
                                willReturn: Single.just(signinSuccessResponse)
                            )
                        )
                    }
                    afterEach {
                        testingDependency.removeIndividualMock(
                            protocolName: APIRequestProtocol.self
                        )
                    }
                    it("正常なレスポンスを返却すること") {
                        expect(
                            try! target.requestSignin(
                                mailAddress: mailAddress,
                                rawPassword: rawPassword
                            ).toBlocking().first()
                        ).to(
                            equal(signinSuccessResponse)
                        )
                    }
                }
            }
        }
    }
}

【2. サインイン処理時のViewModel層におけるユニットテスト例】

SigninViewModelSpec.swift
@testable import VisualEffectTraceExample

import Nimble
import Quick
import RxBlocking
import RxSwift
import SwiftyMocky
import XCTest

final class SigninViewModelSpec: QuickSpec {

    // MARK: - Override

    // MEMO: ViewModelクラス内のInput&Outputの変化が検知できていることを確認する
    override func spec() {

        // MEMO: Testで動かす想定のDIコンテナのインスタンスを生成する
        let testingDependency = DependenciesDefinition()

        let signinRepository = SigninRepositoryMock()
        let applicationUserRepository = ApplicationUserRepositoryMock()

        // MARK: - inputMailAddressTriggerを実行した際のテスト

        describe("#inputMailAddressTrigger") {
            context("メールアドレスを入力した場合") {
                let mailAddress: String = "fumiya.sakai@example.com"
                it("viewModel.outputs.mailAddressへ入力値が反映されること") {
                    let target = SigninViewModel()
                    target.inputs.inputMailAddressTrigger.onNext(mailAddress)
                    expect(try! target.outputs.mailAddress.toBlocking().first()).to(equal(mailAddress))
                }
            }
        }

        // MARK: - inputRawPasswordTriggerを実行した際のテスト

        describe("#inputRawPasswordTrigger") {
            context("パスワードを入力した場合") {
                let rawPassword: String = "testcode1234"
                it("viewModel.outputs.rawPasswordへ入力値が反映されること") {
                    let target = SigninViewModel()
                    target.inputs.inputRawPasswordTrigger.onNext(rawPassword)
                    expect(try! target.outputs.rawPassword.toBlocking().first()).to(equal(rawPassword))
                }
            }
        }

        // MARK: - executeSigninRequestTriggerを実行した際のテスト

        // MEMO: サーバーへ入力内容を送信する場合
        describe("#executeSigninRequestTrigger") {
            context("サーバーへの登録処理が成功した場合") {
                let mailAddress: String = "fumiya.sakai@example.com"
                let rawPassword: String = "testcode1234"
                let signinSuccessResponse = SigninSuccessResponse(
                    result: "OK",
                    token: "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJmdW1peWFzYWMiLCJleHAiOjE2NTgxMzAyMDB9.UQnRp8gM-qhWN8lfsYIEasluc7MjHjRYjwutLALSr8rzXxaAaaG6cJ7GmDK1ERg068KdAGRoih2-CQFy9B_ibA"
                )
                beforeEach {
                    testingDependency.injectIndividualMock(
                        mockInstance: applicationUserRepository,
                        protocolName: ApplicationUserRepository.self
                    )
                    testingDependency.injectIndividualMock(
                        mockInstance: signinRepository,
                        protocolName: SigninRepository.self
                    )
                    signinRepository.given(
                        .requestSignin(
                            mailAddress: .value(mailAddress),
                            rawPassword: .value(rawPassword),
                            willReturn: Single.just(signinSuccessResponse)
                        )
                    )
                }
                afterEach {
                    testingDependency.removeIndividualMock(
                        protocolName: ApplicationUserRepository.self
                    )
                    testingDependency.removeIndividualMock(
                        protocolName: SigninRepository.self
                    )
                }
                it("viewModel.outputs.requestStatusがAPIRequestState.successとなること") {
                    let target = SigninViewModel()
                    target.inputs.inputMailAddressTrigger.onNext(mailAddress)
                    target.inputs.inputRawPasswordTrigger.onNext(rawPassword)
                    target.inputs.executeSigninRequestTrigger.onNext(())
                    expect(try! target.outputs.requestStatus.toBlocking().first()).to(equal(APIRequestState.success))
                }
            }
            context("サーバーへの登録処理が失敗した場合") {
                let mailAddress: String = "fumiya.sakai@example.com"
                let rawPassword: String = "testcode1234"
                beforeEach {
                    testingDependency.injectIndividualMock(
                        mockInstance: applicationUserRepository,
                        protocolName: ApplicationUserRepository.self
                    )
                    testingDependency.injectIndividualMock(
                        mockInstance: signinRepository,
                        protocolName: SigninRepository.self
                    )
                    signinRepository.given(
                        .requestSignin(
                            mailAddress: .value(mailAddress),
                            rawPassword: .value(rawPassword),
                            willReturn: Single.error(CommonError.invalidResponse("入力したパスワードまたはメールアドレスに誤りがあります。"))
                        )
                    )
                }
                afterEach {
                    testingDependency.removeIndividualMock(
                        protocolName: ApplicationUserRepository.self
                    )
                    testingDependency.removeIndividualMock(
                        protocolName: SigninRepository.self
                    )
                }
                it("viewModel.outputs.requestStatusがAPIRequestState.errorとなること") {
                    let target = SigninViewModel()
                    target.inputs.inputMailAddressTrigger.onNext(mailAddress)
                    target.inputs.inputRawPasswordTrigger.onNext(rawPassword)
                    target.inputs.executeSigninRequestTrigger.onNext(())
                    expect(try! target.outputs.requestStatus.toBlocking().first()).to(equal(APIRequestState.error))
                }
            }
        }

        // MARK: - undoAPIRequestStateTriggerを実行した際のテスト

        // MEMO: APIRequestStateを元に戻す場合
        describe("#undoAPIRequestStateTrigger") {
            context("APIリクエスト結果ダイアログ表示後に画面状態を元に戻す場合") {
                it("viewModel.outputs.requestStatusがAPIRequestState.noneとなること") {
                    let target = SigninViewModel()
                    target.inputs.undoAPIRequestStateTrigger.onNext(())
                    expect(try! target.outputs.requestStatus.toBlocking().first()).to(equal(APIRequestState.none))
                }
            }
        }

        // MARK: - clearInputFieldTriggerを実行した際のテスト

        // MEMO: 送信完了した後にTextField値を元に戻す場合
        describe("#clearInputFieldTrigger") {
            context("APIリクエスト結果ダイアログ表示後にTextFieldで入力した変数をクリアする場合") {
                let mailAddress: String = "fumiya.sakai@example.com"
                let rawPassword: String = "testcode1234"
                it("viewModel.outputs.mailAddress及びviewModel.outputs.rawPasswordが空となること") {
                    let target = SigninViewModel()
                    target.inputs.inputMailAddressTrigger.onNext(mailAddress)
                    target.inputs.inputRawPasswordTrigger.onNext(rawPassword)
                    target.inputs.clearInputFieldTrigger.onNext(())
                    expect(try! target.outputs.mailAddress.toBlocking().first()).to(equal(""))
                    expect(try! target.outputs.rawPassword.toBlocking().first()).to(equal(""))
                }
            }
        }
    }
}

4-2. スクロール最下部に到達した際のページネーションを伴うAPIリクエスト処理に関するユニットテスト事例

【1. Repository層におけるユニットテスト例】

@testable import VisualEffectTraceExample

import Nimble
import Quick
import RxBlocking
import RxSwift
import SwiftyMocky
import XCTest

final class RequestItemRepositorySpec: QuickSpec {

    // MARK: - Override
    
    override func spec() {

        // MEMO: Testで動かす想定のDIコンテナのインスタンスを生成する
        let testingDependency = DependenciesDefinition()

        let apiRequestManager = APIRequestProtocolMock()
        let target = RequestItemRepository()

        describe("RequestItemRepository") {

            // MARK: - requestItemDataListを実行した際のテスト

            describe("#requestItemDataList") {

                // MEMO: API通信処理成功時の想定
                context("APIから正常にデータが返却される場合") {
                    let itemAPIResponse1 = getItemAPIResponse(page: 1)
                    let itemAPIResponse2 = getItemAPIResponse(page: 2)
                    let itemAPIResponse3 = getItemAPIResponse(page: 3)
                    let itemAPIResponse4 = getItemAPIResponse(page: 4)

                    // Mockに差し替えたメソッドが返却する値を定める
                    beforeEach {
                        testingDependency.injectIndividualMock(
                            mockInstance: apiRequestManager,
                            protocolName: APIRequestProtocol.self
                        )
                        apiRequestManager.given(
                            .getItemsBy(
                                page: .value(1),
                                willReturn: Single.just(itemAPIResponse1)
                            )
                        )
                        apiRequestManager.given(
                            .getItemsBy(
                                page: .value(2),
                                willReturn: Single.just(itemAPIResponse2)
                            )
                        )
                        apiRequestManager.given(
                            .getItemsBy(
                                page: .value(3),
                                willReturn: Single.just(itemAPIResponse3)
                            )
                        )
                        apiRequestManager.given(
                            .getItemsBy(
                                page: .value(4),
                                willReturn: Single.just(itemAPIResponse4)
                            )
                        )
                    }
                    afterEach {
                        testingDependency.removeIndividualMock(
                            protocolName: APIRequestProtocol.self
                        )
                    }
                    it("StubのJSON値を変換したものをそのまま返却すること(page=1〜4)") {
                        expect(
                            try! target.requestItemDataList(page: 1).toBlocking().first()
                        ).to(
                            equal(itemAPIResponse1)
                        )
                        expect(
                            try! target.requestItemDataList(page: 2).toBlocking().first()
                        ).to(
                            equal(itemAPIResponse2)
                        )
                        expect(
                            try! target.requestItemDataList(page: 3).toBlocking().first()
                        ).to(
                            equal(itemAPIResponse3)
                        )
                        expect(
                            try! target.requestItemDataList(page: 4).toBlocking().first()
                        ).to(
                            equal(itemAPIResponse4)
                        )
                    }
                }
            }
        }
    }
    
    private func getItemAPIResponse(page: Int) -> ItemAPIResponse {

        // JSONファイルから表示用のデータを取得する
        guard let path = Bundle(for: type(of: self)).path(forResource: "item_page\(page)_data", ofType: "json") else {
            fatalError()
        }
        guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else {
            fatalError()
        }
        guard let itemAPIResponse = try? JSONDecoder().decode(ItemAPIResponse.self, from: data) else {
            fatalError()
        }
        return itemAPIResponse
    }
}

【2. ViewModel層におけるユニットテスト例】

@testable import VisualEffectTraceExample

import Nimble
import Quick
import RxBlocking
import RxSwift
import SwiftyMocky
import XCTest

final class ItemsViewModelSpec: QuickSpec {
    
    // MARK: - Override
    
    override func spec() {
        
        // MEMO: Testで動かす想定のDIコンテナのインスタンスを生成する
        let testingDependency = DependenciesDefinition()
        
        let itemRepository = ItemRepositoryMock()
        let recentAnnouncementRepository = RecentAnnouncementRepositoryMock()
        
        // MARK: - initialFetchTriggerを実行した際のテスト
        
        // MEMO: サーバーから表示内容を取得する場合
        describe("#initialFetchTrigger") {
            context("サーバーからの取得処理が成功した場合") {
                let itemAPIResponse1 = getItemAPIResponse(page: 1)
                let announcementDetailAPIResponse = AnnouncementDetailAPIResponse(result: getRecentData())

                // Mockに差し替えたメソッドが返却する値を定める
                beforeEach {
                    testingDependency.injectIndividualMock(
                        mockInstance: itemRepository,
                        protocolName: ItemRepository.self
                    )
                    testingDependency.injectIndividualMock(
                        mockInstance: recentAnnouncementRepository,
                        protocolName: RecentAnnouncementRepository.self
                    )
                    itemRepository.given(
                        .requestItemDataList(
                            page: .value(1),
                            willReturn: Single.just(itemAPIResponse1)
                        )
                    )
                    recentAnnouncementRepository.given(
                        .requestRecentAnnouncementData(
                            willReturn: Single.just(announcementDetailAPIResponse)
                        )
                    )
                }
                afterEach {
                    testingDependency.removeIndividualMock(
                        protocolName: ItemRepository.self
                    )
                    testingDependency.removeIndividualMock(
                        protocolName: RecentAnnouncementRepository.self
                    )
                }
                it("viewModel.outputs.itemsが1ページ分の取得データと一致する&viewModel.outputs.recentAnnouncementが1件分の取得データと一致する") {
                    let target = ItemsViewModel()
                    target.inputs.initialFetchTrigger.onNext(())
                    expect(try! target.outputs.items.toBlocking().first()).to(equal(itemAPIResponse1.result))
                    expect(try! target.outputs.recentAnnouncement.toBlocking().first()).to(equal(announcementDetailAPIResponse.result))
                }
            }
        }

        // MARK: - paginationFetchTriggerを実行した際のテスト
        
        // MEMO: サーバーから表示内容を取得する場合
        describe("#paginationFetchTrigger") {
            context("サーバーからの取得処理が成功した場合") {
                let itemAPIResponse1 = getItemAPIResponse(page: 1)
                let itemAPIResponse2 = getItemAPIResponse(page: 2)
                let itemAPIResponse3 = getItemAPIResponse(page: 3)
                let itemAPIResponse4 = getItemAPIResponse(page: 4)
                let announcementDetailAPIResponse = AnnouncementDetailAPIResponse(result: getRecentData())

                // Mockに差し替えたメソッドが返却する値を定める
                beforeEach {
                    testingDependency.injectIndividualMock(
                        mockInstance: itemRepository,
                        protocolName: ItemRepository.self
                    )
                    testingDependency.injectIndividualMock(
                        mockInstance: recentAnnouncementRepository,
                        protocolName: RecentAnnouncementRepository.self
                    )
                    itemRepository.given(
                        .requestItemDataList(
                            page: .value(1),
                            willReturn: Single.just(itemAPIResponse1)
                        )
                    )
                    itemRepository.given(
                        .requestItemDataList(
                            page: .value(2),
                            willReturn: Single.just(itemAPIResponse2)
                        )
                    )
                    itemRepository.given(
                        .requestItemDataList(
                            page: .value(3),
                            willReturn: Single.just(itemAPIResponse3)
                        )
                    )
                    itemRepository.given(
                        .requestItemDataList(
                            page: .value(4),
                            willReturn: Single.just(itemAPIResponse4)
                        )
                    )
                    recentAnnouncementRepository.given(
                        .requestRecentAnnouncementData(
                            willReturn: Single.just(announcementDetailAPIResponse)
                        )
                    )
                }
                afterEach {
                    testingDependency.removeIndividualMock(
                        protocolName: ItemRepository.self
                    )
                    testingDependency.removeIndividualMock(
                        protocolName: RecentAnnouncementRepository.self
                    )
                }
                it("viewModel.outputs.itemsが1ページ分〜4ページ分の取得データと一致する&viewModel.outputs.recentAnnouncementが1件分の取得データと一致する") {
                    let target = ItemsViewModel()
                    target.inputs.initialFetchTrigger.onNext(())
                    for _ in 2...4 {
                        target.inputs.paginationFetchTrigger.onNext(())
                    }
                    let expected = itemAPIResponse1.result
                    + itemAPIResponse2.result
                    + itemAPIResponse3.result
                    + itemAPIResponse4.result
                    expect(try! target.outputs.items.toBlocking().first()).to(equal(expected))
                    expect(try! target.outputs.recentAnnouncement.toBlocking().first()).to(equal(announcementDetailAPIResponse.result))
                }
            }
            context("サーバーからの取得処理が失敗した場合") {
                let itemAPIResponse1 = getItemAPIResponse(page: 1)
                let announcementDetailAPIResponse = AnnouncementDetailAPIResponse(result: getRecentData())
                beforeEach {
                    testingDependency.injectIndividualMock(
                        mockInstance: itemRepository,
                        protocolName: ItemRepository.self
                    )
                    testingDependency.injectIndividualMock(
                        mockInstance: recentAnnouncementRepository,
                        protocolName: RecentAnnouncementRepository.self
                    )
                    itemRepository.given(
                        .requestItemDataList(
                            page: .value(1),
                            willReturn: Single.just(itemAPIResponse1)
                        )
                    )
                    itemRepository.given(
                        .requestItemDataList(
                            page: .value(2),
                            willReturn: Single.error(CommonError.invalidResponse("データの取得に失敗しました。"))
                        )
                    )
                    recentAnnouncementRepository.given(
                        .requestRecentAnnouncementData(
                            willReturn: Single.just(announcementDetailAPIResponse)
                        )
                    )
                }
                afterEach {
                    testingDependency.removeIndividualMock(
                        protocolName: ItemRepository.self
                    )
                    testingDependency.removeIndividualMock(
                        protocolName: RecentAnnouncementRepository.self
                    )
                }
                it("viewModel.outputs.itemsが1ページ分の取得データと一致する&viewModel.outputs.recentAnnouncementが1件分の取得データと一致する") {
                    let target = ItemsViewModel()
                    target.inputs.initialFetchTrigger.onNext(())
                    target.inputs.paginationFetchTrigger.onNext(())
                    expect(try! target.outputs.items.toBlocking().first()).to(equal(itemAPIResponse1.result))
                    expect(try! target.outputs.recentAnnouncement.toBlocking().first()).to(equal(announcementDetailAPIResponse.result))
                }
            }
        }

        // MARK: - pullToRefreshTriggerを実行した際のテスト

        // MEMO: PullToRefreshでサーバーから表示内容を取得する場合
        describe("#pullToRefreshTrigger") {
            context("サーバーからの取得処理が成功した場合") {
                let itemAPIResponse1 = getItemAPIResponse(page: 1)
                let itemAPIResponse2 = getItemAPIResponse(page: 2)
                let itemAPIResponse3 = getItemAPIResponse(page: 3)
                let itemAPIResponse4 = getItemAPIResponse(page: 4)
                let announcementDetailAPIResponse = AnnouncementDetailAPIResponse(result: getRecentData())

                // Mockに差し替えたメソッドが返却する値を定める
                beforeEach {
                    testingDependency.injectIndividualMock(
                        mockInstance: itemRepository,
                        protocolName: ItemRepository.self
                    )
                    testingDependency.injectIndividualMock(
                        mockInstance: recentAnnouncementRepository,
                        protocolName: RecentAnnouncementRepository.self
                    )
                    itemRepository.given(
                        .requestItemDataList(
                            page: .value(1),
                            willReturn: Single.just(itemAPIResponse1)
                        )
                    )
                    itemRepository.given(
                        .requestItemDataList(
                            page: .value(2),
                            willReturn: Single.just(itemAPIResponse2)
                        )
                    )
                    itemRepository.given(
                        .requestItemDataList(
                            page: .value(3),
                            willReturn: Single.just(itemAPIResponse3)
                        )
                    )
                    itemRepository.given(
                        .requestItemDataList(
                            page: .value(4),
                            willReturn: Single.just(itemAPIResponse4)
                        )
                    )
                    recentAnnouncementRepository.given(
                        .requestRecentAnnouncementData(
                            willReturn: Single.just(announcementDetailAPIResponse)
                        )
                    )
                }
                afterEach {
                    testingDependency.removeIndividualMock(
                        protocolName: ItemRepository.self
                    )
                    testingDependency.removeIndividualMock(
                        protocolName: RecentAnnouncementRepository.self
                    )
                }
                it("viewModel.outputs.itemsが1ページ分の取得データと一致する&viewModel.outputs.recentAnnouncementが1件分の取得データと一致する") {
                    let target = ItemsViewModel()
                    target.inputs.initialFetchTrigger.onNext(())
                    for _ in 2...4 {
                        target.inputs.paginationFetchTrigger.onNext(())
                    }
                    target.inputs.pullToRefreshTrigger.onNext(())
                    expect(try! target.outputs.items.toBlocking().first()).to(equal(itemAPIResponse1.result))
                    expect(try! target.outputs.recentAnnouncement.toBlocking().first()).to(equal(announcementDetailAPIResponse.result))
                }
            }
        }
    }

    private func getItemAPIResponse(page: Int) -> ItemAPIResponse {
        
        // JSONファイルから表示用のデータを取得する
        guard let path = Bundle(for: type(of: self)).path(forResource: "item_page\(page)_data", ofType: "json") else {
            fatalError()
        }
        guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else {
            fatalError()
        }
        guard let itemAPIResponse = try? JSONDecoder().decode(ItemAPIResponse.self, from: data) else {
            fatalError()
        }
        return itemAPIResponse
    }

    private func getRecentData() -> AnnouncementEntity {

        // JSONファイルから表示用のデータを取得する
        guard let path = Bundle(for: type(of: self)).path(forResource: "announcement_data", ofType: "json") else {
            fatalError()
        }
        guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else {
            fatalError()
        }
        guard let announcementDataList = try? JSONDecoder().decode(Array<AnnouncementEntity>.self, from: data) else {
            fatalError()
        }
        guard let recentData = announcementDataList.last else {
            fatalError()
        }
        return recentData
    }
}

5. まとめ

元々は2年前に業務での調査がきっかけとなったものではありますが、今回の様な簡単なバックエンド側との実装と連携をする前提となる処理、条件に応じた画面表示切り替え処理、そして少し画面構造やUI実装に工夫が必要なものをある程度盛り込んだ形でのアプリサンプル実装と組み合わせる事で、実際の業務内で利用することを考える際に非常に役に立ったと改めて感じております。

検証対象の画面に近しい状態や環境を準備するのは時によって難しい場合でも、できるだけ自分でイメージができそうな所までサンプルとして整えていく事や、考えていく上で要点となりそうな部分を自分なりで構いませんのでまとめておく習慣をつけていくと良いのではないかと思います。

特にテストコードの事例を解説している部分については、アプリ側だけで完結する処理ではなく、サーバー側のデータや処理の流れについても押さえておく必要がある処理になります。最初はなかなか大変ではあるかもしれませんが、バックエンド側の処理の事についても関心を持つ様にしておくと、問題の切り分けに加えてより良い設計を考えていく際の大きなヒントとなり得る可能性も大いにありますので、アプリに関係のある処理や関連のあるドメインの知識を積極的に関心を持つ様にする事で、よりアプリ開発が楽しくなるという点もアプリ開発の大事な要素の1つである様に思います。

9
4
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
9
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?