How can I test a flow that uses .stateIn in an And...
# test
m
How can I test a flow that uses .stateIn in an Android ViewModel?
s
What did you try so far which didn't work?
m
I tried to use turbine and I tried all the suggestions chat GTP gave me 🤡 😅
s
Yeah Turbine sounds good, but at this point I still don't know what exactly you tried and how exactly it failed to help you here
m
I have:
Copy code
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn

class CategoriesViewModel(getCategoriesUseCase: GetCategoriesUseCase) : ViewModel() {
    val state = getCategoriesUseCase()
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5000),
            initialValue = emptyList()
        )
}
Copy code
import app.cash.turbine.test
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.mock
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.asserter

class CategoriesViewModelTest {
    private lateinit var sut: CategoriesViewModel
    private val categoryDomainModels = getFakeCategoryDomainModelList()
    private val getCategoriesUseCase: GetCategoriesUseCase = mock {
        every { invoke() } returns flowOf(categoryDomainModels)
    }

    @Test
    fun given_a_use_case_that_returns_a_list_of_categories_when_the_VM_fetches_them_it_should_return_a_list_with_one_CategoryDomainModel() =
        runTest {
            CategoriesViewModel(getCategoriesUseCase = getCategoriesUseCase).state.stateIn(this)
                .test {
                    skipItems(1) // Skip the initial empty emission

                    val items = awaitItem() // Wait for the categories to emit

                    asserter.assertEquals(
                        "Expected item list size does not match.",
                        categoryDomainModels.size,
                        items.size
                    )
                    asserter.assertEquals(
                        "First item does not match.", categoryDomainModels.first(), items.first()
                    )

                    cancelAndIgnoreRemainingEvents()
                }
        }
}
Getting:
app.cash.turbine.TurbineAssertionError: No value produced in 3se
s
I think tou can omit the
.stateIn(this)
there.
.test
should start the collection and make your flow hot already. Also since you're testing a real ViewModel directly which uses viewModelScope, that internally uses Dispatchers.Main.immediate, which i think is quite tricky to get to work well with coroutine tests. I think this talk https://zsmb.co/talks/untangling-coroutine-testing/ goes over it. The interesting part is here

https://youtu.be/nKCsIHWircA?t=1418&si=bR0gvb80aBTd21BVâ–¾

where it's explained how to replace the main dispatcher with the one made for tests
m
thanks! It’s weird I’ve always been using that annotation but I’ve forgotten it in this project I’m doing 😓
oh true: this is KMP app and I’m testing the shared module (should VMs be in the
commonMain
module? 🤔) so I can’t use JUnit’s
TestWatcher
s
If you're in KMP it might be better to just fix the problem on a different level. So in the latest ViewModel versions, you are able to provide a CoroutineScope yourself in its constructor instead of letting it default to the main dispatcher internally. In your test, as you construct your ViewModel you can instead pass in there
backgroundScope
and then it will use that instead of the main dispatcher when you call .viewModelScope
In fact, it is better to do this in non KMP contexts too, but I didn't mention it at first since I don't know if you're on the latest version etc.
m
interesting 🤔
Copy code
viewModelScope: CoroutineScope = Dispatchers.Main + SupervisorJob()
Gives
Copy code
Type mismatch.
Required:
CoroutineScope
Found:
CoroutineContext
OK well I kind of fix it by doing CoroutineScope(Dispatcher.Main + SupervisorJob()), and I tried to test it, and same error as usual:
Copy code
@Test
    fun test() = runTest {
        val categories = listOf(
            Category(id = 1, name = "Category 1", slug = "category-1"),
            Category(id = 2, name = "Category 2", slug = "category-2")
        )
        everySuspend {
            getCategoriesUseCase.invoke()
        } returns flowOf(categoryDomainModels)
        val viewModel = CategoriesViewModel(getCategoriesUseCase)

        viewModel.state.test {
            asserter.assertEquals("", categories, awaitItem())
            cancelAndIgnoreRemainingEvents()
        }
    }
Getting:
Copy code
Expected :[Category(id=1, name=Category 1, slug=category-1), Category(id=2, name=Category 2, slug=category-2)]
Actual   :[]
s
Shouldn't you be dropping your first emission of
initialValue = emptyList()
?
m
how come?
s
I mean, you are getting an emission of an empty list first right? Don't you want to do
skipItems(1)
as you did in the original snippet you sent?
m
right, I tried with
awaitItem()
but it didn’t work
thanks a lot Stylianos, it works and it’s so much better 🙂
s
Nice! Very glad to hear!
m
I never read Ian’s post so I wasn’t aware of this
s
Yup, it's new-ish, so it will take some time until everyone picks up this habit
Btw allow me to send yet another article talking about some of the problems with testing on StateFlow https://www.billjings.com/posts/title/testing-stateflow-is-annoying/?up= And how you may even want to test using the
.value
on it. It's something I've tried some times in the past with good success.
m
thanks!
I started using
.stateIn
because of some articles I read in Medium and similar about how to properly load data from a VM on start
none of them mentioned the testing....
s
stateIn
is in fact the right way to build your UI state emission from your VM. That has nothing to do with the tests themselves. Even if you were using a MutableStateFlow which you were adjusting yourself, you'd still have a tricky time testing against a StateFlow. So I don't think
stateIn
itself makes testing any harder than it already was
gratitude thank you 1
199 Views