🐾
async/awaitで通信中のテストを書く
はじめに
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)
}
色々試行錯誤しましたが、サクッと書くことができました。

株式会社 カラビナテクノロジーは「命綱や支点を素早く確実に繋ぐカラビナ。そんなカラビナのような役割をテクノロジーで実現したい」という想いのもと、福岡で設立。 主にシステム開発・アプリ開発・ Webサイト制作を行っています。採用情報→karabiner.tech/recruit/requirements/
Discussion