🐾

async/awaitで通信中のテストを書く

2023/02/03に公開

はじめに

async/awaitの登場により、非同期処理を同期処理のように書けるようになりました。
その点はすごく便利なのですが、次の行に進んだ時点で、非同期処理処理が終了しているので、
処理中のテストが書けずにいました。
クロージャを使うことで、解決できたのでその方法を紹介します。

準備部分が長いので結論が見たい方は、テストを書くまでジャンプしてください。

対象者

  • ユニットテストが分かる人
  • async/awaitの書き方が分かる人

サンプルアプリの準備

リポジトリ
詳細は省略しますが、GitHubのリポジトリを検索するよくあるサンプルアプリを叩き台にします。

準備

ViewModelの作成

@MainActor
class SearchRepositoryViewModel {

    private var model: SearchRepositoryModelable

    // Output
    @Published var errorMessage = ""
    @Published var repositories = [Repository]()
    @Published var isLoading = false

    private var searchText = ""

    init(model: SearchRepositoryModelable) {
        self.model = model
    }
}

// Input
extension SearchRepositoryViewModel {
    func onSearchTextChanged(_ text: String) {
        self.searchText = text
    }

    func onSearchButtonTapped() async {
        if isLoading {
            return
        }

        isLoading = true
        defer {
            isLoading = false
        }

        do {
            let repositories = try await model.fetchRepositries(keyword: searchText)
            self.repositories = repositories
        } catch {
            errorMessage = error.localizedDescription
        }

    }
}

@Publishedをつけておくと、Publisherとして取り出すことができます。
(例)
viewModel.$isLoading.sink { }

Modelの作成

protocol SearchRepositoryModelable {
    func fetchRepositries(keyword: String) async throws -> [Repository]
}

class SearchRepositoryModel: SearchRepositoryModelable {

    private let baseURL = "https://api.github.com/search/repositories"

    func fetchRepositries(keyword: String) async throws -> [Repository] {
        let urlString = self.baseURL + "?q=\(keyword)"
        guard let url = URL(string: urlString) else {
            throw GitHubAPIError.invailedURL
        }
        let request = URLRequest(url: url)

        do {
            let (data, response) = try await URLSession.shared.data(for: request)
            // ステータスコードチェック
            let httpResponse = response as! HTTPURLResponse
            if httpResponse.statusCode != 200 {
                throw GitHubAPIError.any
            }

            // デコード
            let decoder = JSONDecoder()
            do {
                let result = try decoder.decode(
                    GitHubAPIGetRepositoriesResult.self,
                    from: data
                )

                return result.items
            } catch {
                throw GitHubAPIError.parse
            }
        } catch {
            throw GitHubAPIError.any
        }
    }
}

ModelをMockする

class MockSearchRepositoryModel: SearchRepositoryModelable {
    func fetchRepositries(keyword: String) async -> [SearchRepositoryApp.Repository] {
        return []
    }
}

テストクラスを作る

@MainActor
final class SearchRepositoryViewModelTests: XCTestCase {

    private var viewModel: SearchRepositoryViewModel!
    private var model: MockSearchRepositoryModel!

    override func setUpWithError() throws {
        model = MockSearchRepositoryModel()
        viewModel = SearchRepositoryViewModel(model: model)
    }

    override func tearDownWithError() throws {
    }

    // この関数の中に書いていく
    func testOnSearchButtonClicked() async throws {
    }
}

テストケース

通信中のテストが書きたいので、テストケースは下記です、

  • 通信前は、isLoadingがfalseであること
  • 通信中は、isLoadingがtrueであること
  • 通信後は、isLoadingがfalseであること

テストを書く

状況説明

func testOnSearchButtonClicked() async throws {

    // 通信前
    XCTAssertFalse(viewModel.isLoading)

    await model.fetchRepositries("")
    
    // 通信後
    XCTAssertFalse(viewModel.isLoading)
}

このように通信前後しか書けない

書いていく!

まず、MockしたModelクラスを書き換えます。

class MockSearchRepositoryModel: SearchRepositoryModelable {
    var onFetchRepositoriesCalled: (() -> Void)?
    var fetchRepositoriesResult: [Repository]!

    func fetchRepositries(keyword: String) async throws -> [SearchRepositoryApp.Repository] {
        onFetchRepositoriesCalled?()

        return await withCheckedContinuation {
            $0.resume(returning: fetchRepositoriesResult)
        }
    }
}

withCheckedContinuationを使うと任意のタイミングで完了通知を投げることができます。
クロージャで書かれた関数をConcurrency対応させる時に使われますね。

完了通知を投げる前に、外部から受け取ったクロージャを実行し、
そこで通信中のテストを行います。

func testOnSearchButtonClicked() async throws {
    // 初期値
    XCTAssertFalse(viewModel.isLoading)

    // 通信中
    model.onFetchRepositoriesCalled = {
        XCTAssertTrue(self.viewModel.isLoading)
    }
    model.fetchRepositoriesResult = []

    await viewModel.onSearchButtonTapped()

    // 通信後
    XCTAssertFalse(self.viewModel.isLoading)
}

色々試行錯誤しましたが、サクッと書くことができました。

GitHubで編集を提案
カラビナテクノロジー デベロッパーブログ

Discussion