Open2

Swift Testing: XCTestExpectation相当のことができない

kabeyakabeya

困っています。
何が困るかというと、waitができない点。

https://developer.apple.com/documentation/testing/migratingfromxctest#Validate-asynchronous-behaviors

これを見ると、Confirmationを使えとあるんですが、出ているサンプルがcompletionHandlerを使う例で、asyncのサンプルがないんですね。それでもって以下のように書いてある。

however, they don’t block or suspend the caller while waiting for a condition to be fulfilled.

asyncの何かが変わるのを待てないんで、どうしたものかなっていう状態です。
テストなんで、スピンロック的なループをしてもいいのかも知れませんが、XCTestでできてることができないのが残念。
できるのかしら。

kabeyakabeya

例えば2秒後に値が42になるというコードがあるとします。

@MainActor
class CounterViewModel: ObservableObject {
    @Published var count: Int = 0
    @Published var isLoading: Bool = false

    func loadCount() {
        isLoading = true
        Task {
            try await Task.sleep(for: .seconds(2))
            self.count = 42
            self.isLoading = false
        }
    }
}

表示はこんな感じ。

struct CounterView: View {
    @StateObject private var viewModel = CounterViewModel()

    var body: some View {
        VStack {
            Text(viewModel.isLoading ? "Loading..." : "Count: \(viewModel.count)")

            Button("Load Count") {
                viewModel.loadCount()
            }
        }
    }
}

XCTestで、実際にisLoadingfalseになったときに値が42になっているかというテストをするなら、以下のような感じになります。

    func testLoadCount() async throws {
        let viewModel = await CounterViewModel()
        var cancellables = Set<AnyCancellable>()
        
        let expectation = XCTestExpectation(description: "Load count finishes")
        await viewModel.$isLoading.dropFirst().sink { isLoading in
            if !isLoading {
                expectation.fulfill()
            }
        }.store(in: &cancellables)
        
        await viewModel.loadCount()
        wait(for: [expectation], timeout: 3.0)
        let count = await viewModel.count
        XCTAssertEqual(count, 42) 
    }

wait(for: [exepctation], timeout: 3.0)の行でexpectation.fulfill()が実行されるまで待ってくれるんですよね。最大3秒。実際は2秒でisLoading = falseになるので、3秒も待ちませんが。

でもこれが、Swift Testingだと書けません。本当に?
苦し紛れに以下のような感じです。

    @Test
    func testLoadCount() async throws {
        let viewModel = await CounterViewModel()
        
        await viewModel.loadCount()
        while true {
            let isLoading = await viewModel.isLoading
            if !isLoading {
                break
            }
            try! await Task.sleep(for: .milliseconds(10))
        }
        
        let count = await viewModel.count
        #expect(count == 42)
    }

withCheckedContinuationだと、ハンドラ版のやつは書けてもasyncの奴が書けません。
confirmation()だと、isLoading = falseになるまで待たずにすぐ返ってきてしまうので必ず失敗します。
(もしくはウェイトとかループを入れるか。だったら上みたいなスピンロック的なので充分ですし)

何かおかしいんじゃないかという気になっていますが、どうなんでしょうか。