21

I have a ViewModel that talks to a use case and gets a flow back i.e Flow<MyResult>. I want to unit test my ViewModel. I am new to using the flow. Need help pls. Here is the viewModel below -

class MyViewModel(private val handle: SavedStateHandle, private val useCase: MyUseCase) : ViewModel() {

        private val viewState = MyViewState()

        fun onOptionsSelected() =
            useCase.getListOfChocolates(MyAction.GetChocolateList).map {
                when (it) {
                    is MyResult.Loading -> viewState.copy(loading = true)
                    is MyResult.ChocolateList -> viewState.copy(loading = false, data = it.choclateList)
                    is MyResult.Error -> viewState.copy(loading = false, error = "Error")
                }
            }.asLiveData(Dispatchers.Default + viewModelScope.coroutineContext)

MyViewState looks like this -

 data class MyViewState(
        val loading: Boolean = false,
        val data: List<ChocolateModel> = emptyList(),
        val error: String? = null
    )

The unit test looks like below. The assert fails always don't know what I am doing wrong there.

class MyViewModelTest {

    @get:Rule
    val instantExecutorRule = InstantTaskExecutorRule()

    private val mainThreadSurrogate = newSingleThreadContext("UI thread")

    private lateinit var myViewModel: MyViewModel

    @Mock
    private lateinit var useCase: MyUseCase

    @Mock
    private lateinit var handle: SavedStateHandle

    @Mock
    private lateinit var chocolateList: List<ChocolateModel>

    private lateinit var viewState: MyViewState


    @Before
    fun setup() {
        MockitoAnnotations.initMocks(this)
        Dispatchers.setMain(mainThreadSurrogate)
        viewState = MyViewState()
        myViewModel = MyViewModel(handle, useCase)
    }

    @After
    fun tearDown() {
        Dispatchers.resetMain() // reset main dispatcher to the original Main dispatcher
        mainThreadSurrogate.close()
    }

    @Test
    fun onOptionsSelected() {
        runBlocking {
            val flow = flow {
                emit(MyResult.Loading)
                emit(MyResult.ChocolateList(chocolateList))
            }

            Mockito.`when`(useCase.getListOfChocolates(MyAction.GetChocolateList)).thenReturn(flow)
            myViewModel.onOptionsSelected().observeForever {}

            viewState.copy(loading = true)
            assertEquals(viewState.loading, true)

            viewState.copy(loading = false, data = chocolateList)
            assertEquals(viewState.data.isEmpty(), false)
            assertEquals(viewState.loading, true)
        }
    }
}
Ma2340
  • 647
  • 2
  • 17
  • 34

2 Answers2

32

There are few issues in this testing environment as:

  1. The flow builder will emit the result instantly so always the last value will be received.
  2. The viewState holder has no link with our mocks hence is useless.
  3. To test the actual flow with multiple values, delay and fast-forward control is required.
  4. The response values need to be collected for assertion

Solution:

  1. Use delay to process both values in the flow builder
  2. Remove viewState.
  3. Use MainCoroutineScopeRule to control the execution flow with delay
  4. To collect observer values for assertion, use ArgumentCaptor.

Source-code:

  1. MyViewModelTest.kt

    import androidx.arch.core.executor.testing.InstantTaskExecutorRule
    import androidx.lifecycle.Observer
    import androidx.lifecycle.SavedStateHandle
    import com.pavneet_singh.temp.ui.main.testflow.*
    import org.junit.Assert.assertEquals
    import kotlinx.coroutines.delay
    import kotlinx.coroutines.flow.flow
    import kotlinx.coroutines.runBlocking
    import org.junit.Before
    import org.junit.Rule
    import org.junit.Test
    import org.mockito.ArgumentCaptor
    import org.mockito.Captor
    import org.mockito.Mock
    import org.mockito.Mockito.*
    import org.mockito.MockitoAnnotations
    
    class MyViewModelTest {
    
        @get:Rule
        val instantExecutorRule = InstantTaskExecutorRule()
    
        @get:Rule
        val coroutineScope = MainCoroutineScopeRule()
    
        @Mock
        private lateinit var mockObserver: Observer<MyViewState>
    
        private lateinit var myViewModel: MyViewModel
    
        @Mock
        private lateinit var useCase: MyUseCase
    
        @Mock
        private lateinit var handle: SavedStateHandle
    
        @Mock
        private lateinit var chocolateList: List<ChocolateModel>
    
        private lateinit var viewState: MyViewState
    
        @Captor
        private lateinit var captor: ArgumentCaptor<MyViewState>
    
    
        @Before
        fun setup() {
            MockitoAnnotations.initMocks(this)
            viewState = MyViewState()
            myViewModel = MyViewModel(handle, useCase)
        }
    
        @Test
        fun onOptionsSelected() {
            runBlocking {
                val flow = flow {
                    emit(MyResult.Loading)
                    delay(10)
                    emit(MyResult.ChocolateList(chocolateList))
                }
    
                `when`(useCase.getListOfChocolates(MyAction.GetChocolateList)).thenReturn(flow)
                `when`(chocolateList.get(0)).thenReturn(ChocolateModel("Pavneet", 1))
                val liveData = myViewModel.onOptionsSelected()
                liveData.observeForever(mockObserver)
    
                verify(mockObserver).onChanged(captor.capture())
                assertEquals(true, captor.value.loading)
                coroutineScope.advanceTimeBy(10)
                verify(mockObserver, times(2)).onChanged(captor.capture())
                assertEquals("Pavneet", captor.value.data[0].name)// name is custom implementaiton field of `ChocolateModel` class
            }
        }
    }
    
  2. MainCoroutineScopeRule.kt source to copy the file

  3. List of dependencies

    dependencies {
        implementation fileTree(dir: 'libs', include: ['*.jar'])
        implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
        implementation 'androidx.appcompat:appcompat:1.1.0'
        implementation 'androidx.core:core-ktx:1.2.0'
        implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
        implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
        implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0'
        testImplementation 'junit:junit:4.12'
        androidTestImplementation 'androidx.test.ext:junit:1.1.1'
        androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
        implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.3.0-alpha01'
        implementation 'org.mockito:mockito-core:2.16.0'
        testImplementation 'androidx.arch.core:core-testing:2.1.0'
        testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.3.5'
        testImplementation 'org.mockito:mockito-inline:2.13.0'
    }
    

Output (gif is optimized by removing frames so bit laggy):

Flow testing

View mvvm-flow-coroutine-testing repo on Github for complete implementaion.

Pavneet_Singh
  • 36,884
  • 5
  • 53
  • 68
  • How do we test LiveData this way? – IgorGanapolsky Oct 23 '20 at 18:29
  • 1
    @IgorGanapolsky There are multiple ways, if you have a mocked or fake repository then you can trigger the events with desired data. or you could either use livedatabuilder or [flowasLiveData](https://developer.android.com/reference/kotlin/androidx/lifecycle/package-summary#(kotlinx.coroutines.flow.Flow).asLiveData(kotlin.coroutines.CoroutineContext,%20kotlin.Long)). – Pavneet_Singh Oct 24 '20 at 23:43
  • @Pavneet_Singh What if the received data is `PagingData`? – Dr.jacky Feb 15 '21 at 22:11
  • @Dr.jacky based on your implementation and flow, you probably will have to setup the mocked objects or faces. – Pavneet_Singh Apr 14 '21 at 16:54
  • What are the differences between 'src/test/' and 'src/androidTest' folders for unit testing the viewmodel class? how its handled the execution of viewmodel functions in a main thread? – Marlon López Jun 13 '21 at 02:52
  • the unit test should go under `src/test`, `src/androidTest` is for instrumentation tests. the execution depends on implementiaon and what/how you wanna test. – Pavneet_Singh Jul 13 '21 at 21:04
  • @Pavneet_Singh how to achieve the same if we don't want to transform the `flow` into `liveData`. How to test the flow directly. I tried `flow.toList(destination)` but only receive the initial value of flow. Newly emitted events are not receives. Need your help. – ankuranurag2 Oct 27 '21 at 12:04
  • @ankuranurag2 have you tried collecting the flow using `collect` or maybe use `.toList().last()` – Pavneet_Singh Oct 27 '21 at 20:40
  • I am facing something similar issue but in my case I need the flow in viewmodel update the screen with data. https://stackoverflow.com/questions/71307504/android-espresso-kotlin-flow-not-collecting-data – Akshay Nandwana Mar 01 '22 at 15:16
2

I think I have found a better way to test this, by using Channel and consumeAsFlow extension function. At least in my tests, I seem to be able to test multiple values sent throught the channel (consumed as flow).

So.. say you have some use case component that exposes a Flow<String>. In your ViewModelTest, you want to check that everytime a value is emitted, the UI state gets updated to some value. In my case, UI state is a StateFlow, but this should be do-able with LiveData as well. Also, I am using MockK, but should also be easy with Mockito.

Given this, here is how my test looks:

@Test
fun test() = runBlocking(testDispatcher) {

    val channel = Channel<String>()
    every { mockedUseCase.someDataFlow } returns channel.consumeAsFlow()

    channel.send("a")
    assertThat(viewModelUnderTest.uiState.value, `is`("a"))

    channel.send("b")
    assertThat(viewModelUnderTest.uiState.value, `is`("b"))
}

EDIT: I guess you can also use any kind of hot flow implementation instead of Channel and consumeAsFlow. For example, you can use a MutableSharedFlow that enables you to emit values when you want.

racosta
  • 46
  • 2