Search code examples
androidkotlinjunit4kotlin-coroutinesandroid-mvvm

Android unit testing view model that receives flow


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)
        }
    }
}

Solution

  • 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.