search
LoginSignup
2

posted at

Organization

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

kc.png

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

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

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

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

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

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

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

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

Kotest で同じことをする

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

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

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. と出力される
  4. コルーチンでインクリメント処理後に C. Done. と出力

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

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

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

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

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

output
A. Increment begins.
B. Launched.

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

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

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 した

このケースは実際のところ Android の ViewModelviewModelScope.launch してコルーチンを発射するケースを想定しています。また Repository に対しては Dispatcher を DI する戦法が必要でしょう。

Kotest のコルーチンサポート

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

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

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

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

My_Application_–Spec_kt__Gradle__io_kotest_kotest-framework-api-jvm_5_3_0.png

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

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 のようなフックを適用できるようにできます:

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()
    }
}
CounterSpec.kt
  @OptIn(ExperimentalCoroutinesApi::class, ExperimentalStdlibApi::class)
  class CounterSpec : DescribeSpec({
-     beforeSpec {
-         Dispatchers.setMain(StandardTestDispatcher())
-         coroutineTestScope = true
-     }
-     afterSpec {
-         Dispatchers.resetMain()
-     }
+     extension(KotestCoroutineListener())

      describe("Counter") {
          // ...
      }
  }

KotestCoroutineListenerbeforeSpec, afterSpec で行っていたセットアップを行うようにし、extension で使うことでセットアップを委譲できました。

これでも extension(...) を毎回呼び出すことは変わりはないので、プロジェクト全体で有効にするようにします。

KotestCoroutineListener.kt
+ @AutoScan
  @OptIn(ExperimentalCoroutinesApi::class)
  class KotestCoroutineListener : BeforeSpecListener, AfterSpecListener {
      // ...
  }
CounterSpec.kt
  @OptIn(ExperimentalCoroutinesApi::class, ExperimentalStdlibApi::class)
  class CounterSpec : DescribeSpec({
-     extension(KotestCoroutineListener())
-
      describe("Counter") {
          // ...
      }
  }

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

おわりに

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

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

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
What you can do with signing up
2