🗂
非同期処理を含んだViewStateのユニットテスト
概要
- API通信などの非同期処理を含んだ
ViewState
(=ViewModel
と同義)のユニットテストを考えていきます - また今回は非同期処理をキャンセルできるようにして、このユニットテストも実装します
GitHub
- 記事内で省略したコードは下記を参照ください
参考
- iOSDC Japan 2022: Swift Concurrency時代のiOSアプリの作り方 / Yuta Koshizawa - YouTube
- swift-concurrency-extrasでasync関数のawait前後の状態をTestする #Swift - Qiita
- Unit testing async/await Swift code - SwiftLee
- Task.sleep() vs. Task.yield(): The differences explained
- [Swift] 不安定なConcurrencyのテストをContinuationとClockで解決する
APIClientの実装
- 下記の通りランダムな数字を返すようなAPIClientを実装します
- 通信部分はダミーでSleepを入れています
- また後ほどStubを作成するのでProtocolを併せて宣言しています
// MARK: - Protocol
protocol APIClientProtocol: Actor {
func fetchRandomNumber() async throws -> Int
}
// MARK: - Implement
final actor APIClient: APIClientProtocol {
static let shared: APIClient = .init()
func fetchRandomNumber() async throws -> Int {
try await Task.sleep(for: .seconds(1)) // Heavy Task
return Int.random(in: 1...100)
}
}
非同期処理のキャンセル無しの場合
ViewStateの実装
- 始めに非同期処理、今回でいうとAPI通信のキャンセルを考えないシンプルな形で考えていきます
-
FirstView
のViewStateとして下記を実装します -
init
でAPIClientProtocol
をユニットテストでStubをDIできるようにしています - またランダムな値を取得するボタンを押したときの関数を
fetchRandomNumberButtonTapped
として定義し、これに対してユニットテストを考えていきます
// MARK: - ViewState
@MainActor
@Observable
final class FirstViewState {
// MARK: - Property
private let apiClient: APIClientProtocol
private(set) var number: Int?
private(set) var isProcessing = false // 通信中のフラグ
var numberLabel: String {
guard let number else {
return "Not fetched yet"
}
return "\(number)"
}
// MARK: - LifeCycle
init(apiClient: APIClientProtocol = APIClient.shared) {
self.apiClient = apiClient
}
// MARK: - Actions
func fetchRandomNumberButtonTapped() async throws {
isProcessing = true
defer {
isProcessing = false
}
number = try await apiClient.fetchRandomNumber()
}
}
APIClientのStubの実装(CheckedContinuationを利用)
- ViewStateのユニットテストで扱いにくい点として、
APIClient
の非同期処理があり、この処理のタイミングをユニットテスト側で制御させることを考えたいです - これに関しては下記のkoherさんのiOSDCでの発表がとても参考になり、今回はその内容をベースに実装しています
- APIClientのStubを下記の通り実装します
/// CheckedContnuationをつかったStub
final actor APIClientStubWithCheckedContinuation: APIClientProtocol {
var fetchRandomNumberContinuation: CheckedContinuation<Int, Error>?
func setFetchRandomNumberContinuation(_ fetchRandomNumberContinuation: CheckedContinuation<Int, Error>?) {
self.fetchRandomNumberContinuation = fetchRandomNumberContinuation
}
func fetchRandomNumber() async throws -> Int {
try await withCheckedThrowingContinuation { continuation in
fetchRandomNumberContinuation = continuation
}
}
}
-
CheckedContinuation<Int, Error>?
というプロパティを持たせておくことで、fetchRandomNumber
を呼び出したあと、resume
を実行するまで処理を待機させるような動きを実現きます
// continuationにより任意のタイミングで発火させられる
// 指定した値を返す場合
await apiClientStub.fetchRandomNumberContinuation?.resume(returning: testNumber)
// エラーを返す場合
await apiClientMock.fetchRandomNumberContinuation?.resume(throwing: CancellationError())
ユニットテストの初期設定
- 今回テスト対象のViewModelの名前をsut(System Under Test)として定義します
@MainActor
final class FirstViewStateTests: XCTestCase {
// MARK: - Properties
private var sut: FirstViewState!
// MARK: - Setup/TearDown
override func setUpWithError() throws {
try super.setUpWithError()
}
override func tearDownWithError() throws {
try super.tearDownWithError()
sut = nil
}
}
ユニットテストの実装(CheckedContinuationを利用)
- では実際にユニットテストを書いていきます
- まず先ほど定義したStubを渡してViewStateを作成します
// MARK: Given
let testNumber = Int.random(in: 0...10000)
let apiClientStub = APIClientStubWithCheckedContinuation()
sut = FirstViewState(apiClient: apiClientStub)
XCTAssertNil(sut.number)
- 次に
sut.fetchRandomNumberButtonTapped
を呼び出します - whileでループしているのは
sut.fetchRandomNumberButtonTapped
を呼び出したあと、StubのプロパティfetchRandomNumberContinuation
に値がセットされることを保証するためです - whileループを抜けた直後の状態はとしては、Stubの
fetchRandomNumber
の実行を待機している状態で、resume
を実行するまで処理は進みません - 以上よりViewModelが通信中の状態のテストが可能となっています
// MARK: When
// awaitの処理の完了を待たない(処理自体は並行して走っている)
async let fetchRandomNumber: Void = sut.fetchRandomNumberButtonTapped()
// MARK: Then
// nilのチェックにより、try await apiClient.fetchRandomNumber()の中に入るまでタイミングを遅らせる
while await apiClientStub.fetchRandomNumberContinuation == nil {
await Task.yield()
}
XCTAssertTrue(sut.isProcessing) // 通信中フラグの確認
- その後
resume
により通信処理を発火させます
// APIの実行
// continuationにより任意のタイミングで発火させられる
await apiClientStub.fetchRandomNumberContinuation?.resume(returning: testNumber)
await apiClientStub.setFetchRandomNumberContinuation(nil)
try await fetchRandomNumber // awaitの処理の完了を待つ(= try await apiClient.fetchRandomNumber())
- 最後に通信後の状態をテストすればテストは完了です
// 操作完了後の状態確認
XCTAssertFalse(sut.isProcessing)
XCTAssertEqual(sut.number, testNumber)
- 非同期処理周りのテストをしていると、一回だけだとうまく動作してしまうことがあるため、テスト時間がかさまない程度で複数回テストを回すと良さそうに感じ巻いた
// 非同期処理周りは一回だけだとうまく動作してしまってエラーを検出しきれないことがあるため、複数回テストを回す
for _ in 0..<1000 {
try await test()
}
swift-concurrency-extrasの概要
- また
CheckedContinuation
を使わない別の方法としてpointfreeco/swift-concurrency-extrasのMainSerialExecutor
を利用する方法があります - 参考:
- ライブラリの詳細までは追えていないのですが、
withMainSerialExecutor
のClosure内に処理を書いて、その中をメインスレッドで実行させることで処理を同期的に処理をさせるもの、という理解をしています
try await withMainSerialExecutor {
try await test()
}
- これを使って同様のユニットテストを考えてみます
APIClientのStubの実装(Task.yield()を利用)
- 別のStubを実装します
- 実装の詳細に関しては後述します
/// Task.yield()をつかったStub
final actor APIClientStubWithTaskYield: APIClientProtocol {
var randomNumber: Int = .zero
// テスト側から返り値を指定できるようにする
func setRandomNumber(_ randomNumber: Int) {
self.randomNumber = randomNumber
}
func fetchRandomNumber() async throws -> Int {
await Task.yield() // 実行を1サイクル遅らせる
// タスクがキャンセルされている場合は例外を投げる
// Task.sleepやURLSessionの通信はキャンセル処理(CancellationErrorを投げる)が実装されていると思われるので、その代替の処理としてキャンセルチェックが必要
if Task.isCancelled {
throw CancellationError()
}
return randomNumber
}
}
ユニットテストの実装(MainSerialExecutorを利用)
- 前述の通り
withMainSerialExecutor
の中にテストを書いていきます
try await withMainSerialExecutor {
try await test()
}
- 以下
test()
内の実装で、先ほどのStubを渡してViewStateを作成します
// MARK: Given
let testNumber = Int.random(in: 0...10000)
let apiClientStub = APIClientStubWithTaskYield()
await apiClientStub.setRandomNumber(testNumber)
sut = FirstViewState(apiClient: apiClientStub)
XCTAssertNil(sut.number)
- 続けて
sut.fetchRandomNumberButtonTapped()
を呼び出します - ここでは
let task =
と保存しておいて、後ほどawait
で非同期処理の実行を待てるようにしています - その後
await Task.yield()
を挟むことで、テスト内の処理を一旦待機させて(1サイクル送らせて)、sut.fetchRandomNumberButtonTapped
の処理を優先させます
// MARK: When
// ここは最終的に上述のテストのasync letとやっていることは同じことのはず(async letでも動作することは確認済み)
let task = Task {
try await sut.fetchRandomNumberButtonTapped()
}
// MARK: Then
await Task.yield() // fetchRandomNumber()内のTask.yield()の直前までタイミングをずらす
- 一見処理の流れがわかりにくいので、整理すると以下の流れです
-
1
:await Task.yield()
で以降の処理を待機 -
2
: その後sut.fetchRandomNumberButtonTapped
の処理が優先されて実行される。APIClientのfetchRandomNumber()
の中まで処理が走る -
3
:fetchRandomNumber()
内にawait Task.yield()
があるので以降の処理を待機。つまりは1
の待機が解けて以降の処理が走る
-
- (この辺りPrint文でタイミングを確認しているのですが、もし理解が間違っていたらご指摘いただけると助かります)
- 以上の通り、API通信処理の直前の状態を作れたので通信中の状態である
sut.isProcessing
を確認できます - その後
await task.value
で処理の完了を待ち、最終的な状態の確認をすればテストは完了です
XCTAssertTrue(sut.isProcessing)
_ = try await task.value // 処理の完了を待つ
// 操作完了後の状態確認
XCTAssertFalse(sut.isProcessing)
XCTAssertEqual(sut.number, testNumber)
非同期処理のキャンセル有りの場合
- では続いて先程のAPI通信をキャンセルさせられるようにしてみます
- 新しくViewStateを下記の通り実装します
- プロパティに
Task<(), Error>?
を保持させておき、.cancel()
を呼びキャンセルできるようにしています - これにより
fetchRandomNumberButtonTapped
がasync throws
では無くなっているのが変更点となります
@MainActor
@Observable
final class SecondViewState {
// MARK: - Property
private let apiClient: APIClientProtocol
private(set) var number: Int?
private(set) var fetchRandomNumberTask: Task<(), Error>?
private(set) var isProcessing = false
private(set) var error: Error?
var numberLabel: String {
guard let number else {
return "Not fetched yet"
}
return "\(number)"
}
// MARK: - LifeCycle
init(apiClient: APIClientProtocol = APIClient.shared) {
self.apiClient = apiClient
}
// MARK: - Actions
/// ランダムな番号を取得するボタンが押された
func fetchRandomNumberButtonTapped() {
isProcessing = true
error = nil
fetchRandomNumberTask = Task {
defer {
isProcessing = false
}
do {
number = try await apiClient.fetchRandomNumber()
} catch {
if Task.isCancelled {
number = nil
self.error = MessageError(description: error.localizedDescription)
} else {
fatalError("unimplemented")
}
}
}
}
/// キャンセルボタンが押された
func handleCancelButtonTapped() {
fetchRandomNumberTask?.cancel()
fetchRandomNumberTask = nil
}
}
ユニットテストの実装(CheckedContinuationを利用)
- 同様に上記のViewStateの
fetchRandomNumberButtonTapped
に対してテストを考えます -
fetchRandomNumberButtonTapped
がasyncでは無くなっていますが、書き方としては以前と大きく変わりません
// MARK: Given
let testNumber = Int.random(in: 0...10000)
let apiClientMock = APIClientStubWithCheckedContinuation()
sut = SecondViewState(apiClient: apiClientMock)
XCTAssertNil(sut.number)
// MARK: When
sut.fetchRandomNumberButtonTapped() // awaitメソッドではなくTaskのプロパティを持って管理している
- 以前と同様に
resume
を実行するまでAPIClientの処理は待機されている状態です - 通信中の状態を確認しその後
resume
でAPIの処理を発火させ、taskの完了を待ちます - 前に
let task = Task { ... }
と書きましたが、今回はViewModelがTask
のプロパティを持つので、これを使って同様に書いています
// MARK: Then
while await apiClientMock.fetchRandomNumberContinuation == nil {
await Task.yield()
}
XCTAssertTrue(sut.isProcessing)
// APIの実行
await apiClientMock.fetchRandomNumberContinuation?.resume(returning: testNumber)
await apiClientMock.setFetchRandomNumberContinuation(nil)
_ = try await sut.fetchRandomNumberTask?.value // Taskの完了まで待つ
// 操作完了後の状態確認
XCTAssertFalse(sut.isProcessing)
XCTAssertEqual(sut.number, testNumber)
ユニットテストの実装(MainSerialExecutorを利用)
- こちらも流れは同じのため説明は割愛します
-
sut.fetchRandomNumberButtonTapped
がasyncではなくなり、今回Task.yield()
で制御する必要はありませんでした(必要ないというわけなく、今回のケースでは不要という意味)
// MARK: Given
let testNumber = Int.random(in: 0...100)
let apiClientMock = APIClientStubWithTaskYield()
await apiClientMock.setRandomNumber(testNumber)
sut = SecondViewState(apiClient: apiClientMock)
XCTAssertNil(sut.number)
// MARK: When
sut.fetchRandomNumberButtonTapped()
// MARK: Then
XCTAssertTrue(sut.isProcessing)
_ = try await sut.fetchRandomNumberTask?.value
// 操作完了後の状態確認
XCTAssertFalse(sut.isProcessing)
XCTAssertEqual(sut.number, testNumber)
キャンセル時のユニットテストの実装(CheckedContinuationを利用)
- 本題のキャンセルを含んだユニットテストを考えます
- 通信処理の直前までは先ほどと同様の流れです
// MARK: Given
let apiClientMock = APIClientStubWithCheckedContinuation()
sut = SecondViewState(apiClient: apiClientMock)
XCTAssertNil(sut.number)
// MARK: When
sut.fetchRandomNumberButtonTapped()
while await apiClientMock.fetchRandomNumberContinuation == nil {
await Task.yield()
}
// MARK: Then
XCTAssertTrue(sut.isProcessing)
- ViewStateのキャンセル時の実装ですが、taskのプロパティをnilにしています
@MainActor
@Observable
final class SecondViewState {
...
/// キャンセルボタンが押された
func handleCancelButtonTapped() {
fetchRandomNumberTask?.cancel()
fetchRandomNumberTask = nil
}
}
- そのためユニットテストで
handleCancelButtonTapped
を呼び出す前に、テスト側でtaskを参照を保持する必要があります -
handleCancelButtonTapped
を実行後、APIClientの処理をキャンセルさせるためresume
でCancellationError()
を返させています - その後のtaskの処理完了を待ち、最終の状態を確認すればテストは完了です
// sut側のfetchRandomNumberTaskはキャンセル後にnilになってしまいテストできなくなるため、テスト側で参照を保持させる
let fetchRandomNumberTask = sut.fetchRandomNumberTask
sut.handleCancelButtonTapped()
await apiClientMock.fetchRandomNumberContinuation?.resume(throwing: CancellationError())
await apiClientMock.setFetchRandomNumberContinuation(nil)
_ = await fetchRandomNumberTask?.result // キャンセル時の処理が完了するまで待つ
XCTAssertFalse(sut.isProcessing)
XCTAssertNil(sut.number)
XCTAssertNotNil(sut.error)
キャンセル時のユニットテストの実装(MainSerialExecutorを利用)
- こちらに関しても同様に、テスト側でtaskを参照を保持できるようにしておくのが以前からの変更点です
// MARK: Given
let testNumber = Int.random(in: 0...100)
let apiClientMock = APIClientStubWithTaskYield()
await apiClientMock.setRandomNumber(testNumber)
sut = SecondViewState(apiClient: apiClientMock)
XCTAssertNil(sut.number)
// MARK: When
sut.fetchRandomNumberButtonTapped()
// MARK: Then
XCTAssertTrue(sut.isProcessing)
let fetchRandomNumberTask = sut.fetchRandomNumberTask // sut側のfetchRandomNumberTaskはキャンセル後にnilになってしまいテストできなくなるため、テスト側で参照を保持させる
sut.handleCancelButtonTapped()
_ = await fetchRandomNumberTask?.result
// 操作完了後の状態確認
XCTAssertFalse(sut.isProcessing)
XCTAssertNil(sut.number)
XCTAssertNotNil(sut.error)
- また
sut.handleCancelButtonTapped()
内でtaskをcancelした際に、APIClientのfetchRandomNumber()
がCancellationError()
を返せるように実装しています
/// Task.yield()をつかったStub
final actor APIClientStubWithTaskYield: APIClientProtocol {
...
func fetchRandomNumber() async throws -> Int {
await Task.yield() // 実行を1サイクル遅らせる
// タスクがキャンセルされている場合は例外を投げる
// Task.sleepやURLSessionの通信はキャンセル処理(CancellationErrorを投げる)が実装されていると思われるので、その代替の処理としてキャンセルチェックが必要
if Task.isCancelled {
throw CancellationError()
}
return randomNumber
}
}
Discussion