Infinito Nirone 7

白羽の矢を刺すスタイル

AAC ViewModel と StateFlow を組み合わせたときのユニットテスト

前回の続きで、Kotlin Coroutines の StateFlow を利用した AAC ViewModel のテストをする場合の Kotlin Coroutines 1.6.0 の記述について。

blog.keithyokoma.dev

StateFlow をつかった AAC ViewModel

次のような AAC ViewModel を継承した SomeViewModel を作ります。 内部で StateFlow<String> を持っており、この StateFlow<String> の値を Jetpack Compose などで購読して使うような想定です。 StateFlow<String> を更新する操作も changeText メソッドとして定義してあります。

前回も似たようなモデルクラスをテストしましたが、今回はこの changeText メソッドが viewModelScope の中で StateFlow<String> を更新する点が異なります。

class SomeViewModel(
  private val mainDispatcher: CoroutineDispatcher = Dispatchers.Main,
) : ViewModel() {
  private val textMutation: MutableStateFlow<String> = MutableStateFlow("foo")
  val text: StateFlow<String> = textMutation.asStateFlow()

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

この ViewModel のテストを次のように記述すると、失敗することがあります。

class SomeModelTest : StringSpec() {
  init {
    "Change text" {
      runTest {
        // Given: SomeModel 
        val model = SomeModel()
        val observedChanges: MutableList<String> = ArrayList()
        val job = launch(UnconfinedTestDispatcher(testScheduler)) {
          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")
      }
    }
  }

  override fun beforeTest(testCase: TestCase) {
    super.beforeTest(testCase)
    Dispatchers.setMain(StandardTestDispatcher())
  }

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

問題は changeText メソッドで StateFlow<String> に対する値の更新操作が、この記述では即座に実行されないためにテストでかき集めようとしている更新した値のリストが初期値のみを含むリストになってしまう点にあります(値の送出と購読がそれぞれ並列に動くとき)。 この問題を解消する方法として、runCurrent を使う方法があります。

StateFlow に対する更新操作をするたびに runCurrent を呼び出すことで、購読側の処理も即座に実行されるようになります。 先程のテストの場合、// When: Change text のあとで実行している model.changeText("bar") のあとに runCurrent を呼び出すことで、期待する順序と数の値がテストで観測できるようになります。

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

        // When: Change text
        model.changeText("bar")
        runCurrent()          // <<<<<<=== here!

        // 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")
      }
    }
  }

  override fun beforeTest(testCase: TestCase) {
    super.beforeTest(testCase)
    Dispatchers.setMain(StandardTestDispatcher())
  }

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

注意点としては、runCurrent を使うことで実際のアプリケーションの動作環境では保証のない処理の順序を強制してしまう点があります。

Links