テストフレームワークとして Kotest を使っているのですが、コルーチンまわりのテストについて工夫が必要だったので、やっていることをご紹介します。
(ここでは kotlinx-coroutines-test
1.6 以降を対象とすることにします)。
コルーチンのテストについて
テストフレームワークによらず一般論として、コルーチンのテストは kotlinx-coroutines-test
を使ってテストを行うというやりかたが説明されています。
kotlinx-coroutines-test
1.6 への移行ガイドが一番わかりやすかったので引用します:
kotlinx-coroutines-test
自体のリファレンスや Android のガイドもわかりやすいです:
やることをまとめると以下の流れになるというふうに認識しました:
- テスト用のディスパッチャーを作成しテスト中はこれを使うようにする
-
Dispatchers#setMain
してDispatchers.Main
に (1) のディスパッチャーを指定する -
TestScope#runTest
を使ってテストを実行する - テストが終わったら
Dispachers#resetMain
して (2) を戻す
Kotest で同じことをする
Kotest で同じことをすることを考えます。
まずサンプルのテスト対象です:
class Counter(
private val scope: CoroutineScope = CoroutineScope(Job() + Dispatchers.Main)
) {
var count = 0
private set
fun increment() {
println("A. Increment begins.")
scope.launch {
postIncrementTakingLongTime()
println("C. Done.")
}
println("B. Launched.")
}
/** 時間のかかる処理 */
private suspend fun postIncrementTakingLongTime() {
delay(1000L)
++count
}
}
意味不明ですが、カウンターを用意してインクリメントするのに時間がかかるので coroutine を使っているとします (Dispatchers.Main
を使っているものとします)。
- 処理開始時に
A. Increment begins.
と出力 - コルーチンでインクリメント処理をする
-
increment
が終わる方が現実的には先なのでB. Launched.
と出力される - コルーチンでインクリメント処理後に
C. Done.
と出力
このテストを素朴に書くとこうです:
class CounterSpec : DescribeSpec({
describe("Counter") {
lateinit var counter: Counter
beforeEach {
counter = Counter()
}
it("increment its count") {
// 最初は count は 0
counter.count shouldBe 0
// インクリメントする
counter.increment()
// インクリメント後は count が 1 になっていてほしい
counter.count shouldBe 1
}
}
})
この結果はこうなります:
A. Increment begins.
Exception in thread "pool-1-thread-1 @coroutine#3" java.lang.IllegalStateException: Module with the Main dispatcher had failed to initialize. For tests Dispatchers.setMain from kotlinx-coroutines-test module can be used
at kotlinx.coroutines.internal.MissingMainCoroutineDispatcher.missing(MainDispatchers.kt:118)
at kotlinx.coroutines.internal.MissingMainCoroutineDispatcher.isDispatchNeeded(MainDispatchers.kt:96)
at kotlinx.coroutines.test.internal.TestMainDispatcher.isDispatchNeeded(TestMainDispatcher.kt:31)
スコープで使っている Dispatcher.Main
の設定が必要なので kotlinx-coroutines-test
のガイドにあるようにセットアップします:
@OptIn(ExperimentalCoroutinesApi::class)
class CounterSpec : DescribeSpec({
val dispatcher = StandardTestDispatcher()
beforeSpec {
Dispatchers.setMain(dispatcher)
}
afterSpec {
Dispatchers.resetMain()
}
// ...
})
beforeSpec
, afterSpec
というのは Lifecycle hook というもので、Spec の実行前/後に実行される処理のフックです:
こうすると実行はできましたが、テスト自体は fail します:
A. Increment begins.
B. Launched.
expected:<1> but was:<0>
Expected :1
Actual :0
この状態では counter.increment()
で開始したコルーチンが完了できていないので、テスト用のコルーチンのスケジューラの時間を進めて処理を完了させます:
@OptIn(ExperimentalCoroutinesApi::class)
class CounterSpec : DescribeSpec({
// ...
describe("Counter") {
// ...
it("increment its count") {
// 最初は count は 0
counter.count shouldBe 0
// インクリメントする
counter.increment()
+ // 落ち着くまで時計を進める
+ dispatcher.scheduler.advanceUntilIdle()
// インクリメント後は count が 1 になっていてほしい
counter.count shouldBe 1
}
}
})
こうするとテストが pass するようになりました。
このケースは実際のところ Android の ViewModel
で viewModelScope.launch
してコルーチンを発射するケースを想定しています。また Repository
に対しては Dispatcher
を DI する戦法が必要でしょう。
Kotest のコルーチンサポート
Kotest のガイドにもコルーチンについての記述があり、フラグを有効にすることでテストケースがコルーチンを考慮してくれるようになっています。
testCoroutineDispatcher
フラグを有効にするといいよという紹介がされています。
一方、最近 Kotest で coroutineTestScope
フラグを true
にすると runTest
してくれるようになったようです:
これに伴って、今まで使われていた testCoroutineDispatcher
フラグは deprecated となって、こっちを使うようにオススメされています:
さきほどのセットアップでもこれを使ってみます:
@OptIn(ExperimentalCoroutinesApi::class, ExperimentalStdlibApi::class)
class CounterSpec : DescribeSpec({
- val dispatcher = StandardTestDispatcher()
beforeSpec {
- Dispatchers.setMain(dispatcher)
+ Dispatchers.setMain(StandardTestDispatcher())
coroutineTestScope = true
}
afterSpec {
Dispatchers.resetMain()
}
describe("Counter") {
// ...
it("increment its count") {
// 最初は count は 0
counter.count shouldBe 0
// インクリメントする
counter.increment()
// 落ち着くまで時計を進める
- dispatcher.scheduler.advanceUntilIdle()
+ testCoroutineScheduler.advanceUntilIdle()
// インクリメント後は count が 1 になっていてほしい
counter.count shouldBe 1
}
}
})
testCoroutineScheduler
は有効になっているテストスケジューラーを取得できる Kotest の拡張関数です。coroutineTestScope
により扱えるようになったのでこれに置き換えます。
ちなみに Dispatchers#setMain
後に作成された TestDispatcher
ではスケジューラーが共有されると書かれています:
If the
Main
dispatcher has been replaced with aTestDispatcher
, any newly-createdTestDispatchers
will automatically use the scheduler from theMain
dispatcher,
https://developer.android.com/kotlin/coroutines/test#setting-main-dispatcher
これでテスト用の Dispatcher
を経由して参照する必要もなくなりましたから、val dispatcher
として保持しないようにできました。
Extensions を使う
さて、これを毎回必要なテストに書いていくのは面倒です。このセットアップを自動にして意識しないようにすることを考えます。
まず Kotest の Extension を活用することで beforeSpec
のようなフックを適用できるようにできます:
@OptIn(ExperimentalCoroutinesApi::class)
class KotestCoroutineListener : BeforeSpecListener, AfterSpecListener {
override suspend fun beforeSpec(spec: Spec) {
Dispatchers.setMain(StandardTestDispatcher())
spec.coroutineTestScope = true
}
override suspend fun afterSpec(spec: Spec) {
Dispatchers.resetMain()
}
}
@OptIn(ExperimentalCoroutinesApi::class, ExperimentalStdlibApi::class)
class CounterSpec : DescribeSpec({
- beforeSpec {
- Dispatchers.setMain(StandardTestDispatcher())
- coroutineTestScope = true
- }
- afterSpec {
- Dispatchers.resetMain()
- }
+ extension(KotestCoroutineListener())
describe("Counter") {
// ...
}
}
KotestCoroutineListener
で beforeSpec
, afterSpec
で行っていたセットアップを行うようにし、extension
で使うことでセットアップを委譲できました。
これでも extension(...)
を毎回呼び出すことは変わりはないので、プロジェクト全体で有効にするようにします。
+ @AutoScan
@OptIn(ExperimentalCoroutinesApi::class)
class KotestCoroutineListener : BeforeSpecListener, AfterSpecListener {
// ...
}
@OptIn(ExperimentalCoroutinesApi::class, ExperimentalStdlibApi::class)
class CounterSpec : DescribeSpec({
- extension(KotestCoroutineListener())
-
describe("Counter") {
// ...
}
}
@AutoScan
を指定することでこの Extension を自動で適用してくれるようになりましたので、個別の extension(...)
を削除できました。
おわりに
これで特に意識せずともテスト用のコルーチンのセットアップがされるようになりましたし、変更が必要でも一箇所を変更すれば OK となりました。
Kotest もコルーチンも雰囲気でやっているやつなので、違うじゃん とか こういうほうがいいよというのがあれば教えてください!