Android unit testing view model that receives flow
Solution 1
There are few issues in this testing environment as:
- The
flow
builder will emit the result instantly so always the last value will be received. - The
viewState
holder has no link with our mocks hence is useless. - To test the actual flow with multiple values, delay and fast-forward control is required.
- The response values need to be collected for assertion
Solution:
- Use
delay
to process both values in the flow builder - Remove
viewState
. - Use
MainCoroutineScopeRule
to control the execution flow with delay - To collect observer values for assertion, use
ArgumentCaptor
.
Source-code:
-
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 } } }
MainCoroutineScopeRule.kt source to copy the file
-
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):
View mvvm-flow-coroutine-testing repo on Github for complete implementaion.
Solution 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.
Ma2340
Updated on June 23, 2022Comments
-
Ma2340 about 2 years
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) } } }