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