Infinito Nirone 7

白羽の矢を刺すスタイル

RxJava と Kotlin Coroutines のテストチートシート

忘れそうになるのでメモ。テストは Kotest をつかっているが、極力 JUnit でのテストの記述と対比しやすいよう StringSpec で書いていく。 最近 RxJava から Kotlin Coroutines への乗り換えを進めていて、テストの書き方がどう異なるか(あるいは同様となるのか)についてまとめたくなったので残している。

RxJava

次の SomeModel をテストしたい。

class SomeModel {
  private val textMutation: BehaviorSubject<String> = BehabiorSubject.createDefault("foo")
  val text: Subject<String> = textMutation.hide()

  fun changeText(text: String) {
    textMutation.onNext(text)
  }
}

changeText 関数によるテキストの変更を text を観測して確かめる。 RxJava では TestObserver があるのでこれを使う。

class SomeModelTest : StringSpec() {
  init {
    "Change text" {
      // Given: SomeModel 
      val model = SomeModel()
      val observer: TestObserver<String> = model.text.test()

      // When: Change text
      model.changeText("bar")

      // Then: Observe 2 updates
      observer.awaitCount(2).assertValueCount(2)

      // And: "foo" comes first
      observer.assertValueAt(0) { text ->
        text == "foo"
      }

      // And: "bar" comes second
      observer.assertValueAt(1) { text ->
        text == "bar"
      }
    }
  }
}

Kotlin Coroutines

RxJava の例と似たようなクラスをテストしてみる。RxJava では BehaviorSubject をつかっていたが、Kotlin Coroutines では StateFlow を使う。

class SomeModel {
  private val textMutation: MutableStateFlow<String> = MutableStateFlow("foo")
  val text: StateFlow<String> = textMutation.asStateFlow()

  fun changeText(text: String) {
    textMutation.value = text
  }
}

SomeModel のテストは RxJava の例と同じく、changeText 関数によるテキストの変更を text を観測して確かめる。 RxJava の TestObserver に相当するものとして、StateFlow の通知する値を詰め込んでおく ArrayList を用意する。 ただし kotlinx-coroutines のバージョンによってテストの書き方が変わることに注意する。

次の例は 1.5.2 以前の書き方。

class SomeModelTest : StringSpec() {
  init {
    "Change text" {
      runBlockingTest {
        // Given: SomeModel 
        val model = SomeModel()
        val observedChanges: MutableList<String> = ArrayList()
        val job = launch {
          model.text.toList(observedChanges)
        }

        // When: Change text
        model.changeText("bar")

        // And: Tear down job
        job.cancel()

        // Then: Observe 2 updates
        observedChanges.size.shouldBe(2)

        // And: "foo" comes first
        observedChanges[0].shouldBe("foo")

        // And: "bar" comes second
        observedChanges[1].shouldBe("bar")
      }
    }
  }
}

次の例は 1.6.0 以降の書き方。 runBlockingTest が deprecated となり、代わりに runTest を使う。これにともなって、テストの前準備として StandardTestDispatcherDispatchers.setMain にわたすことと、runTest ブロック内で launch するときに UnconfinedTestDispatcher を利用して StateFlow が通知する全ての値を観測可能にすることが必要になる。 この変更についてはマイグレーションガイドが提供されている。

class SomeModelTest : StringSpec() {
  init {
    "Change text" {
      runTest {
        // Given: SomeModel 
        val model = SomeModel()
        val observedChanges: MutableList<String> = ArrayList()
        val job = launch(UnconfinedTestDispatcher()) {
          model.text.toList(observedChanges)
        }

        // When: Change text
        model.changeText("bar")

        // And: Tear down job
        job.cancel()

        // Then: Observe 2 updates
        observedChanges.size.shouldBe(2)

        // And: "foo" comes first
        observedChanges[0].shouldBe("foo")

        // And: "bar" comes second
        observedChanges[1].shouldBe("bar")
      }
    }
  }

  // Robolectric を利用したテストを記述するとき、StandardTestDispatcher の設定は必ず beforeTest で実行する
  //(beforeSpec では Looper クラスが見つからず失敗する)
  override fun beforeTest(testCase: TestCase) {
    super.beforeTest(testCase)
    Dispatchers.setMain(StandardTestDispatcher())
  }

  override fun afterTest(testCase: TestCase, result: TestResult) {
    Dispatchers.resetMain()
    super.afterTest(testCase, result)
  }
}