3
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

swift-concurrency-extrasでasync関数のawait前後の状態をTestする

Last updated at Posted at 2023-08-10

swift-concurrency-extrasでローディング中(async関数のawaitの前後の状態)をTestする

動画などサイズが大きいデータをダウンロードする際に、ダウンロードのボタンが押下されたら「ダウンロード中」に、ダウンロードが完了したら「ダンロード完了」に変えるという画面があるとき、

Frame 283.png

このよう↓にダウンロードの仕組みがayncで提供されているViewModelなどで、awaitの前後でtextが「ダウンロード中」→「ダウンロード完了」に適切に切り替わることをテストしたいです
このテストの方法として、自分がやっていたパワー?なCheckedContinuationを管理する方法とswift-concurrency-extrasを使った方法を紹介します

class ContentViewModel: ObservableObject {
    @Published var text: String = "ダウンロードする"

    private let downloadHandler: () async -> String
    init(downloadHandler: @escaping () async -> String) {
        self.downloadHandler = downloadHandler
    }

    func download() async {
        text = "ダウンロード中" // ← 関数が呼ばれてからawait中の状態
        let res = await downloadHandler()
        text = "ダウンロード完了: \(res)" // ← await後の状態
    }
}

(Xcode14.3で検証しています)

CheckedContinuationを管理する方法

最初はこんな感じ↓のテストを実装しました
しかし、この実装ではtextが「ダウンロード中」を期待していたタイミングで、「ダウンロードする」から変更されておらずエラーとなってしまいました

class ContentViewModelTests: XCTestCase {
    func testDownload() async {
        let vm = ContentViewModel { "Hello" }
        let task = Task { await vm.download() }

        // vm.textが"ダウンロードする"のままで、テストが失敗する
        XCTAssertEqual(vm.text, "ダウンロード中") // XCTAssertEqual failed: ("ダウンロードする") is not equal to ("ダウンロード中")

        await task.value

        XCTAssertEqual(vm.text, "ダウンロード完了: Hello")
    }
}

これは、Taskの中身の未実行が問題なので、Task.sleepを挟むことで解決できました
しかし、これではテストにかかる時間が増えてしまいます

class ContentViewModelTests: XCTestCase {
    func testDownload() async {
        let vm = ContentViewModel {
+           try? await Task.sleep(for: .seconds(1))
            return "Hello"
        }
        let task = Task { await vm.download() }

+       try? await Task.sleep(for: .seconds(0.5))

        XCTAssertEqual(vm.text, "ダウンロード中")

        await task.value

        // テストの完了まで時間がかかってしまう
        XCTAssertEqual(vm.text, "ダウンロード完了: Hello")
    }
}

そこで、CurrentValueSubjectを使い、withCheckedContinuationでCheckedContinuationが生成されるまで待機させることで、downloadHandlerが実行されるまで待機するのを実現しました
この方法ではテストは問題なく通過しますし、任意のタイミングでイベントを流せて便利なのですが、ボイラープレートが多いことやCheckedContinuationに1回だけイベントを流すのを実装で保証しないといけないことなどの課題感を持っていました

class ContentViewModelTests: XCTestCase {
    func testDownload() async {
+       let subject = CurrentValueSubject<CheckedContinuation<String, Never>?, Never>(nil)

        let vm = ContentViewModel {
+           await withCheckedContinuation { (c: CheckedContinuation<String, Never>) in
+               // downloadHandlerが実行されたらsubjectに通知
+               subject.send(c)
+           }
-           "Hello"
        }
        let task = Task { await vm.download() }

+       // downloadHandlerが実行されるまで待機
+       let continuation = await subject.values.first(where: { $0 != nil })!!

        XCTAssertEqual(vm.text, "ダウンロード中")

+       continuation.resume(with: .success("Hello"))
        await task.value

        XCTAssertEqual(vm.text, "ダウンロード完了: Hello")
    }
}

swift-concurrency-extrasを使う方法

このローディング中のテストは、pointfreecoが公開しているswift-concurrency-extrasに入っているwithMainSerialExecutorを使ってシンプルに実現できました

テストをwithMainSerialExecutorに入れ、Task生成後とdownloadHandlerで値を返す前にTask.yield()を入れると、テストが通過するようになります
Task.yield()は増えましたが、CheckedContinuationの管理がなくなり、シンプルにテストを実現できたと思います

class ContentViewModelTests: XCTestCase {
    func testDownload() async {
+       await withMainSerialExecutor {
            let vm = ContentViewModel {
+               await Task.yield()
                return "Hello"
            }
            let task = Task { await vm.download() }

+           await Task.yield()

            XCTAssertEqual(vm.text, "ダウンロード中")

            await task.value

            XCTAssertEqual(vm.text, "ダウンロード完了: Hello")
+       }
    }
}

もし、withMainSerialExecutorを外し、ContentViewModelに@.MainActorをつけると、一瞬テストが通過するような感じになるのですが、1000回くらい繰り返すと確率で失敗するFlakyなテストとなってしまいました
withMainSerialExecutorはちゃんと何か重要な役割を果たしていそうです

class ContentViewModelTests: XCTestCase {
+   @MainActor
    func testDownload() async {
-       await withMainSerialExecutor {
            let vm = ContentViewModel {
                await Task.yield()
                return "Hello"
            }
            let task = Task { await vm.download() }

            await Task.yield()

            // 確率で失敗してしまう
            XCTAssertEqual(vm.text, "ダウンロード中")

            await task.value

            XCTAssertEqual(vm.text, "ダウンロード完了: Hello")
-       }
    }
}

withMainSerialExecutorの実装を見てみる

withMainSerialExecutorの実装を少しだけ見てみました
大事そうな部分の抜粋です
swift_task_enqueueGlobal_hookというのを取得し、MainActorにenqueueさせるようにしているようです
これにより、全てのTaskがMainActorで実行されることで、順序の問題がなくなり、Flakyな問題が解消されるようです(多分)

@MainActor
public func withMainSerialExecutor(
    @_implicitSelfCapture operation: @MainActor @Sendable () async throws -> Void
) async rethrows {
    let didUseMainSerialExecutor = uncheckedUseMainSerialExecutor
    defer { uncheckedUseMainSerialExecutor = didUseMainSerialExecutor }
    uncheckedUseMainSerialExecutor = true
    try await operation()
}
// 中略
public var uncheckedUseMainSerialExecutor: Bool {
    get { swift_task_enqueueGlobal_hook != nil }
    set {
        swift_task_enqueueGlobal_hook =
            newValue ? { job, _ in MainActor.shared.enqueue(job) } : nil
    }
}
// 中略
private let _swift_task_enqueueGlobal_hook: UnsafeMutablePointer<Hook?> =
  dlsym(dlopen(nil, 0), "swift_task_enqueueGlobal_hook").assumingMemoryBound(to: Hook?.self)

まとめ

Swift Concurrencyでローディング中をTestするために、自分がやっていたCheckedContinuationを管理する方法とswift-concurrency-extrasを使った方法を紹介しました
swift-concurrency-extrasのwithMainSerialExecutorを使うことで、シンプルにテストができました
ただ、CheckedContinuationを管理する方法では任意のタイミングでイベントを流せるというメリットもあるので、いい感じに組み合わせてテストが実装できると良さそうです
(公式でこの辺をいい感じにやる仕組みを作って欲しいという気持ちもあったりします)

3
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?