Combineで実装したViewModeのテストをExpectationを使って書いてみた

## はじめに
`Combine`を使って実装したViewModelのテストを行う際に、私が詰まったところとその解決策を調べてみました。

## サンプルアプリ
使用するサンプルアプリは以下のようなアプリで、画面上部のTextFieldにキーワードを入力すると、関連するQiitaの記事が表示されるというアプリになっています。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3767951/0c40dc41-8eac-7289-3f15-f91cca14b2cc.png" width=200> <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3767951/ef02709e-8dc0-7061-7163-34d1d58e2663.png" width=200>

また、下のコードがViewModelになっています。

```swift: ArticleConfig
class ArticleConfig: ObservableObject {
    @Published var articles: [Article] = []
    @Published var searchText: String = ""
    @Published var viewFlow: ViewFlow = .beforSearch
    @Published var errorText: String = ""
    @Published var isShowErrorAlert = false
    
    private var cancellables = Set<AnyCancellable>()
    private let apiRepository: APIRepositoryProtocol
    
    init(apiRepository: APIRepositoryProtocol = APIRepository()) {
        self.apiRepository = apiRepository
        
        $searchText
            .debounce(for: .seconds(0.5), scheduler: RunLoop.main)
            .removeDuplicates()
            .sink { searchText in
                Task {
                    await self.fetchArticles(query: searchText)
                }
            }
            .store(in: &cancellables)
    }
    
    @MainActor
    func fetchArticles(query: String) async {
        if query == "" {
            self.viewFlow = .beforSearch
            return
        }
        do {
            viewFlow = .loading
            let articles = try await apiRepository.fetch(query: query)
            self.articles = articles
            if self.articles.count == 0 {
                self.viewFlow = .empty
            } else {
                self.viewFlow = .complete
            }
        } catch {
            let error = error as? APIError ?? APIError.unknown
            self.articles = []
            viewFlow = .beforSearch
            errorText = error.title
            isShowErrorAlert = true
        }
    }
    
    func didTapCancelButton() {
        searchText = ""
    }
    
    func didTapErrorAlertButton() {
        isShowErrorAlert = false
    }
}
```

## 詰まったところ
このサンプルアプリのViewModelの主な機能は、`searchText`にキーワードが入力されると`fetchArticles`が実行され、`articles`に取得した記事を代入するという機能なのですが、この機能をテストする方法について、私は以下のようなテストを書いていました。

```swift:StubAPIRepositoy
class StubAPIRepository: APIRepositoryProtocol {
    var result: APIError?
    let articles = [
        Article(
            id: "1",
            title: "HelloSwift",
            url: "apple.com",
            user: User(name: "person", profileImageURL: nil))
    ]
    
    func fetch(query: String) async throws -> [Article] {
        if let error = result {
            throw error
        }
        return articles
    }
}
```

```swift:ArticleSearcherTests
final class ArticleSearcherTests: XCTestCase {
    var apiRepository: StubAPIRepository!
    var config: ArticleConfig!

    override func setUp() {
        super.setUp()
        apiRepository = StubAPIRepository()
        config = ArticleConfig(apiRepository: apiRepository)
    }
    
    func testFetchArticles() async {
        config.searchText = "Swift"
        sleep(1)
        XCTAssertEqual(self.config.articles[0].title, "HelloSwift")
    }
}
```

ViewModelで`.debounce(for: .seconds(0.5), scheduler: RunLoop.main)`と書いており、0.5秒間隔で`searchText`の変更を受け取るため、`searchText` の変更から`sleep(1)`をして1秒待つようにしたのですが、これではテストが完了するまでに必要以上に時間がかかってしまいます。

## 解決策
今回は`expectation`を使って`sleep()`を使わずにテストを書く方法について調べてみました。
以下は実際に`expectation`を使って書き換えたコードになります。

```swift:ArticleSearcherTests
final class ArticleSearcherTests: XCTestCase {
    var apiRepository: StubAPIRepository!
    var config: ArticleConfig!

    override func setUp() {
        super.setUp()
        apiRepository = StubAPIRepository()
        config = ArticleConfig(apiRepository: apiRepository)
    }
    
    func testFetchArticles() async {
        let fetchExpectation = self.expectation(description: "Wait for fetch articles")
        config.$viewFlow
            .sink(receiveValue: { [weak self] in
                switch $0 {
                case .beforSearch:
                    XCTAssertEqual(self?.config.articles.count, 0)
                case .loading:
                    XCTAssertEqual(self?.config.articles.count, 0)
                case .complete:
                    XCTAssertEqual(self?.config.articles[0].title, "HelloSwift")
                    fetchExpectation.fulfill()
                case .empty:
                    XCTFail()
                }
            })
            .store(in: &cancellables)
        config.searchText = "Swift"
        await fulfillment(of: [fetchExpectation], timeout: 1, enforceOrder: false)
    }
}
```
fetchが完了すると`viewFlow`というプロパティーが変更されることを利用し、`viewFlow`の状態に応じて適切な結果を返すことを確認するテストになっています。
また、`fulfillment(of: [fetchArticlesExpectation], timeout: 1, enforceOrder: false)`の引数について説明します。

#### of: [fetchArticlesExpectation]

これは、期待している結果（expectation）のリストです。ここでは、fetchExpectation という1つの期待している結果を含む配列を渡しています。
#### timeout: 1

これはタイムアウトの期間（秒）を指定します。この場合、1秒間待機します。1秒以内に期待している結果が得られなかった場合、テストが失敗します。
タイムアウトを指定することで、非同期処理が無限に続かないようにしています。
#### enforceOrder: false

これは、期待している結果が複数ある場合に、その順序を強制するかどうかを指定します。
false を指定すると、期待の完了順序が厳密に順番通りでなくてもよいことを意味しています。

## おわりに
`sleep`ではなく`expectation`を使うと、より効率的なテストコードが書けたと思います。