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