【今日からできる】SwiftUI/Combineのユニットテスト

こちらの記事は、[YUMEMI.swift #9 ~テストと自動化~](https://yumemi.connpass.com/event/183635/)で発表した内容をもとに書いたものです。

サンプルプロジェクトは[こちら](https://github.com/turara/SwiftUIAndCombineUnitTestsSample)にあります。

# SwiftUIには公式のテスト用APIが用意されていない

UIKitなら例えば以下のようにテストを行うことができます。

```swift
let viewController = ViewController()

// viewDidLoadを呼ぶためのメソッド
viewController.loadViewIfNeeded()

// subviewsからボタンを検索
let button = viewController
    .view
    .subviews
    .first(where: { $0 is UIButton })

// 存在確認
XCTAssertNotNil(button)

// タップイベントを発火
button!.sendActions(for: .touchUpInside)
```

# SwiftUIのViewがユニットテストできないなんて誰が言ったの？

検索してみると……

[Who said we cannot unit test SwiftUI views?](https://nalexn.github.io/swiftui-unit-testing/)[^1]

[^1]: 階層化されたSwiftUIのViewから、どうやって中身を取り出していくかという苦労が綴られていて面白いです。

という記事がヒットしました。どうやらこの記事を書いた人が[ViewInspector](https://github.com/nalexn/ViewInspector)というテスト用のフレームワークを作成してくれているそうです。

現状では、SwiftUIのユニットテストができる唯一無二のフレームワークで、公式のフレームワークが出るまではスタンダードになっていきそうな予感がします。

# ViewInspector

こんな感じのAPIが用意されています。

```swift
// ボタンタップを実行
let button = try view
    .inspect()
    .hStack()
    .button(1)
try button.tap()

// onAppearをシミュレート
let list = try view
    .inspect()
    .list()
try list[5]
    .view(RowItemView.self)
    .callOnAppear()
```

# 実際に使ってみる

[サンプルのプロジェクト](https://github.com/turara/SwiftUIAndCombineUnitTestsSample)はよくある、GitHubのリポジトリを検索してくるやつです。

<img width="300px" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/499615/00e8d083-8c54-329b-23be-d39673942bec.png">

こんな感じのUIで、Listの下に"Search More Swift"というButtonが表示されています。これが表示されているかどうかをユニットテストで確認していきます。

# 準備：DIのためにprotocol経由で参照を持たせる

テストしやすいようにViewにprotocol経由でViewModelを持たせます。

```swift
// View
struct SearchRepositoriesView<ViewModel: ViewModelProtocol>: View {
    // ObservableObject protocolがassociatedtypeを持っているため
    // protocolのまま型指定できないので、Generic Typeを利用する
    @ObservedObject private var viewModel: ViewModel
    ...
}

// ViewModel
protocol ViewModelProtocol: ObservableObject {
    var lastQuery: String? { get }
    var state: ViewModelState { get }
    // Binding<Bool>として使うのでsetが必要
    var shouldShowErrorAlert: Bool { get set }
    var repositoryList: [GitHubRepo] { get }
    var moreSearchResultsExist: Bool { get }
    func send(event: SearchRepositoriesViewModelEvent)
}
```
# ViewInspectorを使って要素を取得する

注意点として、「子要素を複数持つ可能性のある要素」から子要素を取得するときには、子要素（例えばVStack）に対応したメソッド（`vStack`）に、そのindex（親要素の中で何番目にあるか）を渡す必要があります。

```swift:テストコード
// 対象のViewにInspectable protocolを追加
extension SearchRepositoriesView: Inspectable {}
...

// ViewModelのスタブを作成
stubViewModel = SearchRepositoriesViewModelStub()

// 依存性注入
view = SearchRepositoriesView(viewModel: stubViewModel)

stubViewModel.lastQuery = "Swift"
stubViewModel.state = .ready
stubViewModel.repositoryList = ...
stubViewModel.moreResultsExist = true

let text = try view!.inspect()
    .zStack()
    .vStack(0) // Zstackのindex:0の位置
    .list(1)   // Vstackのindex:1の位置
    .button(1) // Listのindex:1の位置
    .text()

XCTAssertEqual(try text.string(), "Search More Swift")
```

```swift:Viewの構造
ZStack {
    VStack { // index: 0
        SearchBar(...) // index: 0
        List { // index: 1
            ForEach(...) { ... } // index: 0
            if ... {
                Text("No results found...")
            } else if self.viewModel.moreResultsExist {
                Button(action: { ... }) { // index: 1
                    if self.isLoading {
                        ActivityIndicator()
                    } else {
                        // Target!!!
                        (Text("Search More ") +
                            Text(self.lastQuery)
                                .font(.headline)
                                .bold()
                                .italic()
                            )
                    }
                }
            }
        }
    }
    if isLoading && isListEmpty {
        ActivityIndicatorWithRectangle()
    }
}
```

## カスタムViewの存在確認

今度はTextではなく、カスタムViewが表示されていることを確認します。
先ほどの"Search More Swift" Buttonをタップすると、インディケーターが周りだします。

<img width="300px" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/499615/e0ae5e4a-bb3c-d0b3-d92e-1c42518536cc.png">


```swift:テストコード
stubViewModel.state = .loading
stubViewModel.repositoryList = ...
stubViewModel.moreResultsExist = true

let button = try view!.inspect()
    .zStack()
    .vStack(0) // Zstackのindex:0の位置
    .list(1)   // Vstackのindex:1の位置
    .button(1) // Listのindex:1の位置

// カスタムViewを取り出す
// viewメソッドにstructのselfを渡したあと、actualViewメソッドで
// ActivityIndicatorのインスタンスが取得できる
XCTAssertNoThrow(
  try button
      .view(ActivityIndicator.self)
      .actualView()
)

// （おまけ）Textが入っていないことも確認
XCTAssertThrowsError(try button.text())
```

```swift:ActivityIndicator
struct ActivityIndicator: UIViewRepresentable {
    func makeUIView(
        context: Context
    ) -> UIActivityIndicatorView {
        let indicator
            = UIActivityIndicatorView()
        indicator.startAnimating()
        return indicator
    }
    ...
}
```

```swift:Buttonの内部（再掲）
Button(action: { ... }) {
    if self.isLoading {
        ActivityIndicator() // Target!
    } else {
        (Text("Search More ") +
            Text(self.lastQuery)
                .font(.headline)
                .bold()
                .italic()
            )
    }
}
```

# SwiftUIのユニットテスト → やりやすい

**SwiftUIではViewが状態の関数であることが強制される**おかげで、**何をテストすべきかがわかりやすいため**、ユニットテストが書きやすいです。

# Combineのユニットテスト

Combineのユーティリティフレームワークである[Entwine](https://github.com/tcldr/Entwine)を使います。

テスト補助用のクラスとしてTestSchedulerが用意されているので、これを使ってViewModelのテストをやってみます。

```swift:ViewModel
protocol ViewModelProtocol: ObservableObject {
    // 最後に使ったクエリを取得できる
    var lastQuery: String? { get }
    // View側からイベント通知をするためのメソッド
    func send(event: SearchRepositoriesViewModelEvent)
    ...
}

class ViewModel: ViewModelProtocol {
    // サーバーとの通信を行うModel
    private let reposModel: GitHubReposModelProtocol

    // 最後に使ったクエリは@Publishedをつけて、SwiftUI Viewの監視対象
    @Published private(set) var lastQuery: String?
    ...
}
```

スケジューラーにイベントの通知をスケジューリングしていきます。

```swift
stubModel = GitHubReposModelStub()
viewModel = SearchRepositoriesViewModel(model: stubModel)

let scheduler = TestScheduler.init(initialClock: 0)
// t = 100に "Swift" という queryを発行
scheduler.schedule(after: 100) {
    self.viewModel.send(event: .didSearchButtonClicked(query: "Swift"))
}
// t = 200に "Python" という queryを発行
scheduler.schedule(after: 200) {
    self.viewModel.send(event: .didSearchButtonClicked(query: "Python"))
}
```

テスト用のsubscriberを作成して、テスト対象であるviewModel.$lastQueryにsubscribeします。

```swift
// テスト用のsubscriberを作成
let subscriber = scheduler.createTestableSubscriber(String?.self, Never.self)
viewModel.$lastQuery.subscribe(subscriber)
```

スケジューラーを起動すると、テスト用のsubscriberがイベントを受け取り、TestSequenceとして記録してくれます。

```swift
// スケジューラを起動
scheduler.resume()

let expected: TestSequence<String?, Never> = [
    (000, .subscription),
    (000, .input(nil)),
    (100, .input("Swift")),
    (200, .input("Python")),
]
// 受け取ったイベントと期待されるイベントを比較
XCTAssertEqual(subscriber.recordedOutput, expected)
```

# おわりに

意外と簡単にユニットテストが書けたのではないでしょうか？
どんどんテストを書いていきましょう！

# 参考

[UIViewControllerをXCTestでUnitテストする](https://qiita.com/_ha1f/items/3d4a4b9c18d1461cab50)
[Who said we cannot unit test SwiftUI views?](https://nalexn.github.io/swiftui-unit-testing/)
[ViewInspector](https://github.com/nalexn/ViewInspector)
[Entwine](https://github.com/tcldr/Entwine)