Blog Infos
Author
Published
Topics
,
Published
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

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

, ,

Testing: how hard can it be?

When people start looking into the testing domain, very similar questions arise: What to test? And more important, what not? What should I mock? What should I test with unit tests and what with Instrumentation?…
Watch Video

Testing: how hard can it be?

DANNY PREUSSLER
Android Lead
Soundcloud

Testing: how hard can it be?

DANNY PREUSSLER
Android Lead
Soundcloud

Testing: how hard can it be?

DANNY PREUSSLER
Android Lead
Soundcloud

Jobs

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 and mock() at the beginning without any specific implementation or stubs.
  2. After initialization of the dependencies, the ViewModel can be instantiated with mock. (sut — system under test — the target of tests)
  3. @Before defines code running every time before the test. We want a new instance of the ViewModel.
  4. 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.
  5. 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.

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:

More articles like this

Resources:

This article was previously published on proandroiddev.com

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
Automation is a key point of Software Testing once it make possible to reproduce…
READ MORE
blog
Every good Android application should be well tested to minimize the risk of error…
READ MORE
blog
In this article we’ll go through how to own a legacy code that is…
READ MORE
blog

Running Instrumented Tests in a Gradle task

During the latest Google I/O, a lot of great new technologies were shown. The…
READ MORE

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.

Menu