Testing Android Flows in ViewModel with Turbine
Testing of the MVI/MVVM architecture built with flows made easy
As it is common now, the correct architecture should contain decoupled layers. Inter-layer communication between them is done by streams, or in our case, flows, in Android. To make our solutions foolproof, we should create tests around every component, and the ViewModel is no exception. The more channels the ViewModel contains, the more challenging is to assert its complexity of it. The states should be emitted in a controlled way rather than in a chaotic.
Photo by Brett Jordan on Unsplash
Set up
Everything will be done without instrumentation tests, so the phone does not need to be connected.
These are the required Android dependencies needed at the app’s build.gradle
for testing:
// core
testImplementation "junit:junit:4.13.2"
// for coroutine handling
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.1"
// mockito for creating mocks and templates
testImplementation "org.mockito:mockito-core:5.3.1"
testImplementation "org.mockito.kotlin:mockito-kotlin:4.1.0"
// turbine for testing the flows
testImplementation 'app.cash.turbine:turbine:1.0.0'
Sample ViewModel
The article will follow a simple ViewModel with one StateFlow (feel free to use the flow or any other channel), which carries all the information for any view. The ViewModel intakes one interface for mocking the repository, which does some work for a long time in the actual application.
// interface for heavy computation job, which returns the string
interface HeavyComputationTemplate {
suspend fun doComputation(): String
}
// Hilt annotations are not needed in the example
// because we will manually inject mocks - in real app with hilt, they are essential
@HiltViewModel
class ExampleViewModel @Inject constructor(
private val computationRepo: HeavyComputationTemplate,
) : ViewModel() {
private val _vmState: MutableStateFlow<VmState> = MutableStateFlow(VmState.Waiting)
val vmState = _vmState.asStateFlow()
fun onEvent(event: VmEvents): Job = when (event) {
VmEvents.OnLaunch -> onLaunch()
}
private fun onLaunch() = viewModelScope.launch(Dispatchers.Main) {
_vmState.value = VmState.Running
val result = computationRepo.doComputation()
_vmState.value = VmState.Finished(result)
_vmState.value = VmState.Waiting
}
}
Since, the plan is to create unit tests - the tests do not care about specific implementation under repository, beacuse it will be mocked. The unit tests will test logic of ViewModel, not repository. This will prevent failing of the testcases for ViewModel, eventhough the tests for the repository might be failing. The tests will point you to the current issues.
The app takes an event, which launches the job and goes through a series of events. Sealed states are used to utilise the exhaustive listing of the states. All it does is minimise place for errors and possible logic issues.
sealed class VmEvents {
object OnLaunch: VmEvents()
}
sealed class VmState {
object Waiting: VmState()
object Running: VmState()
data class Finished(val data: String): VmState()
}
If the state carries some data, use the
data class
, because the test can seamlessly compare the received state and expected state. The Android Studio will even generate comparison function for the data class, if it is needed. The simple class would result in an error every time as the hashcodes of the two different instances would not be the same.
Testing the ViewModel
Initial test case
With the ViewModel prepared, let’s create the first unit test about checking our default state in the StateFlow
. The ViewModel waits for the event, so the expected state is Waiting
. Here is the introductory code:
@RunWith(JUnit4::class)
class TurbineViewModelTest {
// 1.
@Mock
lateinit var heavyComputation: HeavyComputationTemplate
// 2.
@get:Rule
val mockitoRule: MockitoRule = MockitoJUnit.rule()
@Test
fun `Given the sut is initialized, then it waits for event`() {
// 3.
val sut = ExampleViewModel(heavyComputation)
// 4.
assertTrue(sut.vmState.value == VmState.Waiting)
}
}
It is a lot going on there, so let’s explain it step by step:
- Firstly, the required dependencies by our ViewModel need to be defined. We have an interface
HeavyComputationTemplate
which abstracts the logic for the ViewModel. Since it is an interface, the dependency can be mocked with annotation@Mock
at the beginning without any specific implementation or stubs. - Mockito rule to instantiate mocks automatically. It validates usage and detects incorrect stubbing of the mocks. The rule allows you to use any runner without constraints and take advantage of mockito at the same time.
- After initialization of the dependencies, the ViewModel can be instantiated with mock. (sut — system under test — the target of tests)
- Assertion of the ViewModel’s StateFlow that it contains the expected state.
Try to run it as a test, and if a green check appears, the following tests can be done.
I like to create first test for default set up of the class. It can act as proof of good initialisation of the class / it simplifies initial debugging of dependencies.
Testing one StateFlow
Let’s create the test for launching the computation task with the expected result in the form of a string, which the app should receive in the form of a state.
@Test
fun `Given the ViewModel waits - When the event OnLaunch comes, then execute heavy computation with result`() =
// 1.
runTest {
// ARRANGE
val expectedString = "Result"
// 2.
heavyComputation.stub {
onBlocking { doComputation() } doAnswer {expectedString}
}
val sut = ExampleViewModel(heavyComputation)
// 3.
sut.vmState.test {
// ACTION
sut.onEvent(VmEvents.OnLaunch)
// CHECK
// 4.
assertEquals(VmState.Waiting, awaitItem())
assertEquals(VmState.Running, awaitItem())
assertEquals(VmState.Finished(expectedString), awaitItem())
assertEquals(VmState.Waiting, awaitItem())
// the test will finish on its own, because of lambda usage
}
}
runTest
executes the test in its coroutine, which most times does the same job asrunBlocking
. Without any changes, it skipsdelay
within the coroutine. This results in the immediate execution of tests without waiting for the result. The test is run on a single thread, so any child coroutine is always run on the same thread if it is not defined in another way..stub
prepares the mock to expect the call on itself. If the mocked input does not correspond to the input during the code execution in the test, the error will be thrown. (Do not try to overcome it by enabling default inputs in gradle). The mocked method is suspended that is why theonBlocking
is used — without the suspend method it would beon
.flow.test{}
creates lambda within which the flow should be tested. In this lambda body, the test should test the required behaviour by calling appropriate methods and waiting for needed states.awaitItem
, as it says, waits for the following item from the flow. It can take a while to execute code and emit a new state in the flow. The test should compare the contents of the states and if they are in the correct order.awaitItem
returns the result of the flow, so feel free to assert the correctness as it is needed.
The code seems correct as it progresses from Waiting
to Running
following the Finished
state with the result and back to Waiting
. However, the following error will pop up:
Module with the Main dispatcher had failed to initialize. For tests Dispatchers.setMain from kotlinx-coroutines-test module can be used java.lang.IllegalStateException: Module with the Main dispatcher had failed to initialize. For tests Dispatchers.setMain from kotlinx-coroutines-test module can be used
In our code, the app refers to the main thread by calling the Dispatchers.Main
. However, the unit test does not provide any main thread. There is none. To go around it, the main thread can be mocked by setting the test dispatcher as the main dispatcher for coroutines. It has to be called before executing the test and disposed after finishing the test as follows:
@Before
fun setUp() {
// setting up test dispatcher as main dispatcher for coroutines
Dispatchers.setMain(StandardTestDispatcher())
}
@After
fun tearDown() {
// removing the test dispatcher
Dispatchers.resetMain()
}
After adding the test dispatcher, the test should run with a green check.
This setup can be extracted into a rule as below to make it reusable across tests.
/**
* Example usage:
*
* @get:Rule
* val dispatcherRule = StandardDispatcherRule()
*
*/
@OptIn(ExperimentalCoroutinesApi::class)
class StandardDispatcherRule: TestWatcher() {
override fun starting(description: Description?) {
Dispatchers.setMain(StandardTestDispatcher())
super.starting(description)
}
override fun finished(description: Description?) {
Dispatchers.resetMain()
super.finished(description)
}
}
Testing multiple StateFlows
Firstly, the ViewModel should contain another StateFlow. To make it straightforward the ViewModel will possess two identical StateFlows. The jobs are run asynchronously on the same thread. All other functionalities are the same as before.
@HiltViewModel
class ExampleViewModel @Inject constructor(
private val computationRepo: HeavyComputationTemplate,
) : ViewModel() {
private val _vmState: MutableStateFlow<VmState> = MutableStateFlow(VmState.Waiting)
val vmState = _vmState.asStateFlow()
// duplicate StateFlow to track
private val _secondVmState: MutableStateFlow<VmState> = MutableStateFlow(VmState.Waiting)
val secondVmState = _secondVmState.asStateFlow()
fun onEvent(event: VmEvents): Job = when (event) {
VmEvents.OnLaunch -> onLaunch()
}
private fun onLaunch() = viewModelScope.launch(Dispatchers.Main) {
// running the jobs asynchronously
awaitAll(
async { processFirstTask() },
async { processSecondTask() }
)
}
private suspend fun processFirstTask() {
_vmState.value = VmState.Running
val result = computationRepo.doComputation()
_vmState.value = VmState.Finished(result)
_vmState.value = VmState.Waiting
}
private suspend fun processSecondTask() {
_secondVmState.value = VmState.Running
val result = computationRepo.doComputation()
_secondVmState.value = VmState.Finished(result)
_secondVmState.value = VmState.Waiting
}
}
The usage of the Turbine API will change now as the test needs to deal with two flows at the same time.
@Test
fun `Given the ViewModel waits - When the event OnLaunch comes, then both computations runs successfully`() =
runTest {
turbineScope {
// ARRANGE
val expectedString = "Result"
heavyComputation.stub {
onBlocking { doComputation() } doAnswer { expectedString }
}
val sut = ExampleViewModel(heavyComputation)
val firstStateReceiver = sut.vmState.testIn(backgroundScope)
val secondStateReceiver = sut.secondVmState.testIn(backgroundScope)
// ACTION
sut.onEvent(VmEvents.OnLaunch)
// CHECK
assertEquals(VmState.Waiting, firstStateReceiver.awaitItem())
assertEquals(VmState.Waiting, secondStateReceiver.awaitItem())
assertEquals(VmState.Running, firstStateReceiver.awaitItem())
assertEquals(VmState.Running, secondStateReceiver.awaitItem())
assertEquals(VmState.Finished(expectedString), firstStateReceiver.awaitItem())
assertEquals(VmState.Finished(expectedString), secondStateReceiver.awaitItem())
assertEquals(VmState.Waiting, firstStateReceiver.awaitItem())
assertEquals(VmState.Waiting, secondStateReceiver.awaitItem())
firstStateReceiver.cancel()
secondStateReceiver.cancel()
}
}
The arrangement of the test at the beginning stays the same. However, the StateFlow is not tested via lambda. Creating lambdas for multiple states would result in chaotic code. Every state, which needs to be tested in the test case, can construct a receiver. The receiver needs to run in some coroutine scope. It is solved by using a combination of the runTest
and turbineScope
which provides us with background scope to run testing coroutines with state emission tracking.
Afterwards, the test can take some action to call required functions and wait for the events coming from the ViewModel in the form of the states, which are asserted and compared with needed expectations.
Do not forget to cancel or complete the flows at the end of the test. Otherwise, the test will hang / timeout will fail the test.
Conclusion
With the correct mocking strategy and the right level of abstraction, the turbine can help you test the ViewModels all the way through. Moreover, it is much easier to understand the code if the states represent the actual state, which is transformed into the UI. Make the code verbose as much as possible to make your life and life of others easier. Furthermore, testing uncovers many issues before releasing them to production.