前回の続きで、Kotlin Coroutines の StateFlow を利用した AAC ViewModel のテストをする場合の Kotlin Coroutines 1.6.0 の記述について。
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 を使うことで実際のアプリケーションの動作環境では保証のない処理の順序を強制してしまう点があります。