[Swift] Mockolo/CombineでUnitTestを書いてみる


## はじめに

お仕事でMockoloを使っていて、Combineで書かれたコードのユニットテストを書くことがあるので、
どうやって書くのかを備忘録を兼ねてお伝えしたいと思います。

この記事のサンプルコードはこちら: https://github.com/Take111/GithubRepoSearcher

## Mockoloとは？

モック生成をしてくれる[ライブラリ](https://github.com/uber/mockolo)です。
プロジェクトへの導入方法は、uhooiさんの[こちらの記事](https://qiita.com/uhooi/items/0226a28190aea6392806)がとても参考になります。


## テスト対象のコード

サンプルプロジェクトは、以下のよくあるGithubのレポジトリを取得するものです。
<img alt = "repomovie.gif" src = "https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/393442/9d151ae7-5cfc-1a57-da45-6510132cef84.gif" width = 300>

こちらのサンプルはMVVMで書いていて、
APIから情報を取得するところは、UseCase、APIManager(実際にURLを叩くところ)に分けています。

コードの流れとしては、
ViewControllerから文字が入力されると
ViewModelにそれが伝わり、UseCaseに渡され
UseCaseがAPIManagerに伝え、レポジトリの情報が取得できます。

色々省略しますが、コードだとこんな感じ

```SearchRepositoryUseCase.swift

protocol SearchRepositoryUseCase {
    // 入力された文字でリポジトリを返却する
    func searchRepository(word: String) -> AnyPublisher<Repositories, Error>
}
```

```RepoListViewModel.swift

final class RepoListViewModel {

   private let useCase: SearchRepositoryUseCase

   // ViewModelのイベントをView側に伝える
   var effect = PassthroughSubject<RepoListViewModelEffect, Never>()

   init(useCase: SearchRepositoryUseCase) {
      self.useCase = useCase
   }

   func onAppear() {

      // Viewから入力された文字が流れてくる
      searchText
         .sink { [weak self] value in 
            self.requestRepository(word: value)                    
         }.store(in: &cancellables)
   }

   private func requestRepository(word: String) {
      useCase.searchRepository(word: word)
         .sink{ [weak self] completion in
            switch completion {
               case .finished: print()
               case .failure: 
　　　　　　　　　　self.effect.send(.error)　// <- これでエラーを伝えてあげる
            }
         } receiveValue: { [weak self] value in
            // ~~~ 省略
         }.store(in: &cancellables)
   }
```

```RepoListViewModelEffect.swift

enum RepoListViewModelEffect {
   case error
}
```

ViewModelで起きたイベント(エラーハンドリング)は、RepoListViewModelEffectを通して、View側に伝えています。
なので、レポジトリの取得に失敗した場合は、`effec.send(.error)`でエラーを伝えてあげます。

今回は故意にエラーを発生させて、ちゃんとエラーハンドリングができているかテストしたいと思います。


## モックに指定

ViewModelに対するテストになるので、UseCaseのモックをMockoloで作成します。
ちゃんとセットアップできていると、以下のように書くと、モックファイルが生成されます。

モック指定するのはこんな感じ

```SearchRepositoryUseCase.swift

/// @mockable <-モック指定する
protocol SearchRepositoryUseCase {
    // 入力された文字でリポジトリを返却する
    func searchRepository(word: String) -> AnyPublisher<Repositories, Error>
}
```

すると、以下のコードが生成されます。


```MockResults.swift

import Combine
import Foundation
@testable import GithubRepoSearcher


class SearchRepositoryUseCaseMock: SearchRepositoryUseCase {
    init() { }

    private(set) var searchRepositoryCallCount = 0
    var searchRepositoryHandler: ((String) -> (AnyPublisher<Repositories, Error>))?
    func searchRepository(word: String) -> AnyPublisher<Repositories, Error> {
        searchRepositoryCallCount += 1
        if let searchRepositoryHandler = searchRepositoryHandler {
            return searchRepositoryHandler(word)
        }
        fatalError("searchRepositoryHandler returns can't have a default value thus its handler must be set")
    }
}

```

## 実際にテストコードを書いてみる

結論を書くと、こんな感じでかけます。

```RepoListViewModelTests.swift

class RepoListViewModelTests: XCTestCase {

    // 先程のモックを指定してあげる
    var mockSearchRepositoryUseCase: SearchRepositoryUseCaseMock!
    var viewModel: RepoListViewModel!
    var cancellables = Set<AnyCancellable>()

    override func setUpWithError() throws {
        mockSearchRepositoryUseCase = SearchRepositoryUseCaseMock()
        viewModel = RepoListViewModel(useCase: mockSearchRepositoryUseCase)
    }

    func testSuccessSearchRepo() {
        mockSearchRepositoryUseCase.searchRepositoryHandler = { (_) -> AnyPublisher<Repositories, Error> in
            // エラーが発生するPublisherをHandlerに渡す
            Future<Repositories, Error> { promise in
                promise(.failure(NSError(domain: "", code: 0, userInfo: [:])))
            }.eraseToAnyPublisher()
        }
        viewModel.onAppear()
        
        viewModel.effect.sink { value in
            switch value {
            case .error:
                // エラーが発生した場合は、ここにくる
                XCTAssert(true)
            }
        }.store(in: &cancellables)
        
        // hogeを入力する
        viewModel.inputs.searchText.send("hoge")
    }
}


```

実際にテストをXcodeで動かしてみると、テスト成功と出ます！


## 終わり

今回はエラーハンドリングだけでしたが、
入力のバリデーションだったり、ローディング状態の可否などもっと色々書けそうですね。

テストはまだまだ書いたことがないので、もし何かあれば、ご指摘頂ければ、幸いです🙇🏻‍♂️

## 参考記事

[Mockolo](https://github.com/uber/mockolo)
[Swiftのモック生成ライブラリ「Mockolo」のセットアップ&操作方法](https://qiita.com/uhooi/items/0226a28190aea6392806)

***

普段は東京でiOSエンジニアとして活動しています。
個人開発のアプリもリリースしました。

[Twitter](https://twitter.com/maxmax2525425)