Kotest でのコルーチンのテスト向けのセットアップ

![kc.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/359439/b75b01ab-e388-a968-242d-3c88796177b3.png)

テストフレームワークとして [Kotest](https://kotest.io/) を使っているのですが、コルーチンまわりのテストについて工夫が必要だったので、やっていることをご紹介します。

(ここでは `kotlinx-coroutines-test` 1.6 以降を対象とすることにします)。

## コルーチンのテストについて

テストフレームワークによらず一般論として、コルーチンのテストは `kotlinx-coroutines-test` を使ってテストを行うというやりかたが説明されています。

`kotlinx-coroutines-test` 1.6 への移行ガイドが一番わかりやすかったので引用します:

https://github.com/Kotlin/kotlinx.coroutines/blob/master/kotlinx-coroutines-test/MIGRATION.md#simplify-code-by-removing-unneeded-entities

`kotlinx-coroutines-test` 自体のリファレンスや Android のガイドもわかりやすいです:

https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-test/

https://developer.android.com/kotlin/coroutines/test

やることをまとめると以下の流れになるというふうに認識しました:

1. テスト用のディスパッチャーを作成しテスト中はこれを使うようにする
2. `Dispatchers#setMain` して `Dispatchers.Main` に (1) のディスパッチャーを指定する
3. `TestScope#runTest` を使ってテストを実行する
4. テストが終わったら `Dispachers#resetMain` して (2) を戻す

## Kotest で同じことをする

Kotest で同じことをすることを考えます。

まずサンプルのテスト対象です:

```kotlin:Counter.kt
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` を使っているものとします)。

1. 処理開始時に `A. Increment begins.` と出力
2. コルーチンでインクリメント処理をする
3. `increment` が終わる方が現実的には先なので `B. Launched.` と出力される
5. コルーチンでインクリメント処理後に `C. Done.` と出力

このテストを素朴に書くとこうです:

```kotlin:CounterSpec.kt
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
        }
    }
})
```

この結果はこうなります:

```:output
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` のガイドにあるようにセットアップします:

```kotlin:CounterSpec.kt
@OptIn(ExperimentalCoroutinesApi::class)
class CounterSpec : DescribeSpec({
    val dispatcher = StandardTestDispatcher()
    beforeSpec {
        Dispatchers.setMain(dispatcher)
    }
    afterSpec {
        Dispatchers.resetMain()
    }
    
    // ...
})
```

`beforeSpec`, `afterSpec` というのは Lifecycle hook というもので、Spec の実行前/後に実行される処理のフックです:

https://kotest.io/docs/framework/lifecycle-hooks.html

こうすると実行はできましたが、テスト自体は fail します:

```:output
A. Increment begins.
B. Launched.

expected:<1> but was:<0>
Expected :1
Actual   :0
```

この状態では `counter.increment()` で開始したコルーチンが完了できていないので、テスト用のコルーチンのスケジューラの時間を進めて処理を完了させます:

```diff_kotlin:CounterSpec.kt
  @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 するようになりました。

![テストが pass した](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/359439/10d2b5e5-a294-9aa7-5c36-26da6e4760d3.png)

このケースは実際のところ Android の `ViewModel` で `viewModelScope.launch` してコルーチンを発射するケースを想定しています。また `Repository` に対しては [`Dispatcher` を DI する戦法](https://developer.android.com/kotlin/coroutines/test#injecting-test-dispatchers)が必要でしょう。


## Kotest のコルーチンサポート

Kotest のガイドにもコルーチンについての記述があり、フラグを有効にすることでテストケースがコルーチンを考慮してくれるようになっています。

https://kotest.io/docs/framework/coroutines/test-coroutine-dispatcher.html

`testCoroutineDispatcher` フラグを有効にするといいよという紹介がされています。

一方、最近 Kotest で `coroutineTestScope` フラグを `true` にすると `runTest` してくれるようになったようです:

https://github.com/kotest/kotest/pull/2950

これに伴って、今まで使われていた `testCoroutineDispatcher` フラグは deprecated となって、こっちを使うようにオススメされています:

![My_Application_–_Spec_kt__Gradle__io_kotest_kotest-framework-api-jvm_5_3_0_.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/359439/a7ca1687-0418-8af3-b615-cae4b0367f4d.png)

さきほどのセットアップでもこれを使ってみます:

```diff_kotlin:CounterSpec.kt
  @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 a `TestDispatcher`, any newly-created `TestDispatchers` will automatically use the scheduler from the `Main` dispatcher,
> https://developer.android.com/kotlin/coroutines/test#setting-main-dispatcher

これでテスト用の `Dispatcher` を経由して参照する必要もなくなりましたから、`val dispatcher` として保持しないようにできました。

## Extensions を使う

さて、これを毎回必要なテストに書いていくのは面倒です。このセットアップを自動にして意識しないようにすることを考えます。

まず Kotest の Extension を活用することで `beforeSpec` のようなフックを適用できるようにできます:

https://kotest.io/docs/framework/extensions/extensions-introduction.html

```kotlin:KotestCoroutineListener.kt
@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()
    }
}
```

```diff_kotlin:CounterSpec.kt
  @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(...)` を毎回呼び出すことは変わりはないので、プロジェクト全体で有効にするようにします。

```diff_kotlin:KotestCoroutineListener.kt
+ @AutoScan
  @OptIn(ExperimentalCoroutinesApi::class)
  class KotestCoroutineListener : BeforeSpecListener, AfterSpecListener {
      // ...
  }
```

```diff_kotlin:CounterSpec.kt
  @OptIn(ExperimentalCoroutinesApi::class, ExperimentalStdlibApi::class)
  class CounterSpec : DescribeSpec({
-     extension(KotestCoroutineListener())
-
      describe("Counter") {
          // ...
      }
  }
```

`@AutoScan` を指定することでこの Extension を自動で適用してくれるようになりましたので、個別の `extension(...)` を削除できました。 

## おわりに

これで特に意識せずともテスト用のコルーチンのセットアップがされるようになりましたし、変更が必要でも一箇所を変更すれば OK となりました。

Kotest もコルーチンも雰囲気でやっているやつなので、違うじゃん とか こういうほうがいいよというのがあれば教えてください！