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) | |
} | |
} |
Job Offers
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
andmock()
at the beginning without any specific implementation or stubs. - After initialization of the dependencies, the ViewModel can be instantiated with mock. (sut — system under test — the target of tests)
@Before
defines code running every time before the test. We want a new instance of the ViewModel.- Mocked classes are stubbed with a great variety of dictated behaviours during the running of the test to achieve the required behaviour. To clean them, the
reset
the statement is used to clean the mock of all stubs. - 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.
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 the runTest
which provides us background scope to run testing coroutines.
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.
Thanks for reading! Do not forget to clap and follow for more content!
Code repository:
Resources:
This article was previously published on proandroiddev.com