Testing Kotlin flows by using toList() extension skips some emissions

Viewed 65

I was testing some Kotlin Flows according to what the document mentioned in this part.

I created a FakeRepository and a FakeViewModel like this:

class FakeRepository {
    private val flow = MutableSharedFlow<Int>()
    suspend fun emit(value: Int) = flow.emit(value)
    val scores: Flow<Int> = flow
}

class FakeViewModel(private val repository: FakeRepository) : ViewModel() {
    val score: StateFlow<Int> = repository.scores
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), 0)

    fun initialize() {
        viewModelScope.launch {
            //delay(100)
            repository.emit(1)
            //delay(100)
            repository.emit(2)
            //delay(100)
            repository.emit(3)
            //delay(100)
        }
    }
}

I also created a test class to test this score flow like this:

@ExperimentalCoroutinesApi
class FakeViewModelTest {
     /*
       Replace real dispatchers with instances of TestDispatchers to ensure that all 
       code runs on the single test thread.
     */
    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()

    @Test
    fun testFakeRepository() = runTest {
        val fakeRepository = FakeRepository()
        val viewModel = FakeViewModel(fakeRepository)

        val items = mutableListOf<Int>()
        val job = launch(StandardTestDispatcher()) {
            viewModel.score.toList(items)
        }

        viewModel.initialize()

        advanceUntilIdle()

        MatcherAssert.assertThat(items.size, Matchers.equalTo(4))

        job.cancel()
    }
}

When I run the test, surprisingly, it fails and says that the list size is 1.

Failing the test

But after uncommenting the delays, the test will be passed.

Test passed

Why the test behave like this? As the document mentioned here, TestDispatcher will skip delays.

Skipping delays by test dispatchers

Did I misunderstand something? How can I fix it?

1 Answers

As the StateFlow documentation says:

Updates to the value are always conflated. So a slow collector skips fast updates, but always collects the most recently emitted value.

A StateFlow has no memory whatsoever of previous values. In your current code, the collector and the initialize function's coroutine are racing each other. There's no guarantee that the emitter will wait for an item to be collected before emitting the next.

The possible reason the delay calls make it work is that even with the TestDispatcher, it might be suspending (yielding the thread), which would give the other coroutine a chance to collect each item. Not sure, because I don't know the details of what these TestDispatchers do under the hood.

If you want to guarantee values aren't dropped, you need to use a SharedFlow with BufferOverflow.SUSPEND. Or you could give it a replay of Int.MAX_VALUE, but this could be unsustainable depending on your use case.

Related