🌊

TCA で AsyncStream をテストする方法

2022/12/11に公開約10,200字

この記事は 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 の実装

02-Effects-LongLiving.swift
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
  )
}

DependencyValuesScreenshotsKey という独自の Dependency を登録しています。

また、ScreenshotsKey では liveValuetestValue を設定しています。
どちらもシンプルな実装ですが、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 が利用されています。

ConcurrencySupport.swift
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 実装部分で、以下のようになっています。

02-Effects-LongLiving.swift
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 を見てみることにします。

まず先にコードの全体像を示します。

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()
  }
}

割と短いですが、少しずつ説明してみます。

02-Effects-LongLivingTests.swift
final class LongLivingEffectsTests: XCTestCase {
  func testReducer() async {
    let (screenshots, takeScreenshot) = AsyncStream<Void>.streamWithContinuation()
    // ...
}

いきなりですが、ここが本記事で一番説明したい部分になります。
ここでは、AsyncStream を普段利用している人でも見慣れないであろう streamWithContinuation というものを AsyncStream に対して利用することで、screenshots, takeScreenshot という二つの値からなるタプルを得ていることがわかります。

streamWithContinuation は先ほどチラッと紹介した AsyncStream の独自 initializer と同様に、TCA 内部で定義されている function で、以下のような実装になっています。

ConcurrencySupport.swift
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)
  }
}

実装を見てみると実は非常にシンプルで、以下のような手順でタプルを構築しています。

  1. AsyncStream.Continuation 型の continuation を Implicitly Unwrapped Optional として定義する
  2. AsyncStream の標準 initializer である init(_:bufferingPolicy:_:) を利用して、AsyncStream を作成しつつ、1 で定義した AsyncStream.Continuation に initializer により得られる continuation をセットする
  3. 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 の作成部分を見ます。

02-Effects-LongLivingTests.swift
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 を設定することができます。

最後に残りのテストのコードを一気に見ていきます。

02-Effects-LongLivingTests.swift
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

ログインするとコメントできます