iOS 各種非同期処理をXCTestに書く

# 概要
iOS/Swiftでは様々な方法で非同期処理を行うことができます。
その非同期処理の記述に対してどのようにXCTestを書けばいいのかを検討してみました。
非同期処理としては、一回だけ値が返ってくるワンショットオペレーションを想定しています。
以下のパターンで考えてみます。

- async/await
- Callback
- Combine Publisher
- RxSwift

# テストするコード
以下のような、`AsyncApiProtocol` に適合した、絶対に値を返す`LocalDataAsyncApi`と絶対にエラーを返す `ErrorAsyncApi` を用意しました。AsyncApiProtocolは各種非同期で扱える値として返しています。

実用としては実際のネットワークに繋ぐ、AsyncApiProtocolに適合したAsyncApiを用意するかと思いますが、ひとまずテスト用としてstructを作成しています。

```swift
private let apiData = "適当な文字列"
protocol AsyncApiProtocol {
    func loadWithAwait() async throws -> String
    func loadWithCallback(completionHandler: @escaping @Sendable (Result<String, Error>) -> Void)
    func loadWithCombine() -> AnyPublisher<String, URLError>
    func loadWithSingle() -> Single<String>
}

struct LocalDataAsyncApi: AsyncApiProtocol {
    func loadWithAwait() async throws -> String {
        apiData
    }
    
    func loadWithCallback(completionHandler: @escaping @Sendable (Result<String, Error>) -> Void) {
        completionHandler(.success(apiData))
    }
    
    func loadWithCombine() -> AnyPublisher<String, URLError> {
        Just(apiData)
            .setFailureType(to: URLError.self)
            .eraseToAnyPublisher()
    }
    
    func loadWithSingle() -> RxSwift.Single<String> {
        .just(apiData)
    }
}

struct ErrorAsyncApi: AsyncApiProtocol {
    func loadWithAwait() async throws -> String {
        throw URLError(.networkConnectionLost)
    }
    
    func loadWithCallback(completionHandler: @escaping @Sendable (Result<String, Error>) -> Void) {
        completionHandler(.failure(URLError(.networkConnectionLost)))
    }
    
    func loadWithCombine() -> AnyPublisher<String, URLError> {
        AnyPublisher(Fail(error: URLError(.networkConnectionLost)))
    }
    
    func loadWithSingle() -> RxSwift.Single<String> {
        .error(URLError(.networkConnectionLost))
    }
}
```

# XCTest
まずはXCTestCaseファイルの全体像をお見せします。

```swift
import Combine
import XCTest
import RxBlocking

final class AsyncAppTests: XCTestCase {
    private var api: AsyncApiProtocol!
    private var cancellable = Set<AnyCancellable>()

    override func setUpWithError() throws {
    }

    override func tearDownWithError() throws {
        cancellable.removeAll()
    }

    func testLoadWithAwait() async throws {
        api = LocalDataAsyncApi()
        let string = try await api.loadWithAwait()
        XCTAssertEqual(string, apiData)
    }
    
    func testLoadWithAwaitError() async {
        api = ErrorAsyncApi()
        do {
            _ = try await api.loadWithAwait()
            XCTFail()
        } catch {}
    }
    
    func testLoadWithCallback() async {
        api = LocalDataAsyncApi()
        let expectation = XCTestExpectation(description: "Access network asynchronously.")
        api.loadWithCallback { result in
            do {
                let string = try result.get()
                XCTAssertEqual(string, apiData)
            } catch {
                XCTFail()
            }
            expectation.fulfill()
        }
        await fulfillment(of: [expectation])
    }
    
    func testLoadWithCallbackError() async {
        api = ErrorAsyncApi()
        let expectation = XCTestExpectation(description: "Access network asynchronously.")
        api.loadWithCallback { result in
            do {
                _ = try result.get()
                XCTFail()
            } catch {}
            expectation.fulfill()
        }
        await fulfillment(of: [expectation])
    }
    
    func testLoadWithCombine() async {
        api = LocalDataAsyncApi()
        let expectation = XCTestExpectation(description: "Access network asynchronously.")
        api.loadWithCombine()
            .sink { completion in
                switch completion {
                case .finished:
                    break
                case .failure(_):
                    XCTFail()
                }
                expectation.fulfill()
            } receiveValue: { string in
                XCTAssertEqual(string, apiData)
            }
            .store(in: &cancellable)

        await fulfillment(of: [expectation])
    }
    
    func testLoadWithCombineError() async {
        api = ErrorAsyncApi()
        let expectation = XCTestExpectation(description: "Access network asynchronously.")
        api.loadWithCombine()
            .sink { completion in
                switch completion {
                case .finished:
                    XCTFail()
                case .failure(_):
                    break
                }
                expectation.fulfill()
            } receiveValue: { string in
                XCTAssertEqual(string, apiData)
            }
            .store(in: &cancellable)

        await fulfillment(of: [expectation])
    }
    
    func testLoadWithSingle() throws {
        api = LocalDataAsyncApi()
        let string = try api.loadWithSingle().toBlocking().single()
        XCTAssertEqual(string, apiData)
    }
    
    func testLoadWithSingleError() {
        api = ErrorAsyncApi()
        XCTAssertThrowsError(try api.loadWithSingle().toBlocking().single())
    }
}

```

一つづつ見ていきます。

## async/await

```swift
    func testLoadWithAwait() async throws {
        api = LocalDataAsyncApi()
        let string = try await api.loadWithAwait()
        XCTAssertEqual(string, apiData)
    }
    
    func testLoadWithAwaitError() async {
        api = ErrorAsyncApi()
        do {
            _ = try await api.loadWithAwait()
            XCTFail()
        } catch {}
    }
```

async/awaitの返り値の場合、テストのfuncにasyncをつけることでテストが可能になっています。
最もシンプルな形でテストが書けます。
値が返ってきた場合はそのまま値を比較し、エラーを確認したい場合はdo-catchで処理します。
async/awaitでは`XCTAssertNoThrow`は使えないようです。

## Callback

```swift
    func testLoadWithCallback() async {
        api = LocalDataAsyncApi()
        let expectation = XCTestExpectation(description: "Access network asynchronously.")
        api.loadWithCallback { result in
            do {
                let string = try result.get()
                XCTAssertEqual(string, apiData)
            } catch {
                XCTFail()
            }
            expectation.fulfill()
        }
        await fulfillment(of: [expectation])
    }
    
    func testLoadWithCallbackError() async {
        api = ErrorAsyncApi()
        let expectation = XCTestExpectation(description: "Access network asynchronously.")
        api.loadWithCallback { result in
            do {
                _ = try result.get()
                XCTFail()
            } catch {}
            expectation.fulfill()
        }
        await fulfillment(of: [expectation])
    }
```

コールバックで待ち合わせる場合、旧来通りの`XCTestExpectation`を使っての処理を行います。
`fulfillment`というasyncに対応したものが追加されており、これを使うため、funcにasyncキーワードをつけて対応しています。

## Combine Publisher

```swift
    func testLoadWithCombine() async {
        api = LocalDataAsyncApi()
        let expectation = XCTestExpectation(description: "Access network asynchronously.")
        api.loadWithCombine()
            .sink { completion in
                switch completion {
                case .finished:
                    break
                case .failure(_):
                    XCTFail()
                }
                expectation.fulfill()
            } receiveValue: { string in
                XCTAssertEqual(string, apiData)
            }
            .store(in: &cancellable)

        await fulfillment(of: [expectation])
    }
    
    func testLoadWithCombineError() async {
        api = ErrorAsyncApi()
        let expectation = XCTestExpectation(description: "Access network asynchronously.")
        api.loadWithCombine()
            .sink { completion in
                switch completion {
                case .finished:
                    XCTFail()
                case .failure(_):
                    break
                }
                expectation.fulfill()
            } receiveValue: { string in
                XCTAssertEqual(string, apiData)
            }
            .store(in: &cancellable)

        await fulfillment(of: [expectation])
    }
```

[Build Asynchronous Tests with Expectations](https://developer.apple.com/documentation/xctest/asynchronous_tests_and_expectations#3927086)のところの記述に、

> A Future or Promise in Swift Combine

が含まれています。
AnyPublisherはFutureではないですが、コールバックの時と同じく`XCTestExpectation`を使って処理を行っています。

cancellableのstoreなどを行わないといけないため、もしもasync/awaitと同様にシンプルに処理を行いたい場合、[Combine PublisherをSwift Concurrencyに変換する](https://qiita.com/hyuga_amazia/items/2ce629916c2a088d1ecb)方法を使うといいと思います。

## RxSwift

```swift
    func testLoadWithSingle() throws {
        api = LocalDataAsyncApi()
        let string = try api.loadWithSingle().toBlocking().single()
        XCTAssertEqual(string, apiData)
    }
    
    func testLoadWithSingleError() {
        api = ErrorAsyncApi()
        XCTAssertThrowsError(try api.loadWithSingle().toBlocking().single())
    }
```

返り値がSingleの場合、RxBlockingをimportし、`.toBlocking().single()`を行うことでasync/awaitと同じようなテストコードを書くことが可能です。違いはasyncを必要としないので、XCTAssertThrowsErrorが使えます。

# まとめ
Combineは結構テスト用の記述が大変そうなので、async/await形式での記述が好みです。
もちろんプロジェクトによってどのAPIを使っているかは異なると思いますので、環境に合わせてお使いください。