TCA で AsyncStream をテストする方法
この記事は The Composable Architecture Advent Calendar 2022 の 11 日目の記事になります。
昨日は TCA の歩き方 2022 という記事も書いたので、もしよろしければ見ていただけたら嬉しいです!
本記事では TCA と AsyncStream 自体はある程度理解している方向けに TCA で AsyncStream (or AsyncThrwoingStream) をテストする方法を紹介しようと思います。
いけばたさんの「TCA × Firestore入門(テスト編)」と偶然にも内容が少し被ってしまいましたが、多少違うテイスト (若干仕組みよりの話) で説明できればと思います🙏
この記事では以下を理解できるようになることを目指しています。
- TCA で AsyncStream をどのように利用するのか
- TCA で AsyncStream を利用しているコードをどのようにテストするのか
- AsyncStream をテストできるようにしている仕組み
- 本記事で説明する仕組みは TCA を利用していなくても流用できる考え方なので、TCA を利用していない方でも参考になる部分があると思います
TCA で AsyncStream を利用している例
TCA のリポジトリにある Examples
ディレクトリで言うと、以下の二つで AsyncStream
を利用している例があります。
例としては「02-Effects-LongLiving.swift」がシンプルなので、その利用方法を見てみることにします。
Dependency の実装
extension DependencyValues {
var screenshots: @Sendable () async -> AsyncStream<Void> {
get { self[ScreenshotsKey.self] }
set { self[ScreenshotsKey.self] = newValue }
}
}
private enum ScreenshotsKey: DependencyKey {
static let liveValue: @Sendable () async -> AsyncStream<Void> = {
await AsyncStream(
NotificationCenter.default
.notifications(named: UIApplication.userDidTakeScreenshotNotification)
.map { _ in }
)
}
static let testValue: @Sendable () async -> AsyncStream<Void> = unimplemented(
#"@Dependency(\.screenshots)"#, placeholder: .finished
)
}
DependencyValues
に ScreenshotsKey
という独自の Dependency を登録しています。
また、ScreenshotsKey
では liveValue
と testValue
を設定しています。
どちらもシンプルな実装ですが、liveValue
では NotificationCenter
を使って AsyncStream を生成しています。
これはユーザーが端末でスクリーンショットを撮るたびに Void が流れてくるような AsyncStream になっています。
また、testValue
では unimplemented
を利用して、テストで screenshots
の Dependency が意図せず利用された際にテストが失敗するようにしています。
ここで AsyncStream の生成方法に着目してみます。
await AsyncStream(
NotificationCenter.default
.notifications(named: UIApplication.userDidTakeScreenshotNotification)
.map { _ in }
)
NotificationCenter.default.notifications(named:).map()
は型としては AsyncMapSequence<NotificationCenter.Notifications, ()>
というものになります。
しかし、上記のコードを見ると、AsyncMapSequence を直接 AsyncStream に入れることで AsyncStream を生成しているようです。
AsyncStream には標準で init(_:bufferingpolicy:_:)
という initializer がありますが、AsyncMapSequence を直接 initialize 時に利用する方法はありません。
実はここでは、TCA 内部で実装されている以下の initializer が利用されています。
extension AsyncStream {
public init<S: AsyncSequence & Sendable>(
_ sequence: S,
bufferingPolicy limit: Continuation.BufferingPolicy = .unbounded
) where S.Element == Element {
self.init(bufferingPolicy: limit) { (continuation: Continuation) in
let task = Task {
do {
for try await element in sequence {
continuation.yield(element)
}
} catch {}
continuation.finish()
}
continuation.onTermination =
{ _ in
task.cancel()
}
// NB: This explicit cast is needed to work around a compiler bug in Swift 5.5.2
as @Sendable (Continuation.Termination) -> Void
}
}
}
この独自の initializer 自体では特殊な処理は行っておらず、AsyncStream の標準の initializer である init(_:bufferingpolicy:_:)
を使い、受け取った AsyncSequence および Task を利用して、エラーを表現しない AsyncStream に変換しているだけではあります。
(近いうちに、この initializer について若干アップデートが入る可能性があるかもしれません)
この initializer が用意されているため、AsyncSequence を直接 AsyncStream に変換できているだけという話で、少し話が逸れてしまいましたが、次は screenshots
Dependency の利用部分を見ていきます。
ReducerProtocol の実装
利用部分はもちろん ReducerProtocol 実装部分で、以下のようになっています。
struct LongLivingEffects: ReducerProtocol {
struct State: Equatable {
var screenshotCount = 0
}
enum Action: Equatable {
case task
case userDidTakeScreenshotNotification
}
@Dependency(\.screenshots) var screenshots
func reduce(into state: inout State, action: Action) -> EffectTask<Action> {
switch action {
case .task:
// When the view appears, start the effect that emits when screenshots are taken.
return .run { send in
for await _ in await self.screenshots() {
await send(.userDidTakeScreenshotNotification)
}
}
case .userDidTakeScreenshotNotification:
state.screenshotCount += 1
return .none
}
}
}
ReducerProtocol の実装もシンプルなものとなっています。
大きな処理の流れは以下の通りです。
- 画面表示時に処理を行うことができる
task
View Modifier によってtask
Action が発火する -
task
Action の発火によってscreenshots
Dependency が呼び出され、AsyncStream が subscribe される - ユーザーがスクリーンショットを撮ると AsyncStream に Void が流れてきて
userDidTakeScreenshotNotification
Action が発火する
これだけで、ユーザーがスクリーンショットを撮るたびに userDidTakeScreenshotNotification
が発火し、その度に screenshotCount
という state が増加する挙動を表現できます。AsyncStream は便利ですね。
ここまでは TCA で AsyncStream を利用しているシンプルなコードを紹介しました。
では、本題であるテスト方法について説明していこうと思います。
AsyncStream を利用したコードのテスト
AsyncStream を利用したコードをテストしている例として、先ほど紹介した 02-Effects-LongLiving.swift
のテストである 02-Effects-LongLivingTests.swift
を見てみることにします。
まず先にコードの全体像を示します。
@MainActor
final class LongLivingEffectsTests: XCTestCase {
func testReducer() async {
let (screenshots, takeScreenshot) = AsyncStream<Void>.streamWithContinuation()
let store = TestStore(
initialState: LongLivingEffects.State(),
reducer: LongLivingEffects()
)
store.dependencies.screenshots = { screenshots }
let task = await store.send(.task)
// Simulate a screenshot being taken
takeScreenshot.yield()
await store.receive(.userDidTakeScreenshotNotification) {
$0.screenshotCount = 1
}
// Simulate screen going away
await task.cancel()
// Simulate a screenshot being taken to show no effects are executed.
takeScreenshot.yield()
}
}
割と短いですが、少しずつ説明してみます。
final class LongLivingEffectsTests: XCTestCase {
func testReducer() async {
let (screenshots, takeScreenshot) = AsyncStream<Void>.streamWithContinuation()
// ...
}
いきなりですが、ここが本記事で一番説明したい部分になります。
ここでは、AsyncStream を普段利用している人でも見慣れないであろう streamWithContinuation
というものを AsyncStream に対して利用することで、screenshots
, takeScreenshot
という二つの値からなるタプルを得ていることがわかります。
streamWithContinuation
は先ほどチラッと紹介した AsyncStream の独自 initializer と同様に、TCA 内部で定義されている function で、以下のような実装になっています。
extension AsyncStream {
public static func streamWithContinuation(
_ elementType: Element.Type = Element.self,
bufferingPolicy limit: Continuation.BufferingPolicy = .unbounded
) -> (stream: Self, continuation: Continuation) {
var continuation: Continuation!
return (Self(elementType, bufferingPolicy: limit) { continuation = $0 }, continuation)
}
}
実装を見てみると実は非常にシンプルで、以下のような手順でタプルを構築しています。
-
AsyncStream.Continuation
型のcontinuation
を Implicitly Unwrapped Optional として定義する - AsyncStream の標準 initializer である
init(_:bufferingPolicy:_:)
を利用して、AsyncStream を作成しつつ、1 で定義したAsyncStream.Continuation
に initializer により得られるcontinuation
をセットする - 1 で作成した
continuation
と 2 で作成した AsyncStream をタプルにして return する
これによって、テストで利用される AsyncStream と、その AsyncStream を操作するための continuation
を取得することができるという仕組みになっています。
注意しておきたいこととしては、この streamWithContinuation
には以下のようなコメントが記載されていることです。
/// > Warning: ⚠️
AsyncStream
does not support multiple subscribers, therefore you can only use
/// > this helper to test features that do not subscribe multiple times to the dependency
/// > endpoint.
警告: ⚠️ AsyncStream は複数の subscriber をサポートしていないので、このヘルパーは依存関係のあるエンドポイントに対して複数回 subscribe しない機能のテストにのみ使用することができます。
そのため、上記の旨に注意しつつ、基本的にはテストのみで利用するに留めておくのが良さそうかなと思います。
では、テストコードに戻り、次は TestStore の作成部分を見ます。
final class LongLivingEffectsTests: XCTestCase {
func testReducer() async {
let (screenshots, takeScreenshot) = AsyncStream<Void>.streamWithContinuation()
let store = TestStore(
initialState: LongLivingEffects.State(),
reducer: LongLivingEffects()
)
store.dependencies.screenshots = { screenshots }
// ...
TestStore の作成方法は特に変わったところはないので、説明は省略します。
注目すべきは Dependency の設定部分で、steramWithContinuation
によって得られた screenshots
つまり AsyncStream<Void>
を設定していることです。
このように、streamWithContinuation
によって得られた AsyncStream を設定することで、continuation
をコントローラブルにした状態で Dependency を設定することができます。
最後に残りのテストのコードを一気に見ていきます。
final class LongLivingEffectsTests: XCTestCase {
func testReducer() async {
let (screenshots, takeScreenshot) = AsyncStream<Void>.streamWithContinuation()
// ...
// `.task` Action を send しつつ、それを `task` という変数に入れることで、
// `TestStoreTask` を手に入れる
let task = await store.send(.task)
// `takeScreenshot` = continuation を利用して、`yield` を呼び出すことで、
// スクリーンショットが撮られたことをシミュレートする
takeScreenshot.yield()
// AsyncStream に値が流れてきて、期待する Action が発火することをアサート
await store.receive(.userDidTakeScreenshotNotification) {
$0.screenshotCount = 1
}
// 機能を表示している画面が消えて AsyncStream の subscribe が終わることをシミュレートする
await task.cancel()
// subscribe が終わっているタイミングで continuation に対して `yield` しても、
// 何も Action が発火しないことを確認
takeScreenshot.yield()
}
}
コード中にコメントで説明しましたが、手に入れた continuation によって、AsyncStream がコントローラブルになっていることがわかると思います。
おわりに
本記事では TCA において AsyncStream を利用する方法とテストする方法について説明しました。
実は AsyncThrowingStream に対しても、同じような function たちが用意されているので、AsyncStream とほぼ同じ感覚でテストを記述することができます。
今回紹介したように TCA には Swift Concurrency を利用したコードをテストしやすくする仕組みがいくつか用意されています。
しかも、今回紹介した streamWithContinuation
もそうですが、その多くは TCA を利用していなくても利用できる仕組みであることが多く、TCA 利用者以外でも参考になる部分は結構あるのかなと感じています。
他にもいくつかの仕組みが用意されていますが、それについてはまた別の機会に紹介できればと思います!
Discussion