Composable Architecture を利用した Todo アプリの紹介(Part1)と Part2 の続きで、Part3 について紹介します。一気に紹介すると疲れることがわかったので、Part3 の前半部分について触れようと思います。 元の記事はこちらになります。
Composable Architecture を利用した Todo アプリ(Part3-1)
今回は主に、前回までで作成しているアプリを題材として、TCA を利用したテストについて見ていきます。個人的に TCA のテスト機能は非常に便利だと感じていて、TCA の利用を決める際の大きなメリットの一つになると思っています。
前回までの記事で出来上がったアプリはこちらに上げられています。(コードを全文書くと多くなってしまうので、Point-Free さんのリポジトリを参照していただければと思います🙏 )
前回まで作成していたアプリで良くなかった部分
前までのアプリには、あまり良いとは言えない部分がありました。具体的には以下の Reducer を定義している部分になります。
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 を作る際に id
を UUID()
で毎回初期化している部分になります。実際、何が問題なのかを確認するために TCA のテストヘルパーを使いながらテストを書いていきます。
※ もしリポジトリからコードを落としてきた場合は、プロジェクトに入っている TCA のバージョンが古いので、最新バージョンにアップデートした上で読み進めてください
TCA のテストヘルパーを使って簡単なテストを書いてみる
それでは、実際に TCA のテストヘルパーを使いながらテストを書いていきましょう。
まずは Todo を完了したものとしてマークするような機能のテストを書いていきます。
TCA のテストヘルパーでは、 TestStore
というテスト用の Store を作成することができるため、それをまず作ります。
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
に適合している必要があります。
enum TodoAction: Equatable {
...
}
struct AppState: Equatable {
...
}
おそらく前回までの記事の内容で進めていると、 AppState
は既に適合していますが、 TodoAction
は適合していないため、上記のように適合させます。(リポジトリでは既に適合されています。)
ここまで来たら既にビルドできる状態にはなっていますが、AppState が空なのでダミーのデータをイニシャライズ時に注入しておきます。
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 項目の最初のチェックボックスをユーザーがタップした場合を想定したテストを実際に書いてみます。
store.assert(
.send(.todo(index: 0, action: .checkboxTapped)) {
$0.todos[0].isComplete = true
}
)
直感的で結構個人的には分かりやすいと思うのですが、 上記のように「 todo(index:action: )
を送った結果、AppState が持っている todos
の一番最初の isComplete
が true
になっているはずだ」というような構成でテストを書いていくことになります。
この状態でテストを実行してみると pass するかと思います。
もう少し進む前に、TCA でテストを失敗させた場合どのようになるかも見ていきます。
store.assert(
.send(.todo(index: 0, action: .checkboxTapped)) {
$0.todos[0].isComplete = false
}
)
試しに上記のようにコードを変更してみます。checkbox を一回タップしただけだと isComplete
の状態は true
であるはずなので、 false
は誤った結果になるはずです。
この状態でテストを実行してみます。
すると、以下のようになぜテストが失敗したのかという理由が分かりやすくエラーメッセージとして表示されます。
このように TCA のテストヘルパーを使ってテストを行うと、テストが落ちてしまった原因もすぐに特定することができるので非常に便利だと思います。
次の作業に進む前に、 false
にしていた部分を true
に戻しておきましょう。
では、次の作業に進んでいきます。
Todo を追加した時用のテストを書いてみる
次は Todo を追加する動作のテストを書いていきましょう。まずは新しい test メソッドと TestStore を作成します。
func testAddTodo() {
let store = TestStore(
initialState: AppState(),
reducer: appReducer,
environment: AppEnvironment()
)
}
では先ほどと同じように、「 Action の送信 -> 期待する State 」という流れでテストを書いていきましょう。
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 のテストが非常に簡単になるため、テストカバレッジも広がります。
では、空だった AppEnvironment
に UUID
への依存関係を設定します。
struct AppEnvironment {
var uuid: () -> UUID
}
作成した Environment を appReducer
で利用するように変更します。
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 を初期化している部分 ↓ があるため、そちらを変えていきます。
let store = TestStore(
initialState: AppState(),
reducer: appReducer,
environment: AppEnvironment()
)
では、実際に注入していきます。
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 も書き換えます。
store.assert(
.send(.addButtonTapped) {
$0.todos = [
Todo(
description: "",
id: UUID(uuidString: "DEADBEEF-DEAD-BEEF-DEAD-BEEFDEADBEEF")!,
isComplete: false
)
]
}
)
これで無事テストは成功します 🙆♂️
おわりに
テストの結果が分かりやすいため即座に原因が特定でき、依存関係も簡単に注入することができるので、TCA でテストを書くのは個人的に好きです。(Action を送って、期待する State を記述するというのも分かりやすい)
Part3 の後半では、もう少しアプリに機能を足しながらさらにテストを書いていくことになります。
そちらについては気力があればまだ書いてみようと思います 🙏