TCA の Concurrency なコードでも Clock で テスト・プレビューしやすくする

2022/12/24に公開約8,700字

この記事は The Composable Architecture Advent Calendar 2022 の 24 日目の記事になります。

なんとか、これを合わせて 7 つのアドベントカレンダーを書くことができました (よかった)
ただ、今回の記事は Testing を見れば大体わかるような内容になってしまっていて、そちらを見れば十分かなと感じています... (年末で気力が持ちませんでした😢)
せっかく日本語の記事として書いたので公開はしようかなと思います。

本記事では、Clock という API について簡単に紹介しつつ、TCA において Clock をどのように扱えるかについて説明します。

この記事では、以下を理解できるようになることを目指しています。

  • Clock API とは何なのか
    • この記事では Clock の詳細については触れず、ざっくり必要な話のみ紹介します
  • TCA で時間を操作する方法
  • TCA で Clock を利用する方法

Clock API とは?

Clock API は Swift 5.7 で導入された新しい機能で、この機能によって Swift で時間の概念がより扱いやすくなりました。
実際 Clock は Swift Concurrency の文脈で、既にさまざまなシーンで利用されており、例えば swift-async-algorithms における DebounceThrottle を表現するために利用されていたりします。

Clock 自体は protocol となっていて、以下のような interface を持っています。

public protocol Clock<Duration> : Sendable {

    associatedtype Duration where Self.Duration == Self.Instant.Duration

    associatedtype Instant : InstantProtocol

    var now: Self.Instant { get }

    var minimumResolution: Self.Duration { get }

    func sleep(until deadline: Self.Instant, tolerance: Self.Instant.Duration?) async throws
}

いくつかの制約を持った Duration, Instant というような associatedtype や、sleep という function を持っていることなどが特徴です。

Instant が満たしていなければならない InstantProtocol は以下のような inteface になっています。

public protocol InstantProtocol<Duration> : Comparable, Hashable, Sendable {

    associatedtype Duration : DurationProtocol

    func advanced(by duration: Self.Duration) -> Self

    func duration(to other: Self) -> Self.Duration
}

InstantProtocolDuration という associatedtype を持っており、この Duration によって advanced (時間を指定時間移動させる) や duration (他の時間との比較) などの操作を可能にしています。

Duration が満たす必要がある DurationProtocol は以下のような interface になっています。

public protocol DurationProtocol : AdditiveArithmetic, Comparable, Sendable {

    static func / (lhs: Self, rhs: Int) -> Self

    static func /= (lhs: inout Self, rhs: Int)

    static func * (lhs: Self, rhs: Int) -> Self

    static func *= (lhs: inout Self, rhs: Int)

    static func / (lhs: Self, rhs: Self) -> Double
}

このように、Clock 自体はいくつかの protocol が絡むような protocol となっています。
Clock 自体は protocol なので、Clock 自体の機能を使いたい時は、Clock に準拠した独自の Clock を利用するよりも、大体 Swift が提供してくれている ContinuousClockSuspendingClock などを利用することがほとんどだと思います。
ContinuousClock, SuspendingClock については、以下をご参照ください。

以上が TCA で Clock を利用する上で、最低限知っておくとよさそうなことになります。

TCA で時間を操作する方法

ここからは TCA で時間を操作する方法として、どんなものがあるかについて紹介します。

TCA で時間を操作する方法は主に、前述した Clock API や Task.sleep などがあります。
どちらも sleep という function を持っているため、使用感はほとんど同じような感じになります。

Task.sleep の場合は、concurrent context であれば Recucer の中などで以下のように扱うことができます。

return .run { send in
  for _ in 1...5 {
    try await Task.sleep(for: .seconds(1))
    await send(.timerTick)
  }
}

Clock の場合は、Dependency にいくつか用意されているものがあるため、以下のように扱うことができます。(詳細については後ほど説明します)

struct Feature: ReducerProtocol {
  struct State {}
  enum Action {}
  @Dependency(\.continuousClock) var clock
  
  // ...
    return .run { send in
      for _ in 1...5 {
        try await self.clock.sleep(for: .seconds(1))
        await send(.timerTick)
      }
    }
}

しかし、悲しくも Clock は iOS 16 から利用できるようになるものなので、iOS 15 以下では Clock を利用することができません...

TCA で Clock を使ってテスト・プレビューしやすくする

Task.sleep を利用すれば気軽に concurrent context で sleep させることができますが、testable / previewable にするという観点で Task.sleep を利用するよりも、基本的には Clock を利用していくのが良いとされています。
理由は単純で、Task.sleep を利用してしまうと、その sleep に対して外部から干渉することができなくなってしまうから (DI できなくなってしまう) です。

そのため、ここからは Task.sleep の話ではなく Clock に焦点を当てて説明していきます。

先ほど TCA で Clock を扱う際は、Dependency として用意されているものがあるという話をしましたが、TCA に内蔵されている Dependencies の中で、Clock を利用するための Dependency がいくつか定義されています。

Clocks.swift
#if swift(>=5.7) && (canImport(RegexBuilder) || !os(macOS) && !targetEnvironment(macCatalyst))
  import Clocks

  @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *)
  extension DependencyValues {
    public var continuousClock: any Clock<Duration> {
      get { self[ContinuousClockKey.self] }
      set { self[ContinuousClockKey.self] = newValue }
    }
    public var suspendingClock: any Clock<Duration> {
      get { self[SuspendingClockKey.self] }
      set { self[SuspendingClockKey.self] = newValue }
    }

    private enum ContinuousClockKey: DependencyKey {
      static let liveValue: any Clock<Duration> = ContinuousClock()
      static let testValue: any Clock<Duration> = UnimplementedClock(name: "ContinuousClock")
    }
    private enum SuspendingClockKey: DependencyKey {
      static let liveValue: any Clock<Duration> = SuspendingClock()
      static let testValue: any Clock<Duration> = UnimplementedClock(name: "SuspendingClock")
    }
  }
#endif

ContinuousClockKeySuspendingClockKey の構造はほとんど同じで、内部で ContinuousClock / SuspendingClock を利用しているかどうかの違いしかありません。
これらのどちらも、liveValuetestValue では any Clock<Duration> という形で型が定義されていることがわかると思います。

Clock protocol を思い出してみると、Duration という associatedtype を定義する必要がありましたが、ここでは any Clock<Duration> と定義することにより、Swift.Duration によって Duration associatedtype を満たすことができる Clock とすることができます。
また、コードの中に出てきている UnimplementedClock は Point-Free 製の swift-clocks というライブラリで実装されているものになっています。

https://github.com/pointfreeco/swift-clocks

TCA であればお馴染みですが、これによりテストで意図せず Clock Dependency が呼ばれることを防ぐことができます。

このように TCA で既に Dependency として扱える Clock がいくつか定義されているため、先ほど紹介したように以下のようなコードで Clock を扱うことができるようになっていました。

struct Feature: ReducerProtocol {
  struct State {}
  enum Action {}
  @Dependency(\.continuousClock) var clock
  
  // ...
    return .run { send in
      for _ in 1...5 {
        try await self.clock.sleep(for: .seconds(1))
        await send(.timerTick)
      }
    }
}

実は swift-clocks 内部で、Clock に対して以下のような protocol extension が施されているため、上記のように直感的な形 (clock.sleep(for:)) で Clock を利用することができるようになっているという話もあります。

Shims.swift
#if swift(>=5.7) && (canImport(RegexBuilder) || !os(macOS) && !targetEnvironment(macCatalyst))
  @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *)
  extension Clock {
    /// Suspends for the given duration.
    ///
    /// This method should be provided by the standard library, but it is not yet included. See this
    /// proposal for more information:
    /// <https://github.com/apple/swift-evolution/blob/main/proposals/0374-clock-sleep-for.md>
    @_disfavoredOverload
    public func sleep(
      for duration: Duration,
      tolerance: Duration? = nil
    ) async throws {
      try await self.sleep(until: self.now.advanced(by: duration), tolerance: tolerance)
    }
  }
#endif

このように Task.sleep ではなく Clock の仕組みを利用することによって、TCA における Dependencies の恩恵を受けられるようになり、結果としてテスタブルにすることができるようになります。

実際、テストやプレビューにおいては swift-clocks によって提供されている ImmediateClock, TestClock などを利用することによって、clock.sleep(for:) を利用しているコードでも、testable / previewable にすることができます。

ImmediateClock を利用すれば、sleep でどんな秒数を停止させるようにしていたとしても、sleep の処理を即座に完了させることができます。
そのため、sleep で停止させる秒数は重要ではなく、テストやプレビューにかかる時間を節約したい際に ImmediateClock は有用です。

TestClock を利用すれば、任意の秒数を以下のようなコードで advance させることができるため、テストで思いのままに時間を操ることができるようになります。

func testTimer() async {
  let clock = TestClock()
  let model = FeatureModel(clock: clock)

  XCTAssertEqual(model.count, 0)
  model.startTimerButtonTapped()

  // 時間を 1 秒進める
  // `clock.sleep(for:)` によって 1 秒待つような処理がある場合、
  // テストで実際に 1 秒間待つことなく、テストを進めることができる
  await clock.advance(by: .seconds(1))
  XCTAssertEqual(model.count, 1)
  
  // ...
}

おわりに

この記事では、Clock API についての簡単な説明と、TCA でどのように Clock を利用できるかについて説明しました。

swift-clocks の仕組みは、同じく Point-Free 製のライブラリである combine-schedulers と非常に似ていて、それぞれ Swift Concurrency 用 / Combine 用のライブラリとなっています。

swift-clocks 自体の実装についても興味深い点が多く、別の機会でその辺りを深掘って説明などできたら良いなと思っています。

Discussion

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