Hi, I’m having some trouble to test some state changes in my viewModel using MutableSharedFlow. For ...
e
Hi, I’m having some trouble to test some state changes in my viewModel using MutableSharedFlow. For example, I have this class
Copy code
class SampleViewModel : ViewModel() {
    private val interactions = Channel<Any>()
    private val state = MutableSharedFlow<Any>(
        onBufferOverflow = BufferOverflow.DROP_LATEST,
        extraBufferCapacity = 1
    )

    init {
        viewModelScope.launch {
            interactions.receiveAsFlow().collect { action ->
                processAction(action)
            }
        }
    }

    fun flow() = state.asSharedFlow()

    fun handleActions(action: Any) {
        viewModelScope.launch {
            interactions.send(action)
        }
    }

    suspend fun processAction(action: Any) {
        state.emit("Show loading state")

       // process something...

        state.emit("Show success state")
    }
}
I’m trying to test it using this method (already using version 1.6.1):
Copy code
@Test
	fun testHandleActions() = runTest {
	   	val emissions = mutableListOf<Any>()
	    val job = launch(UnconfinedTestDispatcher(testScheduler)) {
	    	viewModel.flow().collect {
	        	emissions.add(it)
	            }
		}
	    viewModel.handleActions("")
	    assertTrue(emissions[0] is String)
	    assertTrue(emissions[1] is String)
	    job.cancel()
	}
If I test the viewmodel.processAction() it works like a charm. But if I try to test handleActions() I only receive the first emission and then it throws an
IndexOutOfBoundsException
If I use the deprecated runBlockingTest, it works
Copy code
@Test
    fun testHandleActionsWithRunBlockingTest() = runBlockingTest {
        val emissions = mutableListOf<Any>()
        val job = launch {
            viewModel.flow().toList(emissions)
        }
        viewModel.handleActions("")
        job.cancel()

        assertTrue(emissions[0] is String)
        assertTrue(emissions[1] is String)
    }
Did I miss something using
runTest
block?
j
@esdrasdl Did you solve this?
e
Not yet =/
j
@esdrasdl Try this:
job.join()
after
viewModel.handleActions("")
. And put
println(emissions)
after
emissions.add(it)
. You should see that
emissions
is being populated as expected. It is no longer empty.
The possible reason why this helps:
In order to for the spawned-off asynchronous code to actually be executed, one must either yield or suspend the test body some other way, or use commands that control scheduling
From here. There's an example just below that text that shows using
job.join()
within
runTest
. Even though you use
UnconfinedTestDispatcher(testScheduler)
to (I assume) ensure that the
launch
coroutine is started before
viewModel.handleActions("")
, I think
viewModel.flow().collect
suspends indefinitely because all this is happening within a
runTest
.
This doesn't get you all the way to a working test, because the test will be suspended indefinitely at
job.join()
. But hopefully it gets you closer by addressing why emissions weren't being collected at all.
That said, modifying
viewModel.flow().collect
to
viewModel.flow().take(2).collect
will prevent indefinite suspension, and allow the test to pass and complete.
Design-wise, I wonder why a
Channel
is necessary. It seems like you can achieve the same, but simpler, with just a
MutableSharedFlow
that you
map
or/and
onEach
on.
e
Hi Julian, thank your for your effort to help me. I tried your suggestion, I replaced the flow statement with flow().take(2) and added job.join() after
viewModel.handleActions("").
. Unfortunately It didn’t work. Regarding using channel. I’m would like to try a MVI approach and I assumed it’s a good idea to have one public function to send eventos to viewmodel. I chose Channel because I guessed it should be simple to use. I guess I’m wrong, =/. BTW I replaced Channel for another MutableSharedFlow and the same problem happened.
May you give more details about your suggestion to use a
MutableSharedFlow
and avoid to use Channel ?
j
This is what I'm running. The main difference, which really should be a superficial one, is that I create a scope instead of using the one from
ViewModel
. This is so I can run the code without Android dependencies. But this should be okay, since the scope I create is the same as the one that
ViewModel
creates i.e. a scope with a
SupervisorJob
.
This seems to me essentially what you're trying to do, but with less complexity.
e
Nice. Indeed If I replace viewModelScope by CoroutineScope(SupervisorJob()), it works. I will take a look in how to change viewModelScope to another one in my tests. As a last approach I could do something like that.
Copy code
class SampleViewModel(
    var scope: CoroutineScope? = null
) : ViewModel() {
    
    init {
        scope = scope ?: viewModelScope
        scope?.launch {
            interactions.receiveAsFlow().collect { action ->
                processAction(action)
            }
        }
    }
}
j
I think best practice is to design classes that have a
CoroutineScope
/
CoroutineDispatcher
dependency such that they can be injected. Especially for testing, where it's a major benefit to have everything running on the same dispatcher.
ViewModel
isn't designed to have its dispatcher injected, but you may know that you can set it indirectly in your tests via
Dispatchers.setMain(TestCoroutineDispatcher())
. If you're not already doing this in your tests, then timing/coordination will be more tricky, for sure. See this in the official docs:
Note: The viewModelScope property of ViewModel classes is hardcoded to Dispatchers.Main. Replace it in tests by calling Dispatchers.setMain and passing in a test dispatcher.
Preferable to isolate functionality as much as possible away from android dependencies, so you can run vanilla non-instrumented tests.
e
Once again, thank you Julian. I’ll continue my studies about coroutines and tests.
🙏 1