7
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Composable Architecture を利用した Todo アプリの紹介(Part3-1)

Last updated at Posted at 2020-12-05

Composable Architecture を利用した Todo アプリの紹介(Part1)Part2 の続きで、Part3 について紹介します。一気に紹介すると疲れることがわかったので、Part3 の前半部分について触れようと思います。 元の記事はこちらになります。

Composable Architecture を利用した Todo アプリ(Part3-1)

今回は主に、前回までで作成しているアプリを題材として、TCA を利用したテストについて見ていきます。個人的に TCA のテスト機能は非常に便利だと感じていて、TCA の利用を決める際の大きなメリットの一つになると思っています。

前回までの記事で出来上がったアプリはこちらに上げられています。(コードを全文書くと多くなってしまうので、Point-Free さんのリポジトリを参照していただければと思います🙏 )

前回まで作成していたアプリで良くなかった部分

前までのアプリには、あまり良いとは言えない部分がありました。具体的には以下の Reducer を定義している部分になります。

ContentView.swift
let appReducer = Reducer<AppState, AppAction, AppEnvironment>.combine(
  todoReducer.forEach(
    state: \AppState.todos,
    action: /AppAction.todo(index:action:),
    environment: { _ in TodoEnvironment() }
  ),
  Reducer { state, action, environment in
    switch action {
    case .addButtonTapped:
      state.todos.insert(Todo(id: UUID()), at: 0)
      return .none
    case .todo(index: let index, action: let action):
      return .none
    }
  }
)

あまり良いとは言えない部分とは、Todo を作る際に idUUID() で毎回初期化している部分になります。実際、何が問題なのかを確認するために TCA のテストヘルパーを使いながらテストを書いていきます。

※ もしリポジトリからコードを落としてきた場合は、プロジェクトに入っている TCA のバージョンが古いので、最新バージョンにアップデートした上で読み進めてください

TCA のテストヘルパーを使って簡単なテストを書いてみる

それでは、実際に TCA のテストヘルパーを使いながらテストを書いていきましょう。
まずは Todo を完了したものとしてマークするような機能のテストを書いていきます。
TCA のテストヘルパーでは、 TestStore というテスト用の Store を作成することができるため、それをまず作ります。

TodosTests.swift
import ComposableArchitecture
import XCTest
@testable import Todos

class TodosTests: XCTestCase {
    func testCompletingTodo() {
        let store = TestStore(
            initialState: AppState(),
            reducer: appReducer,
            environment: AppEnvironment()
        )
    }
}

TestStore は Store と同じように initialState, reducer, environment の三つの引数を必要とします。
TestStore を用いてテストを行っていく場合、テストの過程で適切にアサートを行うために State と Action は Equatable に適合している必要があります。

ContentView.swift
enum TodoAction: Equatable {
  ...
}

struct AppState: Equatable {
  ...
}

おそらく前回までの記事の内容で進めていると、 AppState は既に適合していますが、 TodoAction は適合していないため、上記のように適合させます。(リポジトリでは既に適合されています。)

ここまで来たら既にビルドできる状態にはなっていますが、AppState が空なのでダミーのデータをイニシャライズ時に注入しておきます。

TodosTests.swift
class TodosTests: XCTestCase {
    func testCompletingTodo() {
        let store = TestStore(
          initialState: AppState(
            todos: [
              Todo(
                description: "Milk",
                id: UUID(uuidString: "00000000-0000-0000-0000-000000000000")!,
                isComplete: false
              )
            ]
          ),
          reducer: appReducer,
          environment: AppEnvironment()
        ) 
    }
}

これでアサーションを書いていくための準備ができたので、実際にアサーションを行っていきます。XCTest では XCTAssert~~ などのメソッドを使用してテストを行っていきますが、 TCA では TestStore に assert メソッドがあるため、それを呼び出してテストしていきます。

TCA においては Action が送られて State が変更されるという一貫性があるため、 assert メソッドには Action を送った結果 State がどのように変化しているべきなのかという観点でテストを書いていきます。

では、Todo 項目の最初のチェックボックスをユーザーがタップした場合を想定したテストを実際に書いてみます。

TodosTests.swift
store.assert(
    .send(.todo(index: 0, action: .checkboxTapped)) {
        $0.todos[0].isComplete = true
    }
)

直感的で結構個人的には分かりやすいと思うのですが、 上記のように「 todo(index:action: ) を送った結果、AppState が持っている todos の一番最初の isCompletetrue になっているはずだ」というような構成でテストを書いていくことになります。
この状態でテストを実行してみると pass するかと思います。

もう少し進む前に、TCA でテストを失敗させた場合どのようになるかも見ていきます。

TodosTests.swift
store.assert(
    .send(.todo(index: 0, action: .checkboxTapped)) {
        $0.todos[0].isComplete = false
    }
)

試しに上記のようにコードを変更してみます。checkbox を一回タップしただけだと isComplete の状態は true であるはずなので、 false は誤った結果になるはずです。
この状態でテストを実行してみます。

すると、以下のようになぜテストが失敗したのかという理由が分かりやすくエラーメッセージとして表示されます。

このように TCA のテストヘルパーを使ってテストを行うと、テストが落ちてしまった原因もすぐに特定することができるので非常に便利だと思います。

次の作業に進む前に、 false にしていた部分を true に戻しておきましょう。
では、次の作業に進んでいきます。

Todo を追加した時用のテストを書いてみる

次は Todo を追加する動作のテストを書いていきましょう。まずは新しい test メソッドと TestStore を作成します。

TodosTests.swift
func testAddTodo() {
    let store = TestStore(
        initialState: AppState(),
        reducer: appReducer,
        environment: AppEnvironment()
    )
}

では先ほどと同じように、「 Action の送信 -> 期待する State 」という流れでテストを書いていきましょう。

TodosTests.swift
store.assert(
  .send(.addButtonTapped) {
    $0.todos = [
      Todo(
        description: "",
        id: UUID(),
        isComplete: false
      )
    ]
  }
)

addButtonTapped Action が送信されたら、空だった todos に Todo が一つ追加されている状態が想定しているものなので、上記のように書けます。

それでは、テストを実行してみます。

失敗してしまいました 😢
原因は画像からも分かるように、id が異なっているためのようです。

最初の方でコードにはあまり良くない部分があると言っていた理由はこれで、UUID を直接内部で生成しているために、期待される状態でのテストを行うことができないという問題が発生します。

UUID を外部から注入しましょう

なんとなく察しはつくかもしれないのですが、この問題は UUID を DI するようにすれば解決できる問題です。
現在は Reducer に UUID への依存があるため、それが問題となっています。
その問題を解決するために、TCA では Environment が存在しています。

Envrionment は TCA において、Reducer を動作させる際に必要な依存関係の全てを置く場所になっています。例えば、今回のような UUID イニシャライザや、API クライアント・ Date イニシャライザなどなどが依存関係としては挙げられます。
依存関係を Environment で管理するようにすれば、Reducer のテストが非常に簡単になるため、テストカバレッジも広がります。

では、空だった AppEnvironmentUUID への依存関係を設定します。

ContentView.swift
struct AppEnvironment {
    var uuid: () -> UUID
}

作成した Environment を appReducer で利用するように変更します。

ContentView.swift
let appReducer = Reducer<AppState, AppAction, AppEnvironment>.combine(
  todoReducer.forEach(
    state: \AppState.todos,
    action: /AppAction.todo(index:action:),
    environment: { _ in TodoEnvironment() }
  ),
  Reducer { state, action, environment in
    switch action {
    case .addButtonTapped:
      state.todos.insert(Todo(id: environment.uuid()), at: 0) // ここで environment を利用
      return .none
    case .todo(index: let index, action: let action):
      return .none
    }
  }
)

後は、SwiftUI のプレビューと SceneDelegate 内で Store を作成する時に使用している Environment に以下のように手を加える必要があります。

environment: AppEnvironment(
    uuid: UUID.init
)

ここまででアプリ自体は元と同じように動作するようになりました。
ただ、テストはまだ修正していないので、そちらでも Environment を適用していく必要があります。
これまで書いてきた TestStore で Environment を初期化している部分 ↓ があるため、そちらを変えていきます。

TodosTests.swift
let store = TestStore(
    initialState: AppState(),
    reducer: appReducer,
    environment: AppEnvironment()
)

では、実際に注入していきます。

TodosTests.swift
func testAddTodo() {
    let store = TestStore(
        initialState: AppState(),
        reducer: appReducer,
        environment: AppEnvironment(
            uuid: { UUID(uuidString: "DEADBEEF-DEAD-BEEF-DEAD-BEEFDEADBEEF")! }
        )
    )
        
    store.assert(
        .send(.addButtonTapped) {
            $0.todos = [
                Todo(
                    description: "",
                    id: UUID(),
                    isComplete: false
               )
            ]
        }
    )
}

テストを実行してみましょう。

まだ必要なことを実施していないため、テストは失敗します。
しかし、Actual の id を見てみると、実際に注入した UUID が表示されており、id を開発者が制御することが可能になったことが分かるかと思います。

では、テストを成功させるために、想定される State も書き換えます。

TodosTests.swift
store.assert(
    .send(.addButtonTapped) {
        $0.todos = [
            Todo(
                description: "",
                id: UUID(uuidString: "DEADBEEF-DEAD-BEEF-DEAD-BEEFDEADBEEF")!,
                isComplete: false
           )
        ]
    }
)

これで無事テストは成功します 🙆‍♂️

おわりに

テストの結果が分かりやすいため即座に原因が特定でき、依存関係も簡単に注入することができるので、TCA でテストを書くのは個人的に好きです。(Action を送って、期待する State を記述するというのも分かりやすい)

Part3 の後半では、もう少しアプリに機能を足しながらさらにテストを書いていくことになります。
そちらについては気力があればまだ書いてみようと思います 🙏

7
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?