🧩

RENDEZVOUSのChannelが絡むViewModelをテストする

2025/01/19に公開

よく手癖で作りがちなこういう感じのクラスを想定

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 を見て成否をチェックする必要がある。

テストコードだけで解決する方法

テスト完了まで uiEventcollect されるように修正する。

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