TCA v0.42.0について書いています。
以前のCombine時代のテストなどは変更履歴から確認してください。
『Swiftによるアプリ開発のためのComposable Architectureがすごく良いので紹介したい』で紹介したThe Composable Architecture(TCA)にはテスト用の複数の型もあり、CaseStudiesにサンプルコードのテストが書かれているのでそれについてざっくり書いておきます。
TCAのRedcuerのテスト
検証していること
TCAではアプリケーションの状態をモデリングしているとも思えるんですが、それが意図通りにモデリングできているかという振る舞いをテストする手段とコードも提供されています。
具体的には指定するActionに対してReducerの処理が意図通りならアクションに対して副作用が実行され、
期待通りにStateが変更されたかどうかを差分をもとに検証できます(差分をもとに検証しているので状態が変わっていないということも検証ができる)。
言い換えると下記のとおりです
- Reducerの処理が意図通り書かれているか
- Effectの実行が意図通りか
- Stateの変化が意図通りか
- 差分をもとに検証
念の為ですが、副作用自体のテストはそこに含まれていません。副作用はテスト時には任意の値を返すように置き換えますので、副作用自体のテスト(例えばDBのデータを操作するとか)はTCAがテスト方法を提供するわけではなく、必要があれば自前で別にテストをやることになります。
余談
ここでは深く書いていませんが、副作用のテストをするかしないか、というのは進んだトピックだと思います。副作用実行自体が失敗するかしないかだけであればテストコードを書かなくてもよいかもしれません。しかし、たとえばCore Dataの書き込みに関するバリデーションが意図通りかなど微妙に不安なこともあるので必要だと判断することも多くあるでしょう。
テスト用の型
TCAが用意しているテスト用の型は下記のとおりです
- TestStore
- 役割
- Reducerをテストするための型
- 期待値との比較を行えるメソッドを持つ
- メソッド
- send
- アクションを指定
- Reducerに送るアクションで、Reducerがその後アクションをするならreceiveを書くようにし、Stateの状態を検証する
- do
- スケジューラを進めたり。アサーション間で行われる作業を書く。
- receive
- sendでさらに実行されて受け取ったReducerのアクション
- send
- 役割
- テストスケジューラ:
AnySchedulerOf<Scheduler>
- 役割
- 実時間ではなくテスト用の時間で検証する
- 実時間だと細かな時間を気にしないといけないし
- 実際にその時間通りに動くようにする場合、長い時間のテストならその分だけ時間がかかる
- テスト用の時間 -> 仮想的な単位
- 実際にその時間通りに動くようにする場合、長い時間のテストならその分だけ時間がかかる
- 実時間だと細かな時間を気にしないといけないし
- 実時間ではなく即実行で検証する
- 実時間ではなくテスト用の時間で検証する
- その他
- TCAv0.37.0以前はEffectがPublisherだったため時系列にアクセスする必要がありそのためにも必要だった
- リアクティブプログラミングの文脈ではテストコードにはテストスケジューラを用いるため
- TCAv0.37.0以前はEffectがPublisherだったため時系列にアクセスする必要がありそのためにも必要だった
- 役割
実際のテストコード
02-Effects-Basics.swift
- これなに?
- カウンタ
- 副作用
- APIアクセスで文字列を取得する
- なにがわかる?
- TestStoreの基本
import ComposableArchitecture
import XCTest
@testable import SwiftUICaseStudies
@MainActor
final class EffectsBasicsTests: XCTestCase {
// カウンタのインクリメントとデクリメントのみでStateの変化を検証(副作用を実行せず)
func testCountDown() async {
// storeがテスト用storeのデータを用意する。
// テスト用の依存物を注入し、依存関係を解決できる。
let store = TestStore(
initialState: EffectsBasics.State(),
reducer: EffectsBasics()
)
// テストスケジューラを即時実行用に差し替え
store.dependencies.mainQueue = .immediate
// 全体の流れについて検証できる。各ステップでは状態が予想通りに変化したことを証明する。
await store.send(.incrementButtonTapped) { // send: アクションの呼び出し
$0.count = 1 // 初見で何をしているか疑問になりそう。1を代入した結果を比較するため
}
await store.send(.decrementButtonTapped) {
$0.count = 0
}
}
// カウンタのインクリメントと副作用を実行しデクリメントでStateの変化を検証
func testNumberFact() async {
let store = TestStore(
initialState: EffectsBasics.State(),
reducer: EffectsBasics()
)
// 副作用の結果の文字列に置き換え
store.dependencies.factClient.fetch = { "\($0) is a good number Brent" }
store.dependencies.mainQueue = .immediate
await store.send(.incrementButtonTapped) {
$0.count = 1
}
await store.send(.numberFactButtonTapped) { // send: アクションの呼び出し(副作用)
$0.isNumberFactRequestInFlight = true // これは副作用の結果ではなくアクションの結果
}
await store.receive(.numberFactResponse(.success("1 is a good number Brent"))) {
$0.isNumberFactRequestInFlight = false
$0.numberFact = "1 is a good number Brent" // 服用の結果Stateが変更されている
}
}
初見でわからないだろうと予測できるのは下記のようなところでしょう。
await store.send(.incrementButtonTapped) { // send: アクションの呼び出し
$0.count = 1 // 初見で何をしているか疑問になりそう。1を代入した結果を比較するため
}
sendメソッドでアクションとクロージャを渡しています。
クロージャで渡している処理は、Stateが結果としてどうなるかのためにStateに値を渡しています。
下記のようなことがやりたい、と言えばイメージが伝わりやすいかもしれません。
// NG:
// 正しい書き方ではないが参考に
await store.send(.incrementButtonTapped) {
AssertEqual($0.count, 1)
}
02-Effects-TimersTests
- これなに?
- 副作用
- 1秒ごとのタイマー
- 副作用
- なにがわかる?
- スケジューラについてちょっと分かる。
import ComposableArchitecture
import XCTest
@testable import SwiftUICaseStudies
@MainActor
final class TimersTests: XCTestCase {
func testStart() async {
let store = TestStore(
initialState: Timers.State(),
reducer: Timers()
)
// テスト用のスケジューラに置き換え
let mainQueue = DispatchQueue.test
store.dependencies.mainQueue = mainQueue.eraseToAnyScheduler()
await store.send(.toggleTimerButtonTapped) {
$0.isTimerActive = true
}
await mainQueue.advance(by: 1) // テストスケジューラを1進ませる
await store.receive(.timerTicked) { // 副作用の結果を受け取る
$0.secondsElapsed = 1 // Stateが変更される
}
await mainQueue.advance(by: 5) // スケジューラを5進ませる
// 以下の.timerTickedは5ステップ内の変更を受け取る
await store.receive(.timerTicked) {
$0.secondsElapsed = 2 // Stateが+1されて2となっている
}
await store.receive(.timerTicked) {
$0.secondsElapsed = 3
}
await store.receive(.timerTicked) {
$0.secondsElapsed = 4
}
await store.receive(.timerTicked) {
$0.secondsElapsed = 5
}
await store.receive(.timerTicked) {
$0.secondsElapsed = 6
}
await store.send(.toggleTimerButtonTapped) {
$0.isTimerActive = false
}
}
}
02-Effects-LongLivingTests
イベントとして
- これなに?
- 副作用
-
UIApplication.userDidTakeScreenshotNotification
のNotificationを取得する場合。外部からのイベントをSubjectとして呼び出されるようにする。
-
- 副作用
- なにがわかる?
- Notificationからのアクション実行
- 副作用のキャンセル
import Combine
import ComposableArchitecture
import XCTest
@testable import SwiftUICaseStudies
@MainActor
final class LongLivingEffectsTests: XCTestCase {
func testReducer() async {
let (screenshots, takeScreenshot) = AsyncStream<Void>.streamWithContinuation()
let store = TestStore(
initialState: LongLivingEffects.State(),
reducer: LongLivingEffects()
)
// スクリーンショット実行のNotificationを置き換えている
store.dependencies.screenshots = { screenshots }
// 副作用を実行し外部からキャンセルできるようにTask的なものを保持
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()
// この副作用実行に対してreceiveするテストコードは書かない。
// すでにキャンセル済みなので副作用が呼ばれても何もしないことを検証している。
}
}
このテストの面白いのは外部から副作用を呼び出すこともできるよということでしょう。
これによって外部EffectからActionを呼び出しています。
余談
TCAなどで「単方向の〜」という説明が使われますが、単方向であるとかないとかはUIからしか処理を発火できないと勘違いしてしまいそうです。実際、カンファレンスでの質問で「NotificationからどうやってStateを変更するの?」という質問がありました。
上記のようにNotificationが実行されるのを監視してアクションを動作することができるので、端末の状態によって自動でStateを変化し、別のアクションを実行することも可能で、監視する対象がNotificationだけではなくDelegateであっても何でもかまいません。つまり例えばDBの変化によって何かをすることも可能です。