🧩
RENDEZVOUSのChannelが絡むViewModelをテストする
よく手癖で作りがちなこういう感じのクラスを想定
data class FooViewState(
val showLoading: Boolean = false,
)
sealed interface FooViewEvent {
data object ShowBarScreen : FooViewEvent
}
class FooViewModel : ViewModel() {
private val _uiState = MutableStateFlow(FooViewState())
val uiState: StateFlow<FooViewState> = _uiState.asStateFlow()
private val _uiEvent = Channel<FooViewEvent>()
val uiEvent: Flow<FooViewEvent> = _uiEvent.receiveAsFlow()
fun execute() = viewModelScope.launch {
_uiState.emit(FooViewState(showLoading = true))
// API通信処理を行なっているものとする
delay(1000L)
_uiEvent.send(FooViewEvent.ShowBarScreen)
_uiState.emit(FooViewState(showLoading = false))
}
}
問題点
Channel のデフォルトコンストラクタは capacity として RENDEZVOUS という特別な値を取っている。このときバッファを持たない特殊な Channel となり、購読者が現れるまで send() で待機するという動作になる。
そのため、Channel#send() をしてから MutableStateFlow#emit() をするコードを書くと、Channel#send() から先に進まなくなる。
問題のあるテストコード
@OptIn(ExperimentalCoroutinesApi::class)
class FooViewModelTest : DescribeSpec({
lateinit var viewModel: FooViewModel
lateinit var testScheduler: TestCoroutineScheduler
beforeSpec {
val testDispatcher = StandardTestDispatcher()
testScheduler = testDispatcher.scheduler
Dispatchers.setMain(testDispatcher)
}
beforeEach {
viewModel = FooViewModel()
}
afterSpec {
Dispatchers.resetMain()
}
describe("#execute") {
it("ローディングが表示されること") {
// turbineを使用
viewModel.uiState.test {
awaitItem() shouldBe FooViewState(showLoading = false)
viewModel.execute()
testScheduler.runCurrent()
awaitItem() shouldBe FooViewState(showLoading = true)
testScheduler.advanceTimeBy(1000)
testScheduler.runCurrent()
// ここに到達しない
awaitItem() shouldBe FooViewState(showLoading = false)
}
}
it("BarScreenを表示すること") {
viewModel.uiEvent.test {
viewModel.execute()
testScheduler.advanceUntilIdle()
awaitItem() shouldBe FooViewEvent.ShowBarScreen
}
}
}
})
プロダクションコードをいじる解決策
emit と send の順を入れ替える
こうすればテストは通るものの、本質的な解決ではない。
_uiState.emit(FooViewState(showLoading = false))
_uiEvent.send(FooViewEvent.SendBarScreen)
バッファを指定する
Channel のコンストラクタを BUFFERED にしたり、明示的に capacity を指定すると Channel#send() でバッファに詰むようになるため停止しなくなる。
private val _uiEvent = Channel<FooViewEvent>(capacity = 1)
ただしその分メモリ容量を消費する。
trySend を使う
Channel#send() ではなく Channel#trySend() を使うことで send() できないケースでも処理が継続されるようになる。
_uiEvent.trySend(FooViewEvent.SendBarScreen)
ただ厳密には返り値の ChannelResult を見て成否をチェックする必要がある。
テストコードだけで解決する方法
テスト完了まで uiEvent が collect されるように修正する。
it("ローディングが表示されること") {
launch(testDispatcher + Job()) {
viewModel.uiEvent.collect()
}
viewModel.uiState.test {
awaitItem() shouldBe FooViewState(showLoading = false)
viewModel.execute()
testScheduler.runCurrent()
awaitItem() shouldBe FooViewState(showLoading = true)
testScheduler.advanceTimeBy(1000)
testScheduler.runCurrent()
awaitItem() shouldBe FooViewState(showLoading = false)
}
}
シンプルに collect するだけだと即座に購読が終わってしまうので、テスト全体が終わるまで購読させ続けるために、Job から CoroutineScope を作る必要がある、という感じ。
ちゃんと Job をキャンセルさせるお行儀の良いヘルパーを作るとよさそう。
suspend fun <T> TestScope.ignoreCollectEvent(
eventFlow: Flow<T>,
testAction: suspend () -> Unit,
) {
val testJob = Job()
try {
val collectJob = CoroutineScope(testJob).launch { eventFlow.collect() }
testAction()
collectJob.cancel()
} finally {
testJob.cancel()
}
}
// 以下のように書ける
it("ローディングが表示されること") {
ignoreCollectEvent(
eventFlow = viewModel.uiEvent,
) {
viewModel.uiState.test {
awaitItem() shouldBe FooViewState(showLoading = false)
viewModel.execute()
testScheduler.runCurrent()
awaitItem() shouldBe FooViewState(showLoading = true)
testScheduler.advanceTimeBy(1000)
testScheduler.runCurrent()
awaitItem() shouldBe FooViewState(showLoading = false)
}
}
}
Discussion