3
4

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

Last updated at Posted at 2023-10-19

概要

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

  • async/await
  • Callback
  • Combine Publisher
  • RxSwift

テストするコード

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

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

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ファイルの全体像をお見せします。

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

    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

    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

    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のところの記述に、

A Future or Promise in Swift Combine

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

cancellableのstoreなどを行わないといけないため、もしもasync/awaitと同様にシンプルに処理を行いたい場合、Combine PublisherをSwift Concurrencyに変換する方法を使うといいと思います。

RxSwift

    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を使っているかは異なると思いますので、環境に合わせてお使いください。

3
4
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
4