Tomáš Repčík - 8. 6. 2023

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:

  1. Firstly, the required dependencies by our ViewModel need to be defined. We have an interface HeavyComputationTemplatewhich 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.
  2. 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.
  3. After initialization of the dependencies, the ViewModel can be instantiated with mock. (sut — system under test — the target of tests)
  4. 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
        }
    }
  1. runTest executes the test in its coroutine, which most times does the same job as runBlocking . Without any changes, it skips delay 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.
  2. .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 the onBlocking is used — without the suspend method it would be on.
  3. 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.
  4. 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.

Code repository:

Github with full code.

Subscribe for more
LinkedIn GitHub Medium