swift-concurrency-extrasのTask拡張について
TCA Advent Calendar 2024の記事です。
はじめに
TCAに使われているswift-concurrency-extrasのTaskの拡張について解説します。バージョンはv1.3です。
もし何か間違いがあったらコメントいただければ助かります。
Taskの拡張について
主に3つの拡張が用意されています。
Task.never()
var cancellableValue
Task.megaYield()
Task.never()
テストコードやViewのプレビュー用に使うモックのために使います。
これはテストコードやモック化で「実際の値は必要ないが、コンパイル上あるいはAPI上はasync関数として何らかの戻り値を期待されている」といった状況で活用できます。
具体的には
- 環境依存処理(Dependency)をテストする際に、その戻り値をわざわざ生成する必要がない、あるいは生成したくない場合
- そこでTask.never()を使って「依存関数を呼べるが永遠に応答しない」状態を擬似的に作り、テスト対象側がその状態でどのように振る舞うかを確認できる。
- タイムアウトを検出する、キャンセルを行うなど
- そこでTask.never()を使って「依存関数を呼べるが永遠に応答しない」状態を擬似的に作り、テスト対象側がその状態でどのように振る舞うかを確認できる。
READMEから具体的なコード例
READMEで説明されているのは次のような関数型がある場合です。
struct SettingsClient {
var fetchSettings: () async throws -> Settings
}
これをテスト用もしくはプレビュー用にDIする際に実装を次のようにすることが可能です。
SettingsClient(
fetchSettings: {
// ジェネリクスにより戻り値としてSettingsを返せるようにしていて、
// コンパイルエラーにならない
try await Task.never()
}
)
具体的なコード例ではSettingsClient
のfetchSettings
メソッドがasync throws -> Settings
を返すことになっていますが、
テスト上はあえて何も返したくない場合に、try await Task.never()
を返すことで、
その関数が永遠に応答しない挙動を模倣しています。
永遠に終了しない副作用を再現
TCAのコードではVoiceMemosTestsでプレイヤーの再生処理をテストするのに使われています。
たしかに、再生という処理の開始時にすぐに停止してしまって困るテストがある場合、このような手法は有用そうです。
型を初期化しないメリット
関数が永遠に応答しないようにする理由の1つは、
先ほどの例でSettingsをわざわざ作りたくない場合も有用でしょう。
Settingsがどんなものかを適当に考えてみます。
SettingsClient(
fetchSettings: {
// returnする際にSettingsの初期化をすることを考える。
// こちらでidやuserなどがあると想定。
// ただ、このidやuserなどは直接使わず、
// コンパイラを納得させるためだけに返しているなら無駄すぎる。
Settings(
id: ID(...)
user: User(name: "user1", ...),
pattern: Pattern(...),
name: "name"
)
}
)
上記のように、Settings型をinitする際にStringなどは簡単に適当な値を作れますが、別の型を使わないといけない場合ははるかに面倒になります。
その型が自動で生成される仕組みを作ったりするのもまた別の複雑なデメリットを生んでしまうでしょう。なので型を生成しなくて問題ないのであれば型を作らなければいいだけなのでしょう。
実装
never()
メソッドの実装は大きくわけて2パターンに分かれています。
- TaskのSuccessがNeverでなくFailureがNever
- 例
- 先述の
SettingsClient
など値を返す場合
- 先述の
- 例
- TaskのSuccessとFailureが両方Never
- 例
- TCAのReducerのrun内で何も返さない場合
- 例
TaskのSuccessがNeverでなくFailureがNever
AsyncStream<Success>.never
を呼び出し、非同期でそれが所得できたら1つ目をreturnしています。
そして最後にキャンセルの例外をthrowしています。
AsyncStream<Success>.never
は無限に何も起こらないことで無限ループを実現しています。
そのためこのnever()
は外部からキャンセルされたとき処理を抜け、最後に例外をthrowしているということでしょう。
TaskのSuccessとFailureが両方Never
両方がNeverのときはもっとシンプルです。SuccessがNeverのためループ内でも何もしません。
cancellableValue
Task.cancellableValue is a property that awaits the unstructured task's value property while propagating cancellation from the current async context.
意味がわからないので今回は解説しません。直接.value
する場合との違いがわかりませんでした。
実装
withTaskCancellationHandler
でself.cancel()
しているのは、
そうしないとawaitしている側にキャンセルが伝わらない?
実験してみたところ、これがなくても外部からTaskがキャンセルされていればawait側でcatchする際にエラーが伝わっているし、
Task内部でもTask.isCancelはtrueになるので内部でもキャンセルが伝播しています。
よくわからないというのが本音です。
Task.megaYield
Task.megaYield
は不安定なテストを安定させるために何度もyieldを呼び出すことで、テスト対象やモックされた副作用を実行できる可能性を高めるためのメソッドのようです。
Task.yieldはそもそも呼び出した際に他のTaskの実行を促します。ですが確実性がないためにこのメソッドがあるようです。
関連する記事は別に書いてあります。
実装
複数回Task.detachedをawaitしています。
繰返しの回数として、引数で指定しない場合環境変数を使い、環境変数がないとデフォルトで20回繰り返します。
おわりに
TCAではTask.never()は副作用のモック化のほか、Reducerのbodyに仕込みキャンセルのテストに使われ、Task.megaYieldやcancellableValueはテスト自体に使われています。
Discussion