🗂

非同期処理を含んだViewStateのユニットテスト

に公開

概要

  • API通信などの非同期処理を含んだViewState(=ViewModelと同義)のユニットテストを考えていきます
  • また今回は非同期処理をキャンセルできるようにして、このユニットテストも実装します

Apr-10-2025 11-36-24

GitHub

参考

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として下記を実装します
  • initAPIClientProtocolをユニットテストで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の概要

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()を呼びキャンセルできるようにしています
  • これによりfetchRandomNumberButtonTappedasync 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の処理をキャンセルさせるためresumeCancellationError()を返させています
  • その後の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