Search code examples
androidunit-testingandroid-viewmodelkotlin-stateflow

Android ViewModel with StateFlow - testing issue. Test never waits for next value


I have search view model like this. searchPoiUseCase doing requests to Room DB. For testing purposes i am using Room.inMemoryDatabaseBuilder.

@HiltViewModel
class SearchVm @Inject constructor(
    private val searchPoiUseCase: SearchPoiUseCase
) : ViewModel() {

    private val queryState = MutableStateFlow("")
    
    @OptIn(FlowPreview::class)
    val searchScreenState = queryState
        .filter { it.isNotEmpty() }
        .debounce(500)
        .distinctUntilChanged()
        .map { query -> searchPoiUseCase(SearchPoiUseCase.Params(query)) }
        .map { result ->
            if (result.isEmpty()) SearchScreenUiState.NothingFound
            else SearchScreenUiState.SearchResult(result.map { it.toListUiModel() })
        }
        .stateIn(
            viewModelScope,
            SharingStarted.WhileSubscribed(5_000),
            SearchScreenUiState.None
        )
    
    fun onSearch(query: String) {
        queryState.value = query
    }

}

On the device this logic works perfectly fine. But i can't succeed with Unit Testing this logic. Here is my unit test:

@OptIn(ExperimentalCoroutinesApi::class)
@HiltAndroidTest
@Config(application = HiltTestApplication::class)
@RunWith(RobolectricTestRunner::class)
class SearchViewModelTest {

    @get:Rule
    var hiltRule = HiltAndroidRule(this)

    @Inject
    lateinit var searchUseCase: SearchPoiUseCase

    lateinit var SUT: SearchVm

    @Before
    fun setup() {
        hiltRule.inject()
        SUT = SearchVm(searchUseCase)
        Dispatchers.setMain(UnconfinedTestDispatcher())
    }

    @After
    fun teardown() {
        Dispatchers.resetMain()
    }

    @Test
    fun `test search view model`() = runTest {
        val collectJob = launch { SUT.searchScreenState.collect() }
        assertEquals(SearchScreenUiState.None, SUT.searchScreenState.value)

        SUT.onSearch("Query")
        assertEquals(SearchScreenUiState.NothingFound, SUT.searchScreenState.value)

        collectJob.cancel()
    }
}

The second assertion always failed. Am i missing something? Thanks in advance!

UPDATED Thanks to Ibrahim Disouki

His solution working for me with one change

 @Test
    fun `test search view model`() = runTest {
        whenever(searchUseCase(SearchPoiUseCase.Params("Query"))).thenReturn(emptyList()) // here you can create another test case when return valid data

        assertEquals(SearchScreenUiState.None, SUT.searchScreenState.value)

        val job = launch {
            SUT.searchScreenState.collect() //now it should work
        }

        SUT.onSearch("Query")
        advanceTimeBy(500) // This is required in order to bypass debounce(500)
        runCurrent() // Run any pending tasks at the current virtual time, according to the testScheduler.

        assertEquals(SearchScreenUiState.NothingFound, SUT.searchScreenState.value)

        job.cancel()
    }

Solution

  • Please check the following references:

    Also, your view model can be run with the regular JUnit test runner as it does not contain any specific Android framework dependencies. Check my working and tested version of your unit test:

    import junit.framework.TestCase.assertEquals
    import kotlinx.coroutines.Dispatchers
    import kotlinx.coroutines.ExperimentalCoroutinesApi
    import kotlinx.coroutines.flow.collect
    import kotlinx.coroutines.launch
    import kotlinx.coroutines.test.*
    import org.junit.After
    import org.junit.Before
    import org.junit.Test
    import org.junit.runner.RunWith
    import org.mockito.Mock
    import org.mockito.junit.MockitoJUnitRunner
    import org.mockito.kotlin.whenever
    
    @OptIn(ExperimentalCoroutinesApi::class)
    @RunWith(MockitoJUnitRunner::class)
    class SearchViewModelTest {
    
        @Mock
        private lateinit var searchUseCase: SearchPoiUseCase
    
        lateinit var SUT: SearchVm
    
        @Before
        fun setup() {
            Dispatchers.setMain(UnconfinedTestDispatcher())
            SUT = SearchVm(searchUseCase)
        }
    
        @After
        fun teardown() {
            Dispatchers.resetMain()
        }
    
        @Test
        fun `test search view model`() = runTest {
            whenever(searchUseCase(SearchPoiUseCase.Params("Query"))).thenReturn(emptyList()) // here you can create another test case when return valid data
    
            assertEquals(SearchScreenUiState.None, SUT.searchScreenState.value)
    
            val job = launch {
                SUT.searchScreenState.collect() //now it should work
            }
    
            SUT.onSearch("Query")
    
            runCurrent() // Run any pending tasks at the current virtual time, according to the testScheduler.
    
            assertEquals(SearchScreenUiState.NothingFound, SUT.searchScreenState.value)
    
            job.cancel()
        }
    }
    

    Another important thing from mocking the SearchPoiUseCase is to manipulating its result to be able to test more cases for example:

    • Return an empty list
    • Return a list of results.
    • etc...