7
Kotlin 1.4.21

I have a very simple ViewModel that uses coroutine and stateFlow. However, the unit test will fail as the stateFlow doesn't seem to get updated.

I think its because the test will finish before the stateFlow is updated.

expected not to be empty

This is my ViewModel under test

class TrendingSearchViewModel @Inject constructor(
    private val loadTrendingSearchUseCase: LoadTrendingSearchUseCase,
    private val coroutineDispatcher: CoroutineDispatcherProvider
) : ViewModel() {

    private val trendingSearchMutableStateFlow = MutableStateFlow<List<String>>(emptyList())
    val trendingSearchStateFlow = trendingSearchMutableStateFlow.asStateFlow()

    fun getTrendingSearch() {
        viewModelScope.launch(coroutineDispatcher.io()) {
            try {
                trendingSearchMutableStateFlow.value = loadTrendingSearchUseCase.execute()
            } catch (exception: Exception) {
                Timber.e(exception, "trending ${exception.localizedMessage}")
            }
        }
    }
}

This is my actual test class, I have tried different things to get it to work

class TrendingSearchViewModelTest {
    private val loadTrendingSearchUseCase: LoadTrendingSearchUseCase = mock()
    private val coroutineDispatcherProvider = CoroutineDispatcherProviderImp()
    private lateinit var trendingSearchViewModel: TrendingSearchViewModel

    @Before
    fun setUp() {
        trendingSearchViewModel = TrendingSearchViewModel(
            loadTrendingSearchUseCase,
            coroutineDispatcherProvider
        )
    }

    @Test
    fun `should get trending search suggestions`() {
        runBlocking {
            // Arrange
            val trending1 = UUID.randomUUID().toString()
            val trending2 = UUID.randomUUID().toString()
            val trending3 = UUID.randomUUID().toString()

            whenever(loadTrendingSearchUseCase.execute()).thenReturn(listOf(trending1, trending2, trending3))

            val job = launch {
                trendingSearchViewModel.trendingSearchStateFlow.value
            }

            // Act
            trendingSearchViewModel.getTrendingSearch()

            // Assert
            val result = trendingSearchViewModel.trendingSearchStateFlow.value
            assertThat(result).isNotEmpty()

            job.cancel()
        }
    }
}

This is the usecase I am mocking in the test:

class LoadTrendingSearchUseCaseImp @Inject constructor(
    private val searchCriteriaProvider: SearchCriteriaProvider,
    private val coroutineDispatcherProvider: CoroutineDispatcherProvider
) : LoadTrendingSearchUseCase {

    override suspend fun execute(): List<String> {
        return withContext(coroutineDispatcherProvider.io()) {
            searchCriteriaProvider.provideTrendingSearch().trendingSearches
        }
    }
}

Just in case its needed this is my interface:

interface CoroutineDispatcherProvider {
    fun io(): CoroutineDispatcher = Dispatchers.IO
    fun default(): CoroutineDispatcher = Dispatchers.Default
    fun main(): CoroutineDispatcher = Dispatchers.Main
    fun immediate(): CoroutineDispatcher = Dispatchers.Main.immediate
    fun unconfined(): CoroutineDispatcher = Dispatchers.Unconfined
}

class CoroutineDispatcherProviderImp @Inject constructor() : CoroutineDispatcherProvider
1

2 Answers 2

1

I think this library https://github.com/cashapp/turbine by Jake Wharton will be of great help in the future when you need more complex scenarios.


What I think is happening is that in fragment you are calling .collect { } and that is ensuring the flow is started. Check the Terminal operator definition: Terminal operators on flows are suspending functions that start a collection of the flow. https://kotlinlang.org/docs/flow.html#terminal-flow-operators

This is not true for sharedFlow, which might be configured to be started eagerly.


So to solve your issue, you might just call

val job = launch {
    trendingSearchViewModel.trendingSearchStateFlow.collect()
}
Sign up to request clarification or add additional context in comments.

Comments

0

This is what worked for me:

@Test
fun `should get trending search suggestions`() {
    runBlockingTest {
        // Arrange
        val trending1 = UUID.randomUUID().toString()
        val trending2 = UUID.randomUUID().toString()
        val trending3 = UUID.randomUUID().toString()
        val listOfTrending = listOf(trending1, trending2, trending3)

        whenever(loadTrendingSearchUseCase.execute()).thenReturn(listOfTrending)

        /* List to collect the results */
        val listOfEmittedResult = mutableListOf<List<String>>()
        val job = launch {
            trendingSearchViewModel.trendingSearchStateFlow.toList(listOfEmittedResult)
        }

        // Act
        trendingSearchViewModel.getTrendingSearch()

        // Assert
        assertThat(listOfEmittedResult).isNotEmpty()
        verify(loadTrendingSearchUseCase).execute()

        job.cancel()
    }
}

1 Comment

What if I used a StateFlow in the ViewModel which is created using a StateIn operator? which means there is no other operation on the flow inside the view model but rather it is just a downward stream of data from the repo to the compose screen.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.