🧩
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