Swift Concurrencyによる状態遷移のテスト手法

はじめに
こんにちは、iOSエンジニアの牟田です。
前回の記事ではウェルスナビのiOSアプリにおけるアーキテクチャとその移行について紹介しました。
現在も絶賛移行中ではありますが、テストリソースの関係から新規画面または既存画面のうち改修が必要な画面でのみの移行に限られています。
今までは ViewController(またはSwiftUI.View)のsnapshot testと通信部分のunit testを中心にテストを書いており、ViewStoreの品質はQAによる機能テストまたは開発チームによる手動テスト、及びリグレッションテストで担保してきました。
しかし、これでは既存画面の移行が進まないため ViewStore の品質もunit testで担保できるようにテストコードの整備を進めています。
今回はSwift ConcurrencyのAsyncSequenceを利用したViewStoreのテスト手法について紹介します。
ViewStoreがなぜテストしづらかったのか
前回の記事を見ていただけると分かる通り、画面の状態(state)はRxSwiftのBehaviorRelayで格納されています。また外部にはSignalを通じて公開されており実態は完全に隠蔽されています。
この時BehaviorRelayはcompleteを流さないためtoBlocking()で待つことができないという問題があります。
また単方向データフローを守るためTestSchedulerによる外部からの値の流し込みもできません。
なお、ここまでの理由はstateだけでなくeffect(PublishRelay)にも当てはまります。
以上の理由から、stateやeffectのテストはXCTestExpectationを使った力業になってしまいました。
例として、前回の記事で例に挙げたLoginViewStoreのログイン処理のテストコードは以下のようになります。
LoginViewStoreImplTests
func testLogin_success() throws {
let email = "foo"
let password = "bar"
// prepare for stub
viewStore.dispatchCommand(.inputLoginId(email))
viewStore.dispatchCommand(.inputPassword(password))
viewStore.dispatchCommand(.login)
var userId: String?
let exp = expectation(description: "effect")
_ = viewStore.effect
.emit(onNext: {
switch $0 {
case let .complete(value):
userId = value
exp.fulfill()
case .showProgress, .dismissProgress: return
default:
XCTFail("Unexpected effect: \($0)")
exp.fulfill()
}
})
wait(for: [exp], timeout: 5.0)
XCTAssertEqual("expected-user-id", userId)
// verify changes in fake dependencies
}
一見問題無さそうに見えるコードですが、wait(for:timeout:)のビジーウェイトによりスレッドがデッドロックする場合があり、テストがランダムで失敗します[1]。
またウェルスナビで目指している脱RxSwiftの為にはRxSwiftに依存しないテストコードを書く必要がありますが、上記のコードはRxSwiftに依存していることが一目瞭然です。
不安定なテストはCI/CDの効率も下げるため手動テストを是としていました。
for await (AsyncSequence)を使ったテスト手法
RxSwift 6.5.0からSwift Concurrencyに対応し、for await が扱えるようになりました。
これによりビジーウェイトを生じないテストコードが書けることで、stateやeffectのイベントを安定してテストできるようになりました。
先程のテストコードをfor awaitを使って書き換えると以下のようになります。
LoginViewStoreImplTests
func testLogin_success() async throws {
let email = "foo"
let password = "bar"
// prepare for stub
viewStore.dispatchCommand(.inputLoginId(email))
viewStore.dispatchCommand(.inputPassword(password))
viewStore.dispatchCommand(.login)
// ↑↑ここより上の行は変わらないため以降のコードでは省略します↑↑
for await effect in viewStore.effect.values {
switch effect {
case let .complete(userId):
XCTAssertEqual("expected-user-id", userId)
// verify changes in fake dependencies
return
case .showProgress, .dismissProgress:
break
default:
XCTFail("Unexpected effect: \(effect)")
return
}
}
}
for awaitを使うことでXCTestExpectationによる不安定さを回避できるようになりました。
ただし、このままではfor awaitの中に全てのアサーションを書くことになりテストコードの見通しが悪くなります。
また、実装が変わって万が一バグによりeffectに何も流れない状況が発生した場合、無限ループのような状態になりテストが終了しなくなってしまいます。
CIサービスによってはジョブ自体にタイムアウトが設けられている場合もありますが、従量課金がデファクトとなっている現状ではクレジットの垂れ流しと同義と捉えても良いでしょう。
そこで、タイムアウトを設けつつ結果を返してfor awaitの外でアサーションできるような仕組みを作ります。
Concurrency+Timeout
@inlinable public func withTimeout<Result: Sendable>(
nanoseconds duration: UInt64 = 500_000_000,
of resultType: Result.Type = Result.self,
body: @Sendable @escaping @MainActor () async throws -> Result
) async rethrows -> Result {
try await withThrowingTaskGroup(of: resultType) { group in
group.addTask(operation: body)
group.addTask {
try await Task.sleep(nanoseconds: duration)
throw CancellationError()
}
let result = try await group.next()
group.cancelAll()
return result!
}
}
LoginViewStoreImplTests
let userId = try await withTimeout { [self] in
for await effect in viewStore.effect.values {
switch effect {
case let .complete(userId):
return userId
case .showProgress, .dismissProgress: break
default:
throw UnexpectedEffectError(effect)
}
}
throw CancellationError()
}
XCTAssertEqual("expected-user-id", userId)
// verify changes in fake dependencies
クロージャーが @escaping なため self のキャプチャが必要なことと、ビルドエラーを回避するために throw CancellationError() を追加しています。
これらのボイラープレートを解消すべく更に関数を切ります。
Concurrency+Timeout
/// イベントストリームに目的の値を返すイベントが流れてくるまで待つ。`body` がエラーを投げたらそのエラーを投げる。
/// - Parameters:
/// - stream: イベントストリーム
/// - duration: タイムアウトまでの時間(単位: `ns`)
/// - resultType: 目的の値の型
/// - body: 目的のイベントであれば対応する値を返す。それ以外であれば `nil`を返せば次のイベントを待つ。
/// - Throws: `CancellationError` タイムアウトの場合
/// - Returns: 目的の値
@inlinable public func elementForStream<Stream: AsyncSequence, Result: Sendable>(
_ stream: Stream,
withTimeoutByNanoseconds duration: UInt64 = 500_000_000,
of resultType: Result.Type = Result.self,
body: @Sendable @escaping @MainActor (Stream.Element) throws -> Result?
) async rethrows -> Result {
try await withTimeout(nanoseconds: duration, of: resultType) {
for try await value in stream {
if let result = try body(value) {
return result
}
}
throw CancellationError()
}
}
LoginViewStoreImplTests
let userId = try await elementForStream(viewStore.effect.values) {
switch $0 {
case let .complete(userId): userId
case .showProgress, .dismissProgress: nil
default: throw UnexpectedEffectError($0)
}
}
XCTAssertEqual("expected-user-id", userId)
// verify changes in fake dependencies
Swift 5.9で追加されたif/switch式と併用することでシンプルに書けるようになりました。
for await (AsyncSequence)を使ったテスト手法のメリット
原則Swift標準のAPIのみでテストが書けるので、将来CombineやObservation Frameworkへの移行が容易になると考えています。
実際、Combineの CurrentValueSubject には既に AsyncSequence に適合している AsyncPublisher または AsyncThrowingPublisher を返す values プロパティが存在しています[2]。
そのため、ViewStoreをCombineで書き直す際このテストコードには手を入れることなく書き換えが可能になります。
まとめ
RxSwiftがfor awaitに対応したことで安定したViewStoreのテストが書けるようになりました。
またテストコードそのものはRxSwiftに依存しない形になっているため、「RxSwiftを剥がすためにRxSwiftのテストを書く」といった本末転倒なことをする必要がなくなりました。
これにより全レイヤーについてテストが書けるようになったので、テストコードを整備しつつ引き続き脱RxSwiftを進めていきます。
明日は、デザイナー 匂坂 の「Figmaのバリアブルで効果的なユーザビリティテストを!実践的なプロトタイプ作成術」です!
お楽しみに!
📣 ウェルスナビは一緒に働く仲間を募集しています 📣
著者プロフィール
牟田 拓広(むた たくひろ)
2019年9月入社、iOS周りの色々(アプリ開発、CI整備、検証端末管理etc...)を担当。
Vision Proが気になる今日この頃。
Discussion