🟢

TCA公式チュートリアル日本語版(基礎編)

に公開

TCA(The Composable Architecture)における新機能の作成、副作用のレイヤリング、および機能の完全なテストスイートの記述に関する基本を探求します。

はじめての機能

機能のロジックと動作を実装するためにReducerプロトコルへの適合を作成する方法、そしてその機能を SwiftUI ビューに接続する方法を学びます。

公式Docはこちら↓
https://pointfreeco.github.io/swift-composable-architecture/1.0.0/tutorials/composablearchitecture/01-01-yourfirstfeature

セクション1:Reducerの作成

Composable Architectureで機能が構築される基本的な単位はReducerです。このプロトコルへの適合は、アプリケーションの機能のロジックと動作を表します。これには、アクションがシステムに送信されたときに現在の状態を次の状態にどのように進化させるか、およびエフェクトが外部の世界とどのように通信し、データをシステムにフィードバックするかということが含まれます。

そして最も重要なことは、機能のコアロジックと動作は、SwiftUI ビューに言及することなく完全に分離して構築できるため、分離して開発しやすくなり、再利用しやすくなり、テストしやすくなります。

まず、カウンターのロジックをカプセル化するシンプルなReducerを作成しましょう。機能にもっと興味深い動作を追加しますが、今のところはシンプルに始めましょう。

ステップ 1

CounterFeature.swift という名前の新しい Swift ファイルを作成し、Composable Architecture をインポートします。

http://github.com/pointfreeco/swift-composable-architecture#Installation

CounterFeature.swift
import ComposableArchitecture
No such module 'ComposableArchitecture' のエラーが出た場合の解決策

No such module 'ComposableArchitecture' のエラーを解決するには、Xcodeで以下の手順を実行してください。

  1. Xcodeでプロジェクトを開く:
    /Users/<your_name>/Swift/<project_name>/<project_name>.xcodeproj をXcodeで開きます。

  2. Swift Package Dependenciesの確認と追加:

    • Xcodeのプロジェクトナビゲータで、一番上のプロジェクト名("Todo")をクリックします。
    • プロジェクト設定画面で「Swift Packages」タブを選択します。
    • swift-composable-architecture がこのリストにあるか確認してください。
      • もしリストにない場合は、「File」>「Add Packages...」を選択します。
      • 検索バーに https://github.com/pointfreeco/swift-composable-architecture
        を貼り付け、Enterキーを押します。
      • 表示されたパッケージを選択し、「Add Package」をクリックしてプロジェクトに追加します。
  3. ターゲットへのパッケージのリンクを確認:

    • プロジェクト設定画面で、サイドバーからターゲット(通常は「Todo」)を選択します。
    • 「Build Phases」タブに移動します。
    • 「Link Binary With Libraries」セクションを展開し、ComposableArchitecture
      がこのリストにあるか確認してください。
      • もしリストにない場合は、+ ボタンをクリックし、ComposableArchitecture
        を検索して選択し、「Add」をクリックして追加します。
  4. クリーンビルドと再ビルド:

    • Xcodeのメニューバーから「Product」>「Clean Build
      Folder」を選択し、ビルドキャッシュをクリアします。
    • その後、「Product」>「Build」を選択してプロジェクトを再ビルドします。

ステップ 2

Reducer プロトコルに適合する CounterFeature という新しい構造体を定義します。

CounterFeature.swift
import ComposableArchitecture

+
+ struct CounterFeature: Reducer {
+
+ }

ステップ 3

Reducer に適合するには、ドメインモデリングの演習から始めます。機能がそのジョブを実行するために必要な状態を保持する State 型 (通常は構造体) を作成します。次に、ユーザーが機能で実行できるすべてのアクションを保持する Action 型 (通常は enum) を作成します。

CounterFeature.swift
import ComposableArchitecture

struct CounterFeature: Reducer {
+  struct State {

+  }
+
+  enum Action {
+
+  }
}

ステップ 4

シンプルなカウンター機能の目的では、状態は単一の整数 (現在のカウント) のみで構成され、アクションはカウントを増減するためのボタンをタップすることのみで構成されます。

ヒント

Action ケースの名前は、incrementCount のような実行したいロジックではなく、incrementButtonTapped のようにユーザーが UI で行うことを文字通りに名付けるのが最善です。

CounterFeature.swift
import ComposableArchitecture

struct CounterFeature: Reducer {
  struct State {
+    var count = 0
  }

  enum Action {
+    case decrementButtonTapped
+    case incrementButtonTapped
  }
}

ステップ 5

そして最後に、Reducer への適合を完了するには、ユーザーアクションが与えられたときに状態を現在の値から次の値に進化させ、機能が外部の世界で実行したいエフェクトを返す reduce(into:action:) メソッドを実装する必要があります。これは、ほとんどの場合、受信したアクションを切り替えて、実行する必要のあるロジックを決定することから始まります。

注記

reduce メソッドは State を引数として受け取り、inout とマークされています。これは、状態に直接変更を加えられることを意味します。状態を返すために状態のコピーを作成する必要はありません。

CounterFeature.swift
import ComposableArchitecture

struct CounterFeature: Reducer {
  struct State {
    var count = 0
  }

  enum Action {
    case decrementButtonTapped
    case incrementButtonTapped
  }
+
+  func reduce(into state: inout State, action: Action) -> Effect<Action> {
+    switch action {
+    case .decrementButtonTapped:
+
+    case .incrementButtonTapped:
+
+    }
+  }
}

ステップ 6

この機能のロジックは非常にシンプルです。状態のカウントを 1 減らすか、1 増やすだけです。また、外部の世界で実行されるエフェクトを表す Effect の値を返さなければなりませんが、この場合は何も実行する必要はありません。したがって、実行するエフェクトがないことを表す特別な none の値を返すことができます。

CounterFeature.swift
import ComposableArchitecture

struct CounterFeature: Reducer {
  struct State {
    var count = 0
  }

  enum Action {
    case decrementButtonTapped
    case incrementButtonTapped
  }

  func reduce(into state: inout State, action: Action) -> Effect<Action> {
    switch action {
    case .decrementButtonTapped:
+      state.count -= 1
+      return .none

    case .incrementButtonTapped:
+      state.count += 1
+      return .none
    }
  }
}

Composable Architecture で非常に基本的な機能を実装するために必要なのはこれだけです。もちろん、エフェクトの実行とデータのシステムへのフィードバック、Reducerでの依存関係の使用、複数のReducerの組み合わせなど、知っておくべきことはたくさんあります。しかし、この機能についてはここで終了し、ビューに移りましょう。

セクション2:SwiftUI との統合

Reducerとして構築されたシンプルな機能ができたので、その機能から SwiftUI ビューを動かす方法を考える必要があります。これには 2 つの新しい概念が含まれます。機能のランタイムを表す Store と、ランタイムの監視を表す ViewStore です。

ステップ 1

Reducerとビューを同じファイルに保持するのは、それが持続不可能になるまでという個人的な好みですが、他の人は型を独自のファイルに分割することを好みます。このチュートリアルでは、すべてのものを CounterFeature.swift に入れ続け、今度は SwiftUI をインポートし、基本的なビューを配置します。

CounterFeature.swift
import ComposableArchitecture
import SwiftUI

struct CounterView: View {
  var body: some View {
    EmptyView()
  }
}

ステップ 2

ビューに追加する最初のものは、前のセクションで構築したReducerに対するジェネリックな Store です。Store は機能のランタイムを表します。つまり、状態を更新するためにアクションを処理し、エフェクトを実行してそのエフェクトからのデータをシステムにフィードバックできるオブジェクトです。

ヒント

ストアは let として保持できます。ビューによって監視される必要はありません。

CounterFeature.swift
struct CounterView: View {
+  let store: StoreOf<CounterFeature>
+
  var body: some View {
    EmptyView()
  }
}

ステップ 3

次に、カウントを表示し、増減ボタンを提供する基本的なビュー階層を実装できます。

注記

Store から直接状態を読み取ることも、直接アクションを送信することもできません。したがって、今のところはその動作のスタブを提供しますが、ViewStore が追加されたら、実際の実装を提供できます。

CounterFeature.swift
struct CounterView: View {
  let store: StoreOf<CounterFeature>

  var body: some View {
+    VStack {
+      Text("0")
+        .font(.largeTitle)
+        .padding()
+        .background(Color.black.opacity(0.1))
+        .cornerRadius(10)
+      HStack {
+        Button("-") {
+        }
+        .font(.largeTitle)
+        .padding()
+        .background(Color.black.opacity(0.1))
+        .cornerRadius(10)
+
+        Button("+") {
+        }
+        .font(.largeTitle)
+        .padding()
+        .background(Color.black.opacity(0.1))
+        .cornerRadius(10)
+      }
+    }
  }
}

基本的なビューの足場が整ったので、ストアで実際に状態を監視できるようになりました。これは ViewStore を構築することで行われ、SwiftUI ビューの場合、ViewStore を構築するための軽量な構文を提供する WithViewStore という便利なビューがあります。

シミュレータのUI

ステップ 4

ViewStore は State が Equatable であることを要求するため、まずそれを行う必要があります。ViewStore が構築されると、機能の状態にアクセスし、ユーザーがボタンをタップしたときにアクションを送信できます。

ヒント

現在は observe: { $0 } を使用してストア内のすべての状態を監視していますが、通常、機能はビューで必要なものよりもはるかに多くの状態を保持します。ビューがそのジョブを実行するために必要な最小限のものを最適に監視する方法については、Performance を参照してください。

CounterFeature.swift
+extension CounterFeature.State: Equatable {}

struct CounterView: View {
  let store: StoreOf<CounterFeature>

  var body: some View {
+    WithViewStore(self.store, observe: { $0 }) { viewStore in
      VStack {
+        Text("\(viewStore.count)")
          .font(.largeTitle)
          .padding()
          .background(Color.black.opacity(0.1))
          .cornerRadius(10)
        HStack {
          Button("-") {
+            viewStore.send(.decrementButtonTapped)
          }
          .font(.largeTitle)
          .padding()
          .background(Color.black.opacity(0.1))
          .cornerRadius(10)

          Button("+") {
+            viewStore.send(.incrementButtonTapped)
          }
          .font(.largeTitle)
          .padding()
          .background(Color.black.opacity(0.1))
          .cornerRadius(10)
        }
      }
+    }
  }
}

ステップ 5

次に、実際に機能を実行したいとします。プレビューから始めます。これを行うには CounterView を構築する必要があり、そのためには StoreOf<CounterFeature> を構築する必要があります。ストアは、機能が開始する初期状態と、機能を動かすReducerを指定する末尾のクロージャを提供することで構築できます。

それが完了したら、プレビューを実行して、「+」および「-」ボタンをタップすると、UI の表示カウントが実際に変化することを確認できます。

CounterFeature.swift
struct CounterPreview: PreviewProvider {
  static var previews: some View {
    CounterView(
      store: Store(initialState: CounterFeature.State()) {
        CounterFeature()
      }
    )
  }
}

次のセクションに進む前に、Composable Architecture のスーパーパワーを素早く紹介しましょう。機能のすべてのロジックと動作がReducerに含まれているため、完全に異なるReducerでプレビューを実行して、その実行方法を変更できます。

ステップ 6

たとえば、プレビューで CounterFeature Reducerをコメントアウトすると、ストアには状態の変異やエフェクトを実行しないReducerが与えられます。これにより、そのロジックや動作を気にすることなく、機能のデザインをプレビューできます。

CounterFeature.swift
struct CounterPreview: PreviewProvider {
  static var previews: some View {
    CounterView(
      store: Store(initialState: CounterFeature.State()) {
+        // CounterFeature()
      }
    )
  }
}

ステップ 7

次に進む前に、プレビューで CounterFeature Reducerを元に戻すようにしてください。そうしないと、次の探求でも不活性なままになります。

Composable Architecture で基本的な機能を実装するのはこれだけです。もちろん、機能は非常にシンプルで、副作用のような興味深いものは含まれていません (これについては、Adding side effects で説明します) が、その前に、この機能をアプリケーションに統合する方法を見てみましょう。

セクション3:アプリへの統合

Composable Architecture の機能を SwiftUI ビューに統合し、プレビューで実行できるようになりました。次に、アプリケーションのエントリポイントを変更して、完全なアプリケーションで機能を実行する方法を考える必要があります。これにより、シミュレーターまたはデバイスで機能を実行できるようになります。

ステップ 1

デフォルトでは、アプリケーションのエントリポイントは App.swift というファイルにあり、次のようなコードになっているはずです。

App.swift
import SwiftUI

@main
struct MyApp: App {
  var body: some Scene {
    WindowGroup {
      ContentView()
    }
  }
}

ステップ 2

アプリケーションのエントリポイントを変更して、プレビューで行ったように CounterView を構築し、ストアを提供します。

App.swift
+ import ComposableArchitecture
import SwiftUI

@main
struct MyApp: App {
  var body: some Scene {
    WindowGroup {
+      CounterView(
+        store: Store(initialState: CounterFeature.State()) {
+          CounterFeature()
+        }
+      )
    }
  }
}

ステップ 3

アプリケーションを動かす Store は、一度だけ作成されることが重要です。ほとんどのアプリケーションでは、シーンのルートにある WindowGroup で直接作成するだけで十分です。しかし、静的変数として保持し、シーンで提供することもできます。

App.swift
import ComposableArchitecture
import SwiftUI

@main
struct MyApp: App {
+  static let store = Store(initialState: CounterFeature.State()) {
+    CounterFeature()
+  }
+
  var body: some Scene {
    WindowGroup {
+      CounterView(store: MyApp.store)
    }
  }
}

Composable Architecture のもう 1 つのスーパーパワーを紹介しましょう。Reducerには、SwiftUI が提供するツールに似た printChanges(:) というメソッドがあります。これを使用すると、Reducerが処理するすべてのアクションがコンソールに出力され、アクション処理後の状態の変化が表示されます。このメソッドは、状態の差分をコンパクトな形式に折りたたむために多大な労力を費やします。これには、変更されていないネストされた状態を表示しないことや、変更されていないコレクション内の要素を表示しないことが含まれます。

ステップ 4

アプリケーションのエントリポイントを更新して、Reducerで printChanges(:) を呼び出すようにします。

App.swift
import ComposableArchitecture
import SwiftUI

@main
struct MyApp: App {
  static let store = Store(initialState: CounterFeature.State()) {
    CounterFeature()
+      ._printChanges()
  }

  var body: some Scene {
    WindowGroup {
      CounterView(store: MyApp.store)
    }
  }
}

ステップ 5

これで、シミュレーターでアプリケーションを実行し、「+」および「-」ボタンを数回タップすると、何が起こっているかを示すログがコンソールに正確に表示されるはずです。

サイドエフェクトの追加

機能内で外部と通信し、外部から機能にデータを供給する方法を学びます。

公式Docはこちら↓
https://pointfreeco.github.io/swift-composable-architecture/1.0.0/tutorials/composablearchitecture/01-02-addingsideeffects

セクション1:サイドエフェクトとは?

サイドエフェクトは、機能開発において最も重要な側面です。これらは、APIリクエストの作成、ファイルシステムとのやり取り、時間ベースの非同期処理の実行など、外部との通信を可能にします。サイドエフェクトがなければ、アプリケーションはユーザーにとって真に価値のあることを何もできません。

しかし、サイドエフェクトは機能の最も複雑な部分でもあります。状態の変更は単純なプロセスです。同じ状態とアクションでリデューサーを実行すると、常に同じ結果が得られます。しかし、エフェクトはネットワーク接続、ディスク権限など、外部環境の気まぐれな影響を受けやすいです。エフェクトを実行するたびに、まったく異なる結果が得られる可能性があります。

まず、Reducer準拠内でエフェクトフルな作業を直接実行できない理由を見てから、ライブラリがエフェクトを実行するために提供するツールを見ていきましょう。

ステップ1

「最初の機能」で作成したカウンター機能に新しい機能を追加しましょう。これは、タップすると現在表示されている数字に関する情報を取得するためのネットワークリクエストを行うボタンを追加するものです。

これには2つのアプローチがあります。リデューサーから作業を開始し、次にビューに進むことを好む人もいますが、今回はビューから始めます。CounterViewの下部にボタンを追加し、タップされるとfactButtonTappedアクションを送信します。このアクションはまだカウンターのドメインには存在しませんが、すぐに作成します。

CounterFeature.swift
struct CounterView: View {
  let store: StoreOf<CounterFeature>

  var body: some View {
    WithViewStore(self.store, observe: { $0 }) { viewStore in
      VStack {
        Text("\(viewStore.count)")
          .font(.largeTitle)
          .padding()
          .background(Color.black.opacity(0.1))
          .cornerRadius(10)
        HStack {
          Button("-") {
            viewStore.send(.decrementButtonTapped)
          }
          .font(.largeTitle)
          .padding()
          .background(Color.black.opacity(0.1))
          .cornerRadius(10)

          Button("+") {
            viewStore.send(.incrementButtonTapped)
          }
          .font(.largeTitle)
          .padding()
          .background(Color.black.opacity(0.1))
          .cornerRadius(10)
        }
+        Button("Fact") {
+          viewStore.send(.factButtonTapped)
+        }
+        .font(.largeTitle)
+        .padding()
+        .background(Color.black.opacity(0.1))
+        .cornerRadius(10)
      }
    }
  }
}

ステップ2

また、fact を読み込んでいる間に表示するためのプログレスビューを下部に追加し、fact を表示するためにオプションの状態を少し展開します。これを実現するためにisLoadingとfactの状態を使用していますが、これらはまだカウンター機能には存在しませんが、すぐに作成します。

CounterFeature.swift
struct CounterView: View {
  let store: StoreOf<CounterFeature>

  var body: some View {
    WithViewStore(self.store, observe: { $0 }) { viewStore in
      VStack {
        Text("\(viewStore.count)")
          .font(.largeTitle)
          .padding()
          .background(Color.black.opacity(0.1))
          .cornerRadius(10)
        HStack {
          Button("-") {
            viewStore.send(.decrementButtonTapped)
          }
          .font(.largeTitle)
          .padding()
          .background(Color.black.opacity(0.1))
          .cornerRadius(10)

          Button("+") {
            viewStore.send(.incrementButtonTapped)
          }
          .font(.largeTitle)
          .padding()
          .background(Color.black.opacity(0.1))
          .cornerRadius(10)
        }
        Button("Fact") {
          viewStore.send(.factButtonTapped)
        }
        .font(.largeTitle)
        .padding()
        .background(Color.black.opacity(0.1))
        .cornerRadius(10)
+
+       if viewStore.isLoading {
+         ProgressView()
+       } else if let fact = viewStore.fact {
+         Text(fact)
+           .font(.largeTitle)
+           .multilineTextAlignment(.center)
+           .padding()
+       }
      }
    }
  }
}

ビューに関してはこれで完了です。まだ存在しない状態やアクションを使用しているため、コードはまだコンパイルされません。リデューサーでこれらのエラーを修正しましょう。

ステップ3

「最初の機能」で作成したCounterFeatureリデューサーは、これまで次のようになっていました。シンプルなStateとAction型を持ち、リデューサーは送信されたアクションに応じてカウント状態を増減するだけです。

CounterFeature.swift
import ComposableArchitecture

struct CounterFeature: Reducer {
  struct State: Equatable {
    var count = 0
  }

  enum Action {
    case decrementButtonTapped
    case incrementButtonTapped
  }

  func reduce(into state: inout State, action: Action) -> Effect<Action> {
    switch action {
    case .decrementButtonTapped:
      state.count -= 1
      return .none

    case .incrementButtonTapped:
      state.count += 1
      return .none
    }
  }
}

ステップ4

ビューによって指示された追加の状態とアクションを追加しましょう。factとisLoadingの状態が必要であり、factButtonTappedアクションも必要です。そのアクションをリデューサーで実装し、isLoadingをtrueに切り替え、いずれかのボタンがタップされたときにfact状態をクリアします。そして最後に、他のすべての場合と同様に.noneを返します。

CounterFeature.swift
import ComposableArchitecture

struct CounterFeature: Reducer {
  struct State: Equatable {
    var count = 0
+   var fact: String?
+   var isLoading = false
  }

  enum Action {
    case decrementButtonTapped
+   case factButtonTapped
    case incrementButtonTapped
  }

  func reduce(into state: inout State, action: Action) -> Effect<Action> {
    switch action {
    case .decrementButtonTapped:
      state.count -= 1
+     state.fact = nil
      return .none

+   case .factButtonTapped:
+     state.fact = nil
+     state.isLoading = true
+     return .none
+
    case .incrementButtonTapped:
      state.count += 1
+     state.fact = nil
      return .none
    }
  }
}

ステップ5

さて、サイドエフェクトをどのように実行できるかという問題です。状態の現在のカウントに関する情報を取得するためにnumbersapi.comを使用します。リデューサーでURLSessionを直接使用して非同期作業を実行できることを期待するかもしれませんが、残念ながらそれは許可されていません。

CounterFeature.swift
import ComposableArchitecture

struct CounterFeature: Reducer {
  struct State: Equatable {
    var count = 0
    var fact: String?
    var isLoading = false
  }

  enum Action {
    case decrementButtonTapped
    case factButtonTapped
    case incrementButtonTapped
  }

  func reduce(into state: inout State, action: Action) -> Effect<Action> {
    switch action {
    case .decrementButtonTapped:
      state.count -= 1
      state.fact = nil
      return .none

    case .factButtonTapped:
      state.fact = nil
      state.isLoading = true
+
+     let (data, _) = try await URLSession.shared
+       .data(from: URL(string: "http://numbersapi.com/\(state.count)")!)
+     // 🛑 'async' call in a function that does not support concurrency
+     // 🛑 Errors thrown from here are not handled
+
+     state.fact = String(decoding: data, as: UTF8.self)
+     state.isLoading = false
+
      return .none

    case .incrementButtonTapped:
      state.count += 1
      state.fact = nil
      return .none
    }
  }
}

Composable Architectureは、状態の単純で純粋な変換を、複雑で煩雑なサイドエフェクトから分離します。これはライブラリのコア原則の1つであり、そうすることには多くの利点があります。幸いなことに、ライブラリはサイドエフェクトを実行するのに適切なツールを提供しています。Effectと呼ばれ、次のセクションで詳しく説明します。

セクション2:ネットワークリクエストの実行

サイドエフェクトとは何か、そしてなぜリデューサーで直接実行できないのかを理解したところで、上記で書いたコードを修正する方法を見ていきましょう。

Composable Architectureは、「エフェクト」の概念をReducerの定義に直接組み込んでいます。リデューサーが状態を変更することでアクションを処理した後、Effectと呼ばれるものを返すことができます。これは、Storeによって実行される非同期単位を表します。エフェクトは、外部システムと通信し、外部からリデューサーにデータを供給できるものです。

これはまさに、数字のファクトエフェクトで行いたいことです。ネットワークリクエストを行い、その情報をリデューサーにフィードバックしたいのです。では、始めましょう。

ステップ1

Effectを構築する主な方法は、静的メソッドrun(priority:operation:catch:fileID:line:)を使用することです。これにより、あらゆる種類の作業を実行するための非同期コンテキストと、システムにアクションを送信するためのハンドル (send) が提供されます。

CounterFeature.swift
import ComposableArchitecture

struct CounterFeature: Reducer {
  struct State: Equatable {
    var count = 0
    var fact: String?
    var isLoading = false
  }

  enum Action {
    case decrementButtonTapped
    case factButtonTapped
    case incrementButtonTapped
  }

  func reduce(into state: inout State, action: Action) -> Effect<Action> {
    switch action {
    case .decrementButtonTapped:
      state.count -= 1
      state.fact = nil
      return .none

    case .factButtonTapped:
      state.fact = nil
      state.isLoading = true
      return .run { send in
        // ✅ ここで非同期作業を実行し、アクションをシステムに送信します。
      }

    case .incrementButtonTapped:
      state.count += 1
      state.fact = nil
      return .none
    }
  }
}

ステップ2

.runの末尾クロージャは、numbersapi.comからデータをフェッチし、それを文字列に変換するネットワークリクエストを実行するのに最適な場所です。

ヒント

残念ながらnumbersapi.comはHTTPSを提供していないため、HTTPリクエストを許可するためにアプリケーションのInfo.plistにエントリを追加する必要があります。その方法については、この記事を参照してください。

CounterFeature.swift
import ComposableArchitecture

struct CounterFeature: Reducer {
  struct State: Equatable {
    var count = 0
    var fact: String?
    var isLoading = false
  }

  enum Action {
    case decrementButtonTapped
    case factButtonTapped
    case incrementButtonTapped
  }

  func reduce(into state: inout State, action: Action) -> Effect<Action> {
    switch action {
    case .decrementButtonTapped:
      state.count -= 1
      state.fact = nil
      return .none

    case .factButtonTapped:
      state.fact = nil
      state.isLoading = true
+      return .run { [count = state.count] send in
+        let (data, _) = try await URLSession.shared
+          .data(from: URL(string: "http://numbersapi.com/\(count)")!)
+        let fact = String(decoding: data, as: UTF8.self)
      }

    case .incrementButtonTapped:
      state.count += 1
      state.fact = nil
      return .none
    }
  }
}

ステップ3

しかし、ネットワークからデータをフェッチした後、エフェクト内でstate.countを変更することはできません。これは、送信可能なクロージャがinout状態をキャプチャできないため、コンパイラによって厳密に強制されます。これは、ライブラリが、リデューサーが実行するきれいで単純で純粋な状態の変更と、煩雑で複雑なエフェクトをどのように分離しているかを示しています。

CounterFeature.swift
import ComposableArchitecture

struct CounterFeature: Reducer {
  struct State: Equatable {
    var count = 0
    var fact: String?
    var isLoading = false
  }

  enum Action {
    case decrementButtonTapped
    case factButtonTapped
    case incrementButtonTapped
  }

  func reduce(into state: inout State, action: Action) -> Effect<Action> {
    switch action {
    case .decrementButtonTapped:
      state.count -= 1
      state.fact = nil
      return .none

    case .factButtonTapped:
      state.fact = nil
      state.isLoading = true
      return .run { [count = state.count] send in
        let (data, _) = try await URLSession.shared
          .data(from: URL(string: "http://numbersapi.com/\(count)")!)
        let fact = String(decoding: data, as: UTF8.self)
+        state.fact = fact
+        // 🛑 並行実行中のコードでは 'inout' パラメータ 'state' の可変キャプチャは許可されていません
      }

    case .incrementButtonTapped:
      state.count += 1
      state.fact = nil
      return .none
    }
  }
}

ステップ4

エフェクトからの情報をリデューサーにフィードバックするために、factResponseという別のアクションを導入する必要があります。これは、ネットワークから取得した文字列の関連値を持つことになります。そして、非同期作業を実行した後、エフェクト内でそのアクションを送信し、新しいアクションを処理してisLoadingをfalseに戻し、fact状態を更新できます。

警告

現在、URLSessionがスローする可能性のあるエラーを無視しています。より完全なアプリケーションでは、エラーをリデューサーにフィードバックして適切に対応できるようにするために、TaskResultを活用することをお勧めします。

CounterFeature.swift
import ComposableArchitecture

struct CounterFeature: Reducer {
  struct State: Equatable {
    var count = 0
    var fact: String?
    var isLoading = false
  }

  enum Action {
    case decrementButtonTapped
    case factButtonTapped
+    case factResponse(String)
    case incrementButtonTapped
  }

  func reduce(into state: inout State, action: Action) -> Effect<Action> {
    switch action {
    case .decrementButtonTapped:
      state.count -= 1
      state.fact = nil
      return .none

    case .factButtonTapped:
      state.fact = nil
      state.isLoading = true
      return .run { [count = state.count] send in
        let (data, _) = try await URLSession.shared
          .data(from: URL(string: "http://numbersapi.com/\(count)")!)
        let fact = String(decoding: data, as: UTF8.self)
+        await send(.factResponse(fact))
      }

+    case let .factResponse(fact):
+      state.fact = fact
+      state.isLoading = false
+      return .none
+
    case .incrementButtonTapped:
      state.count += 1
      state.fact = nil
      return .none
    }
  }
}

ステップ5

これで、シミュレーターまたはプレビューでアプリケーションを実行し、機能が期待どおりに動作することを確認できます。数を数え、"Fact"ボタンをタップすると、しばらくしてUIの下部に情報が表示されます。

セクション3:タイマーの管理

ネットワークリクエストはおそらく最も一般的なサイドエフェクトの種類の1つですが、それだけではありません。カウンター機能に新しい機能を追加しましょう。これは、タップすると1秒間隔で繰り返されるタイマーを開始し、各ティックで状態のカウントを1ずつ増加させる別のボタンを追加するものです。

ステップ1

以前と同様に、ビューレイヤーから始めます。isTimerRunningの状態に応じて「タイマーを停止」または「タイマーを開始」と表示されるボタンを追加し、タップされるとtoggleTimerButtonTappedアクションを送信します。ビューに関してはこれで完了です。

isTimerRunningの状態とtoggleTimerButtonTappedアクションはまだ存在しません。次のステップで追加します。

CounterFeature.swift
struct CounterView: View {
  let store: StoreOf<CounterFeature>

  var body: some View {
    WithViewStore(self.store, observe: { $0 }) { viewStore in
      VStack {
        Text("\(viewStore.count)")
          .font(.largeTitle)
          .padding()
          .background(Color.black.opacity(0.1))
          .cornerRadius(10)
        HStack {
          Button("-") {
            viewStore.send(.decrementButtonTapped)
          }
          .font(.largeTitle)
          .padding()
          .background(Color.black.opacity(0.1))
          .cornerRadius(10)

          Button("+") {
            viewStore.send(.incrementButtonTapped)
          }
          .font(.largeTitle)
          .padding()
          .background(Color.black.opacity(0.1))
          .cornerRadius(10)
        }
        Button(viewStore.isTimerRunning ? "Stop timer" : "Start timer") {
          viewStore.send(.toggleTimerButtonTapped)
        }
        .font(.largeTitle)
        .padding()
        .background(Color.black.opacity(0.1))
        .cornerRadius(10)

        Button("Fact") {
          viewStore.send(.factButtonTapped)
        }
        .font(.largeTitle)
        .padding()
        .background(Color.black.opacity(0.1))
        .cornerRadius(10)

        if viewStore.isLoading {
          ProgressView()
        } else if let fact = viewStore.fact {
          Text(fact)
            .font(.largeTitle)
            .multilineTextAlignment(.center)
            .padding()
        }
      }
    }
  }
}

ステップ2

次に、isTimerRunningの状態、新しいtoggleTimerButtonTappedアクションを追加し、新しいアクションのロジックの基本をスタブ化することで、リデューサーを更新します。タイマー、特にタイマーとして何らかの非同期作業を実行したいので、runエフェクトを開きました。

CounterFeature.swift
import ComposableArchitecture

struct CounterFeature: Reducer {
  struct State: Equatable {
    var count = 0
    var fact: String?
    var isLoading = false
    var isTimerRunning = false
  }

  enum Action {
    case decrementButtonTapped
    case factButtonTapped
    case factResponse(String)
    case incrementButtonTapped
    case toggleTimerButtonTapped
  }

  func reduce(into state: inout State, action: Action) -> Effect<Action> {
    switch action {
    case .decrementButtonTapped:
      state.count -= 1
      state.fact = nil
      return .none

    case .factButtonTapped:
      state.fact = nil
      state.isLoading = true
      return .run { [count = state.count] send in
        let (data, _) = try await URLSession.shared
          .data(from: URL(string: "http://numbersapi.com/\(count)")!)
        let fact = String(decoding: data, as: UTF8.self)
        await send(.factResponse(fact))
      }

    case let .factResponse(fact):
      state.fact = fact
      state.isLoading = false
      return .none

    case .incrementButtonTapped:
      state.count += 1
      state.fact = nil
      return .none

    case .toggleTimerButtonTapped:
      state.isTimerRunning.toggle()
      return .run { send in
      }
    }
  }
}

ステップ3

runエフェクト内では、無限のwhileループを開始し、ループ内で1秒間スリープすることで、非常に粗雑なタイマーをエミュレートできます。これはタイマーを構築する最善の方法ではありません。ドリフトが発生する可能性がありますが、現時点ではこれで十分です。

CounterFeature.swift
import ComposableArchitecture

struct CounterFeature: Reducer {
  struct State: Equatable {
    var count = 0
    var fact: String?
    var isLoading = false
    var isTimerRunning = false
  }

  enum Action {
    case decrementButtonTapped
    case factButtonTapped
    case factResponse(String)
    case incrementButtonTapped
    case toggleTimerButtonTapped
  }

  func reduce(into state: inout State, action: Action) -> Effect<Action> {
    switch action {
    case .decrementButtonTapped:
      state.count -= 1
      state.fact = nil
      return .none

    case .factButtonTapped:
      state.fact = nil
      state.isLoading = true
      return .run { [count = state.count] send in
        let (data, _) = try await URLSession.shared
          .data(from: URL(string: "http://numbersapi.com/\(count)")!)
        let fact = String(decoding: data, as: UTF8.self)
        await send(.factResponse(fact))
      }

    case let .factResponse(fact):
      state.fact = fact
      state.isLoading = false
      return .none

    case .incrementButtonTapped:
      state.count += 1
      state.fact = nil
      return .none

    case .toggleTimerButtonTapped:
      state.isTimerRunning.toggle()
      return .run { send in
+        while true {
+          try await Task.sleep(for: .seconds(1))
+        }
      }
    }
  }
}

ステップ4

リデューサーで各タイマーティックに反応するために、新しいアクションtimerTickを導入する必要があります。これはTask.sleepの後に送信され、そのアクションで状態のカウントを増加させます。

CounterFeature.swift
import ComposableArchitecture

struct CounterFeature: Reducer {
  struct State: Equatable {
    var count = 0
    var fact: String?
    var isLoading = false
    var isTimerRunning = false
  }

  enum Action {
    case decrementButtonTapped
    case factButtonTapped
    case factResponse(String)
    case incrementButtonTapped
+    case timerTick
    case toggleTimerButtonTapped
  }

  func reduce(into state: inout State, action: Action) -> Effect<Action> {
    switch action {
    case .decrementButtonTapped:
      state.count -= 1
      state.fact = nil
      return .none

    case .factButtonTapped:
      state.fact = nil
      state.isLoading = true
      return .run { [count = state.count] send in
        let (data, _) = try await URLSession.shared
          .data(from: URL(string: "http://numbersapi.com/\(count)")!)
        let fact = String(decoding: data, as: UTF8.self)
        await send(.factResponse(fact))
      }

    case let .factResponse(fact):
      state.fact = fact
      state.isLoading = false
      return .none

    case .incrementButtonTapped:
      state.count += 1
      state.fact = nil
      return .none

+    case .timerTick:
+      state.count += 1
+      state.fact = nil
+      return .none

    case .toggleTimerButtonTapped:
      state.isTimerRunning.toggle()
      return .run { send in
        while true {
          try await Task.sleep(for: .seconds(1))
+          await send(.timerTick)
        }
      }
    }
  }
}

しかし、バグがあります。「タイマーを開始」ボタンをタップしてタイマーを開始し、「タイマーを停止」をタップしても、タイマーが停止しません。

ステップ5

このバグを修正するには、Composable Architectureの強力な機能である「エフェクトキャンセル」を活用できます。IDを提供してcancellable(id:cancelInFlight:)メソッドを使用すると、任意のエフェクトをキャンセル可能としてマークでき、後でcancel(id:)を使用してそのエフェクトをキャンセルできます。

CounterFeature.swift
import ComposableArchitecture

struct CounterFeature: Reducer {
  struct State: Equatable {
    var count = 0
    var fact: String?
    var isLoading = false
    var isTimerRunning = false
  }

  enum Action {
    case decrementButtonTapped
    case factButtonTapped
    case factResponse(String)
    case incrementButtonTapped
    case timerTick
    case toggleTimerButtonTapped
  }

+  enum CancelID { case timer }
+
  func reduce(into state: inout State, action: Action) -> Effect<Action> {
    switch action {
    case .decrementButtonTapped:
      state.count -= 1
      state.fact = nil
      return .none

    case .factButtonTapped:
      state.fact = nil
      state.isLoading = true
      return .run { [count = state.count] send in
        let (data, _) = try await URLSession.shared
          .data(from: URL(string: "http://numbersapi.com/\(count)")!)
        let fact = String(decoding: data, as: UTF8.self)
        await send(.factResponse(fact))
      }

    case let .factResponse(fact):
      state.fact = fact
      state.isLoading = false
      return .none

    case .incrementButtonTapped:
      state.count += 1
      state.fact = nil
      return .none

    case .timerTick:
      state.count += 1
      state.fact = nil
      return .none

    case .toggleTimerButtonTapped:
      state.isTimerRunning.toggle()
+      if state.isTimerRunning {
+        return .run { send in
+          while true {
+            try await Task.sleep(for: .seconds(1))
+            await send(.timerTick)
+          }
+        }
+        .cancellable(id: CancelID.timer)
+      } else {
+        return .cancel(id: CancelID.timer)
+      }
    }
  }
}

ステップ6

これで、機能は期待どおりに動作します。タイマーを開始し、数秒待ってカウントアップするのを確認し、タイマーを停止できます。これは、エフェクトのキャンセル方法を含む、長時間実行されるエフェクトを管理する方法を示しています。

機能のテスト

以前のチュートリアルで構築したカウンターのテスト方法を学びます。これには、状態の変化をアサートする方法、およびエフェクトがどのように実行され、システムにデータをフィードバックするかを含みます。

公式Docはこちら↓
https://pointfreeco.github.io/swift-composable-architecture/1.0.0/tutorials/composablearchitecture/01-03-testingyourfeature

セクション1:状態の変化をテストする

Composable Architectureで構築された機能でテストする必要があるのはリデューサーだけであり、それは2つのことをテストすることに帰着します。アクションが送信されたときに状態がどのように変化するか、そしてエフェクトがどのように実行され、そのデータをリデューサーにフィードバックするかです。

Composable Architectureでは、リデューサーが純粋な関数を形成するため、状態の変化はテストする上で最も簡単な部分です。状態の一部とアクションをリデューサーに渡し、状態がどのように変化したかをアサートするだけです。

しかし、Composable Architectureは、TestStoreのおかげで、簡単なプロセスをさらに簡単にします。TestStoreは、アクションを送信する際にシステム内で起こっているすべてのことを監視する、機能のテスト可能なランタイムであり、簡単なアサーションを書くことを可能にし、アサーションが失敗した場合には、適切にフォーマットされた失敗メッセージを提供します。

ステップ 1

カウンター機能における非常に単純なインクリメントとデクリメントの動作のテストを書きましょう。テストのための基本的な足場を整えたCounterFeatureTests.swiftファイルを作成することから始めます。

注意

Composable Architectureのテストツールは非同期性を使用するため、テストメソッドを事前にasyncにします。

CounterFeatureTests.swift
import ComposableArchitecture
import XCTest

@MainActor
final class CounterFeatureTests: XCTestCase {
  func testCounter() async {

  }
}

ステップ 2

次に、TestStoreを作成します。これは、システムにアクションが送信されたときに機能の動作がどのように変化するかを簡単にアサートできるツールです。TestStoreは、Storeを作成するのと同じ方法で作成します。機能を開始するための初期状態を提供し、機能を動かすリデューサーを記述するための末尾のクロージャを提供します。

CounterFeatureTests.swift
import ComposableArchitecture
import XCTest

@MainActor
final class CounterFeatureTests: XCTestCase {
  func testCounter() async {
+    let store = TestStore(initialState: CounterFeature.State()) {
+      CounterFeature()
+    }
  }
}

ステップ 3

次に、ユーザーが行っていることをエミュレートするために、ストアにアクションを送信し始めることができます。たとえば、インクリメントボタンをタップし、次にデクリメントボタンをタップするのをエミュレートできます。

注意

TestStoreのsend(_:assert:file:line:)メソッドは非同期です。これは、ほとんどの機能が非同期の副作用を伴い、TestStoreがそれらの副作用を追跡するために非同期コンテキストを使用するためです。

CounterFeatureTests.swift
import ComposableArchitecture
import XCTest

@MainActor
final class CounterFeatureTests: XCTestCase {
  func testCounter() async {
    let store = TestStore(initialState: CounterFeature.State()) {
      CounterFeature()
    }
+
+    await store.send(.incrementButtonTapped)
+    await store.send(.decrementButtonTapped)
  }
}

ステップ 4

cmd+Uと入力するか、テストメソッドの横にあるテストダイヤモンドをクリックしてテストを実行します。残念ながら、テストは失敗します。これは、TestStoreにアクションを送信するたびに、そのアクションが送信された後に状態がどのように変化したかを正確に記述する必要があるためです。ライブラリはまた、期待する状態と実際の状態がどのように異なっていたか(マイナス「-」の行)と実際の値(プラス「+」の行)を示す詳細な失敗メッセージを親切に表示します。

CounterFeatureTests.swift
import ComposableArchitecture
import XCTest

@MainActor
final class CounterFeatureTests: XCTestCase {
  func testCounter() async {
    let store = TestStore(initialState: CounterFeature.State()) {
      CounterFeature()
    }

    await store.send(.incrementButtonTapped)
+    // ❌ State was not expected to change, but a change occurred: …
+    //
+    //       CounterFeature.State(
+    //     −   count: 0,
+    //     +   count: 1,
+    //         fact: nil,
+    //         isLoading: false,
+    //         isTimerRunning: false
+    //       )
+    //
+    // (Expected: −, Actual: +)
    await store.send(.decrementButtonTapped)
+    // ❌ State was not expected to change, but a change occurred: …
+    //
+    //       CounterFeature.State(
+    //     −   count: 1,
+    //     +   count: 0,
+    //         fact: nil,
+    //         isLoading: false,
+    //         isTimerRunning: false
+    //       )
+    //
+    // (Expected: −, Actual: +)
  }
}

ステップ 5

テストの失敗を修正するには、各アクションを送信した後に状態がどのように変化したかをアサートする必要があります。TestStoreはこれを非常に人間工学的に行います。sendメソッドに末尾のクロージャを提供するだけでよく、そのクロージャにはアクションが送信される前の状態の可変バージョンが渡され、アクションが送信された後の状態と等しくなるように$0を変更するのがあなたの仕事です。

ヒント

count = 1のような「絶対的な」変更を、count += 1のような「相対的な」変更よりも優先してください。前者は、状態に適用された変換だけでなく、機能がどの正確な状態にあるかを知っていることを証明するより強力なアサーションです。

CounterFeatureTests.swift
import ComposableArchitecture
import XCTest

@MainActor
final class CounterFeatureTests: XCTestCase {
  func testCounter() async {
    let store = TestStore(initialState: CounterFeature.State()) {
      CounterFeature()
    }

+    await store.send(.incrementButtonTapped) {
+      $0.count = 1
+    }
+    await store.send(.decrementButtonTapped) {
+      $0.count = 0
+    }
  }
}

これでテストがパスし、インクリメントとデクリメントのロジックが期待どおりに機能することを証明します。しかし、インクリメントとデクリメントのロジックは、機能の中で最も単純なものです。より現実的な機能では、ロジックははるかに複雑になり、状態の変化をアサートするためにより多くの作業を行う必要があります。しかし、幸いなことに、TestStoreのおかげで、状態の変化をアサートするのは人間工学的に簡単で、テストの失敗メッセージもユーザーフレンドリーです。

セクション2:エフェクトのテスト

リデューサーの最も重要な責任の1つ、つまりアクションを処理する際に状態がどのように変化するかをテストしました。リデューサーの次に重要な責任は、ストアによって処理されるエフェクトを返すことです。

副作用に対するテストの記述は、通常、外部システムへの依存関係を制御し、テスト用にそれらの依存関係のテストフレンドリーなバージョンを提供する必要があるため、より多くの作業が必要です。まず、機能のタイマー機能をテストすることから始めましょう。これは、数値ファクトをフェッチするためのネットワークリクエストよりも少しテストしやすいことがわかります。

ステップ 1

新しい非同期テストメソッドを作成し、TestStoreを構築することで、新しいテストの足場を整えましょう。

CounterFeatureTests.swift
import ComposableArchitecture
import XCTest

@MainActor
final class CounterFeatureTests: XCTestCase {
  func testTimer() async {
    let store = TestStore(initialState: CounterFeature.State()) {
      CounterFeature()
    }
  }
}

ステップ 2

ユーザーがタイマーを開始し、数秒待ってカウントが増加するのを確認し、その後ユーザーがタイマーを停止するというフローをテストしたいと考えています。これは、toggleTimerButtonTappedを送信してユーザーがタイマーを開始するのをエミュレートすることで行うことができ、isTimerRunningの状態がtrueに反転することがわかっているので、状態の変化をアサートすることもできます。

CounterFeatureTests.swift
import ComposableArchitecture
import XCTest

@MainActor
final class CounterFeatureTests: XCTestCase {
  func testTimer() async {
    let store = TestStore(initialState: CounterFeature.State()) {
      CounterFeature()
    }
+
+    await store.send(.toggleTimerButtonTapped) {
+      $0.isTimerRunning = true
+    }
  }
}

ステップ 3

しかし、このテストを実行すると失敗します。テストが終了したが、エフェクトがまだ実行中であると表示されます。

CounterFeatureTests.swift
import ComposableArchitecture
import XCTest

@MainActor
final class CounterFeatureTests: XCTestCase {
  func testTimer() async {
    let store = TestStore(initialState: CounterFeature.State()) {
      CounterFeature()
    }

    await store.send(.toggleTimerButtonTapped) {
      $0.isTimerRunning = true
    }
+    // ❌ An effect returned for this action is still running.
+    //    It must complete before the end of the test. …
  }
}

これは失敗です。なぜなら、TestStoreは、エフェクトを含む機能全体の時間経過による進化をアサートすることを強制するからです。この場合、TestStoreは、テスト中に開始されたすべてのエフェクトがテスト終了前に完了することを強制しています。これにより、エフェクトが実行されていることを知らず、予期しないアクションがシステムに送り返された場合や、それらのアクションによる状態の変更にバグがあった場合など、バグを捕捉するのに役立ちます。したがって、これは非常に良い失敗であり、Composable Architectureがコードの問題を捕捉するのに役立つ多くの方法の1つです。

ステップ 4

テストをパスさせるには、toggleTimerButtonTappedアクションを送信して、ユーザーがタイマーを再度切り替えるのをエミュレートするだけです。

CounterFeatureTests.swift
import ComposableArchitecture
import XCTest

@MainActor
final class CounterFeatureTests: XCTestCase {
  func testTimer() async {
    let store = TestStore(initialState: CounterFeature.State()) {
      CounterFeature()
    }

    await store.send(.toggleTimerButtonTapped) {
      $0.isTimerRunning = true
    }
+    await store.send(.toggleTimerButtonTapped) {
+      $0.isTimerRunning = false
+    }
  }
}

このテストはパスしますが、タイマーの動作を何もアサートしていません。しばらくするとtimerTickアクションがシステムに送信され、カウントがインクリメントされることをアサートしたいと考えています。これは、TestStorereceive(_:timeout:assert:file:line:)メソッドを使用して、アクションを受信することを期待し、そのアクションを受信したときに状態がどのように変化するかを記述することで実現できます。

ステップ 5

timerTickアクションを受信し、カウント状態が1に増加することを期待する新しいアサーションを追加します。

注意

receiveを使用するには、機能のAction型がEquatableである必要があります。これは、TestStoreが受信したアクションをアサートする必要があるためです。

CounterFeatureTests.swift
import ComposableArchitecture
import XCTest

+// In CounterFeature.swift
+extension CounterFeature.Action: Equatable {}

@MainActor
final class CounterFeatureTests: XCTestCase {
  func testTimer() async {
    let store = TestStore(initialState: CounterFeature.State()) {
      CounterFeature()
    }

    await store.send(.toggleTimerButtonTapped) {
      $0.isTimerRunning = true
    }
+    await store.receive(.timerTick) {
+      $0.count = 1
+    }
    await store.send(.toggleTimerButtonTapped) {
      $0.isTimerRunning = false
    }
  }
}

ステップ 6

テストを実行して、残念ながらテストが失敗することを確認します。これは、タイマーが完全に発動するまでに1秒かかるためですが、TestStoreはアクションを受信するのに短時間しか待機せず、受信しない場合はテストの失敗を作成します。これは、TestStoreがテストを遅くしたくないためであり、代わりに、より高速で決定的なテストを作成するために、時間への依存関係を制御します。

CounterFeatureTests.swift
import ComposableArchitecture
import XCTest

@MainActor
final class CounterFeatureTests: XCTestCase {
  func testTimer() async {
    let store = TestStore(initialState: CounterFeature.State()) {
      CounterFeature()
    }

    await store.send(.toggleTimerButtonTapped) {
      $0.isTimerRunning = true
    }
    await store.receive(.timerTick) {
      $0.count = 1
    }
+    // ❌ Expected to receive an action, but received none after 0.1 seconds.
    await store.send(.toggleTimerButtonTapped) {
      $0.isTimerRunning = false
    }
  }
}

ステップ 7

アクションを受信するまでTestStoreにさらに待機させるためにできることの1つは、receiveのタイムアウトパラメータを使用することです。Task.sleepは正確なツールではないため、1秒以上待機させる必要がありますが、明示的なタイムアウトにより、テストはパスします。

CounterFeatureTests.swift
import ComposableArchitecture
import XCTest

@MainActor
final class CounterFeatureTests: XCTestCase {
  func testTimer() async {
    let store = TestStore(initialState: CounterFeature.State()) {
      CounterFeature()
    }

    await store.send(.toggleTimerButtonTapped) {
      $0.isTimerRunning = true
    }
+    await store.receive(.timerTick, timeout: .seconds(2)) {
      $0.count = 1
    }
    await store.send(.toggleTimerButtonTapped) {
      $0.isTimerRunning = false
    }
  }
}

しかし、テストの実行に1秒以上かかります。さらに数回タイマーティックをアサートしたい場合、さらに多くの時間を待つ必要があります。あるいは、タイマーを10秒ごとにティックするように変更したい場合はどうでしょうか。ティックを待つためにテストスイートを10秒間も停止させたいでしょうか?

解決策は、グローバルで制御不能なTask.sleep関数に頼らないことです。これは、タイマーからのティックを取得するためにテストスイートにリアルタイムが経過するのを待たせることを強制します。代わりに、機能がSwift Clockを使用するようにする必要があります。これにより、シミュレーターやデバイスで実行する際にはContinuousClockを提供できますが、テストではTestClockのような制御可能なクロックを使用できます。

幸いなことに、Composable Architectureには依存関係管理システムが付属しており(詳細についてはDependenciesを参照)、すぐに使える制御可能なクロックも付属しています。

ステップ 8

CounterFeature.swiftに戻り、連続クロックへの依存関係をリデューサーに追加します。そして、reduceの実装では、Task.sleepを使用せず、機能が依存するクロックを使用します。

CounterFeatureTests.swift
import ComposableArchitecture

struct CounterFeature: Reducer {
  struct State: Equatable {
    var count = 0
    var fact: String?
    var isLoading = false
    var isTimerRunning = false
  }

  enum Action {
    case decrementButtonTapped
    case factButtonTapped
    case factResponse(String)
    case incrementButtonTapped
    case timerTick
    case toggleTimerButtonTapped
  }

  enum CancelID { case timer }

  @Dependency(\.continuousClock) var clock

  func reduce(into state: inout State, action: Action) -> Effect<Action> {
    switch action {
    case .decrementButtonTapped:
      state.count -= 1
      state.fact = nil
      return .none

    case .factButtonTapped:
      state.fact = nil
      state.isLoading = true
      return .run { [count = state.count] send in
        let (data, _) = try await URLSession.shared
          .data(from: URL(string: "http://numbersapi.com/\(count)")!)
        let fact = String(decoding: data, as: UTF8.self)
        await send(.factResponse(fact))
      }

    case let .factResponse(fact):
      state.fact = fact
      state.isLoading = false
      return .none

    case .incrementButtonTapped:
      state.count += 1
      state.fact = nil
      return .none

    case .timerTick:
      state.count += 1
      state.fact = nil
      return .none

    case .toggleTimerButtonTapped:
      state.isTimerRunning.toggle()
      if state.isTimerRunning {
        return .run { send in
          for await _ in self.clock.timer(interval: .seconds(1)) {
            await send(.timerTick)
          }
        }
        .cancellable(id: CancelID.timer)
      } else {
        return .cancel(id: CancelID.timer)
      }
    }
  }
}

時間ベースの非同期性への依存を制御するための少しの先行作業で、非常に単純なテストを決定論的かつ即座にパスするテストを書くことができるようになりました。

ステップ 9

テストのために使用する制御可能なクロックを明示的に提供するように変更するため、CounterFeatureTests.swiftに戻ります。

CounterFeatureTests.swift
import ComposableArchitecture
import XCTest

@MainActor
final class CounterFeatureTests: XCTestCase {
  func testTimer() async {
    let store = TestStore(initialState: CounterFeature.State()) {
      CounterFeature()
    }

    await store.send(.toggleTimerButtonTapped) {
      $0.isTimerRunning = true
    }
    await store.receive(.timerTick, timeout: .seconds(2)) {
      $0.count = 1
    }
    await store.send(.toggleTimerButtonTapped) {
      $0.isTimerRunning = false
    }
  }
}

ステップ 10

testTimerメソッドの先頭でTestClockを構築します。これは、時間を制御するために機能のリデューサーで使用したいクロックです。これを行うには、TestStorewithDependenciesという別の末尾クロージャを提供し、これにより必要な依存関係をオーバーライドできます。そして最後に、timerTickアクションを受信する前に、TestClockに1秒進むように指示します。

注意

TestClock型は、多くの便利なクロック実装とツールを提供する私たちのオープンソースライブラリであるswift-clocksから来ています。

CounterFeatureTests.swift
import ComposableArchitecture
import XCTest

@MainActor
final class CounterFeatureTests: XCTestCase {
  func testTimer() async {
+    let clock = TestClock()
+
    let store = TestStore(initialState: CounterFeature.State()) {
      CounterFeature()
+    } withDependencies: {
+      $0.continuousClock = clock
+    }

    await store.send(.toggleTimerButtonTapped) {
      $0.isTimerRunning = true
    }
+    await clock.advance(by: .seconds(1))
+    await store.receive(.timerTick) {
      $0.count = 1
    }
    await store.send(.toggleTimerButtonTapped) {
      $0.isTimerRunning = false
    }
  }
}

これでこのテストは即座にパスし、常に100%決定論的にパスすると確信できます。依存関係を制御することで、テストスイートの速度低下を心配したり、receiveで十分なタイムアウトを提供しなかったことを恐れたりする必要はありません。

しかし、ここまではすべて非常にうまくいっていますが、私たちの機能にはまだテストカバレッジがない動作があり、それは別の副作用を伴います。それは、数値のファクトをロードする動作であり、そのデータをロードするためにネットワークリクエストを使用します。

セクション3:ネットワークリクエストのテスト

ネットワークリクエストは、ほとんどの場合、外部サーバーがユーザーのデータを保持しているため、アプリケーションで最も一般的な種類の副作用である可能性があります。ネットワークリクエストを行う機能をテストするのは困難な場合があります。なぜなら、リクエストの作成は遅くなる可能性があり、ネットワーク接続やサーバーに依存する可能性があり、サーバーからどのようなデータが返されるかを予測する方法がないからです。

数値ファクトの動作を素朴な方法でテストしてみましょう。何が問題になるか見てみましょう。

ステップ 1

数値ファクトの動作をテストするために、CounterFeatureTests.swiftに新しいテストメソッドを追加します。また、TestStoreの基本的な足場を整えます。

CounterFeatureTests.swift
import ComposableArchitecture
import XCTest

@MainActor
final class CounterFeatureTests: XCTestCase {
  func testNumberFact() async {
    let store = TestStore(initialState: CounterFeature.State()) {
      CounterFeature()
    }
  }
}

テストでは、ユーザーがファクトボタンをタップし、プログレスインジケーターが表示され、しばらくしてファクトがシステムにフィードバックされるというフローをエミュレートしたいと考えています。

ステップ 2

factButtonTappedアクションを送信してユーザーがボタンをタップするのをエミュレートします。isLoadingがtrueに反転することをすでにアサートできます。

CounterFeatureTests.swift
import ComposableArchitecture
import XCTest

@MainActor
final class CounterFeatureTests: XCTestCase {
  func testNumberFact() async {
    let store = TestStore(initialState: CounterFeature.State()) {
      CounterFeature()
    }
+
+    await store.send(.factButtonTapped) {
+      $0.isLoading = true
+    }
  }
}

ステップ 3

残念ながら、テストを実行すると失敗します。これは、テストについて上で学んだことに基づいて驚くべきことではありません。TestStoreは、すべてのエフェクトがどのように実行されるかをアサートすることを強制し、ネットワークリクエストがまだ完了していないため、失敗が発生しています。

CounterFeatureTests.swift
import ComposableArchitecture
import XCTest

@MainActor
final class CounterFeatureTests: XCTestCase {
  func testNumberFact() async {
    let store = TestStore(initialState: CounterFeature.State()) {
      CounterFeature()
    }

    await store.send(.factButtonTapped) {
      $0.isLoading = true
    }
+    // ❌ An effect returned for this action is still running.
+    //    It must complete before the end of the test. …
  }
}

ステップ 4

テストを修正するには、ネットワークリクエストが完了するのを待って、factResponseアクションを受信する必要があります。しかし、その場合、サーバーから返されるファクトをどのようにアサートするのでしょうか?サーバーからファクトを要求するたびに、異なるものが返される可能性があります。

CounterFeatureTests.swift
import ComposableArchitecture
import XCTest

@MainActor
final class CounterFeatureTests: XCTestCase {
  func testNumberFact() async {
    let store = TestStore(initialState: CounterFeature.State()) {
      CounterFeature()
    }

    await store.send(.factButtonTapped) {
      $0.isLoading = true
    }
+    await store.receive(.factResponse("???"), timeout: .seconds(1)) {
+      $0.isLoading = false
+      $0.fact = "???"
+    }
  }
}

ステップ 5

テストを実行すると、サーバーから予期できなかったファクトを受信するため、テストが失敗します。

CounterFeatureTests.swift
import ComposableArchitecture
import XCTest

@MainActor
final class CounterFeatureTests: XCTestCase {
  func testNumberFact() async {
    let store = TestStore(initialState: CounterFeature.State()) {
      CounterFeature()
    }

    await store.send(.factButtonTapped) {
      $0.isLoading = true
    }
    await store.receive(.factResponse("???"), timeout: .seconds(1)) {
      $0.isLoading = false
      $0.fact = "???"
    }
+    // ❌ A state change does not match expectation: …
+    //
+    //       CounterFeature.State(
+    //         count: 0,
+    //     −   fact: "???",
+    //     +   fact: "0 is the atomic number of the theoretical element tetraneutron.",
+    //         isLoading: false,
+    //         isTimerRunning: false
+    //       )
  }
}

ここで見ているのは、この動作をテストする方法がないということです。サーバーは毎回異なるファクトを返します。そして、サーバーから返されるデータを予測できたとしても、テストはインターネット接続と外部サーバーの稼働時間に依存するため、遅く不安定になるため、理想的ではありません。

セクション4:依存関係の制御

これで、機能コードで制御不能な依存関係を使用することの問題点がわかりました。これにより、コードのテストが困難になり、コードのテストが困難になり、テストの実行に時間がかかったり、不安定になったりする可能性があります。

これらの理由などから、外部システムへの依存関係を制御することを強くお勧めします(詳細についてはDependenciesを参照)。Composable Architectureには、アプリケーション全体で依存関係を制御および伝播するための一連のツールが付属しています。

ステップ 1

まず、新しいファイルNumberFactClient.swiftを作成し、Composable Architectureをインポートします。これにより、機能内の依存関係を制御するために必要なツールにアクセスできるようになります。

NumberFactClient.swift
import ComposableArchitecture

ステップ 2

依存関係を制御するための最初のステップは、依存関係を抽象化するインターフェースをモデル化することです。この場合、整数を受け取り、文字列を返す単一の非同期のthrowsエンドポイントです。これにより、シミュレーターやデバイスで機能を実行するときに依存関係の「ライブ」バージョンを使用できますが、テスト中はより制御されたバージョンを使用できます。

ヒント

プロトコルは依存関係インターフェースを抽象化する最も一般的な方法ですが、唯一の方法ではありません。私たちは、インターフェースを表すために可変プロパティを持つ構造体を使用し、適合性を表すために構造体の値を構築することを好みます。必要に応じて依存関係にプロトコルを使用することもできますが、構造体スタイルについて詳しく知りたい場合は、ビデオシリーズを参照してください。

NumberFactClient.swift
import ComposableArchitecture
+
+struct NumberFactClient {
+  var fetch: (Int) async throws -> String
+}

ステップ 3

次に、依存関係をライブラリに登録する必要があります。これには2つのステップが必要です。まず、クライアントをDependencyKeyプロトコルに準拠させます。これにはliveValueを提供する必要があります。これは、機能がシミュレーターやデバイスで実行されるときに使用される値であり、ライブネットワークリクエストを行うのに適切な場所です。

注意

技術的には、Composable Architectureの依存関係管理システムは、私たちの別のライブラリであるswift-dependenciesによって提供されています。このライブラリは、バニラのSwiftUIおよびUIKitアプリケーションでも有用であることが明らかになったため、Composable Architectureから分割されました。

NumberFactClient.swift
import ComposableArchitecture
+import Foundation

struct NumberFactClient {
  var fetch: (Int) async throws -> String
}
+
+extension NumberFactClient: DependencyKey {
+  static let liveValue = Self(
+    fetch: { number in
+      let (data, _) = try await URLSession.shared
+        .data(from: URL(string: "http://numbersapi.com/\(number)")!)
+      return String(decoding: data, as: UTF8.self)
+    }
+  )
+}

ステップ 4

ライブラリに依存関係を登録する2番目のステップは、DependencyValuesにゲッターとセッターを持つ計算プロパティを追加することです。これにより、リデューサーで@Dependency(\.numberFact)という構文が可能になります。

注意

依存関係をライブラリに登録することは、SwiftUIで環境値を登録することと似ています。これには、EnvironmentKeyに準拠してdefaultValue値を提供し、EnvironmentValuesを拡張して計算プロパティを提供する必要があります。

NumberFactClient.swift
import ComposableArchitecture
import Foundation

struct NumberFactClient {
  var fetch: (Int) async throws -> String
}

extension NumberFactClient: DependencyKey {
  static let liveValue = Self(
    fetch: { number in
      let (data, _) = try await URLSession.shared
        .data(from: URL(string: "http://numbersapi.com/\(number)")!)
      return String(decoding: data, as: UTF8.self)
    }
  )
}
+
+extension DependencyValues {
+  var numberFact: NumberFactClient {
+    get { self[NumberFactClient.self] }
+    set { self[NumberFactClient.self] = newValue }
+  }
+}

それが、依存関係の前面に制御可能なインターフェースを配置するために必要なすべてです。少しの先行作業で、機能で依存関係を使用し始め、最も重要なことに、テストで依存関係のテストフレンドリーなバージョンを使用し始めることができます。

ステップ 5

CounterFeature.swiftに戻り、@Dependencyプロパティラッパーを使用して新しい依存関係を追加します。ただし、今回は数値ファクトクライアント用です。次に、factButtonTappedから返されるエフェクトで、URLSessionにアクセスしてライブネットワークリクエストを行うのではなく、numberFact依存関係を使用してファクトをロードします。

CounterFeature.swift
import ComposableArchitecture

struct CounterFeature: Reducer {
  struct State: Equatable {
    var count = 0
    var fact: String?
    var isLoading = false
    var isTimerRunning = false
  }

  enum Action {
    case decrementButtonTapped
    case factButtonTapped
    case factResponse(String)
    case incrementButtonTapped
    case timerTick
    case toggleTimerButtonTapped
  }

  enum CancelID { case timer }

  @Dependency(\.continuousClock) var clock
  @Dependency(\.numberFact) var numberFact

  func reduce(into state: inout State, action: Action) -> Effect<Action> {
    switch action {
    case .decrementButtonTapped:
      state.count -= 1
      state.fact = nil
      return .none

    case .factButtonTapped:
      state.fact = nil
      state.isLoading = true
      return .run { [count = state.count] send in
        try await send(.factResponse(self.numberFact.fetch(count)))
      }

    case let .factResponse(fact):
      state.fact = fact
      state.isLoading = false
      return .none

    case .incrementButtonTapped:
      state.count += 1
      state.fact = nil
      return .none

    case .timerTick:
      state.count += 1
      state.fact = nil
      return .none

    case .toggleTimerButtonTapped:
      state.isTimerRunning.toggle()
      if state.isTimerRunning {
        return .run { send in
          for await _ in self.clock.timer(interval: .seconds(1)) {
            await send(.timerTick)
          }
        }
        .cancellable(id: CancelID.timer)
      } else {
        return .cancel(id: CancelID.timer)
      }
    }
  }
}

機能で行われたその少しの作業で、ネットワークを完全に回避し、常に100%即座に決定論的にパスするこの動作の単体テストを瞬時に簡単に書くことができるようになりました。しかし、そうする前に、Composable Architectureの超能力を紹介しましょう。

ステップ 6

テストを変更せずに、Xcodeでテストを再度実行します。

CounterFeature.swift
import ComposableArchitecture
import XCTest

@MainActor
final class CounterFeatureTests: XCTestCase {
  func testNumberFact() async {
    let store = TestStore(initialState: CounterFeature.State()) {
      CounterFeature()
    }

    await store.send(.factButtonTapped) {
      $0.isLoading = true
    }
    await store.receive(.factResponse("???"), timeout: .seconds(1)) {
      $0.isLoading = false
      $0.fact = "???"
    }
    // ❌ @Dependency(\.numberFact) has no test implementation, but was
    //    accessed from a test context:
    //
    //   Location:
    //     TCATest/ContentView.swift:70
    //   Dependency:
    //     NumberFactClient
    //
    // Dependencies registered with the library are not allowed to use
    // their default, live implementations when run from tests.
  }
}

テストは同じメッセージで失敗しますが、新しいメッセージが表示されます。それは、オーバーライドせずにテストでライブ依存関係を使用していることを示しています。これは素晴らしい失敗です。なぜなら、テストで意図せずにネットワークリクエストを行ったり、ディスクに何かを書き込んだり、分析を追跡したりする可能性がある場合に、常に通知してくれるからです。

テストでライブネットワークリクエストを行いたくないので、修正しましょう。これにより、テストは即座に決定論的に、常に100%パスするようになります。機能をテスト可能にするために1つの小さな変更を加えます。

ステップ 7

withDependencies末尾クロージャを開いて、TestStoreの依存関係をオーバーライドします。このクロージャには、現在の依存関係を表す引数が渡され、好きなように依存関係を変更できます。特に、numberFact.fetchエンドポイントをオーバーライドして、ハードコードされた文字列を即座に返します。エンドポイントで実際の非同期またはリクエスト作業が実行されていないことに注意してください。

CounterFeature.swift
import ComposableArchitecture
import XCTest

@MainActor
final class CounterFeatureTests: XCTestCase {
  func testNumberFact() async {
    let store = TestStore(initialState: CounterFeature.State()) {
      CounterFeature()
+    } withDependencies: {
+      $0.numberFact.fetch = { "\($0) is a good number." }
+    }

    await store.send(.factButtonTapped) {
      $0.isLoading = true
    }
    await store.receive(.factResponse("???"), timeout: .seconds(1)) {
      $0.isLoading = false
      $0.fact = "???"
    }
  }
}

ステップ 8

これでnumberFactクライアントが常に予測可能なものを返すようにオーバーライドされたので、receiveからタイムアウトを削除し、状態がどのように変化するかを適切にアサートできます。

CounterFeature.swift
import ComposableArchitecture
import XCTest

@MainActor
final class CounterFeatureTests: XCTestCase {
  func testNumberFact() async {
    let store = TestStore(initialState: CounterFeature.State()) {
      CounterFeature()
    } withDependencies: {
      $0.numberFact.fetch = { "\($0) is a good number." }
    }

    await store.send(.factButtonTapped) {
      $0.isLoading = true
    }
+    await store.receive(.factResponse("0 is a good number.")) {
      $0.isLoading = false
+      $0.fact = "0 is a good number."
    }
  }
}

それが、機能をテスト用に準備するために必要なすべてです。いくつかの先行ステップが必要ですが、一度完了すれば、どの機能でも依存関係を即座に使用できます。依存関係を制御することには、テストの記述以外にも、制御された環境でXcodeプレビューを実行したり、ユーザーが予期しない外部への変更を行わないようにサンドボックスで機能を実行するためのオンボーディングを提供したりするなど、さらに多くの利点があります。

Discussion