Tim Malseed
04/17/2023, 10:16 PMclass MyViewModel {
val uiState = repository.getSomeFlow().map { UiState.Success(it) }
.stateIn(viewModelScope, WhileSubscribed(), UIState.Loading)
}
When it comes to testing the ViewModel, I’ll have a fake repository where getSomeFlow()
has a different implementation:
class FakeRepository: Repository {
var onGetSomeFlow: () -> Flow = {
throw NotImplementedException()
}
override fun getSomeFlow(): Flow {
return onGetSomeFlow()
}
}
A problem arises though - if I instantiate the ViewModel in the @Before
test setup function, then the flow value is derived at that point:
@Before
fun setup() {
viewModel = MyViewModel() // the uiState will be derived at this time
}
Then, in an individual test, if I want to change the behaviour of getSomeFlow()
, it has no effect:
@Test
fun MyTest() {
fakeRepository.onGetSomeFlow = { flowOf(someValue) }
...
testAssertions()
}
Tim Malseed
04/17/2023, 10:19 PM@Test
fun MyTest() {
fakeRepository.onGetSomeFlow = { flowOf(someValue) }
viewModel = MyViewModel(fakeRepository)
...
testAssertions()
}
Tim Malseed
04/17/2023, 10:20 PM@Before
is for!)Tim Malseed
04/17/2023, 10:22 PMandylamax
04/17/2023, 11:39 PMStylianos Gakis
04/18/2023, 7:56 AMTurbine
. Then in your test, emit items to that turbine, like in this test, and it will populate that flow, and in turn this will be emitted to your uiState.
It’s super convenient, you don’t get a first emission of something since it’s empty before you give it a value, and you don’t need to deal with `var`s and whatever issues that may bring you.Stylianos Gakis
04/18/2023, 9:59 AMWhileSubscribed
.
Usually when that’s a problem when you do stuff in the init {}
block, but you’re not doing that really. Your ViewModel setup looks perfectly fine, just that the FakeRepository could use some Turbinedewildte
04/20/2023, 4:37 PMStylianos Gakis
04/20/2023, 5:02 PMrepository.getSomeFlow()
function is called, which is not an issue actually. It’s only an issue here because the Fake
implementation has implemented it in this way where it can be lazily changed (since it’s a var) but the VM calls it on start.
I feel like there’s a misunderstanding here on what’s happening here vs other VMs which on init {} launch coroutines which is definitely a much different situationandylamax
04/20/2023, 9:27 PMIts already emitting o ly after its called due to it being WhileSubscribed
And when is WhileSubscribed called???
andylamax
04/20/2023, 9:32 PMclass MyViewModel(
private val repo: Repository
) {
val state = MutableState<Somethint>()
fun showSomething() {
repo.getSomeFlow().collect {
state.value = UiState.Success(it)
}
}
}
Now in your test code, you call showSomething
before your assertions.
I hope that is clearStylianos Gakis
04/21/2023, 7:03 AMStylianos Gakis
04/21/2023, 7:08 AMcollectStateWithLifecycle
won't help since you'd not be scoping the flow collection to your lifecycle scope.
All of this is solved by what was written in the original message, by using .stateIn and WhileSubscribed.
And when is WhileSubscribed called???
That's called on initialization, but that's not a side effect, you're just describing the configuration of your StateFlow, and that it's only going to be active after it's subscribed. Which means in ViewModel init, it's gonna be not active, and only after the first collectStateWithLifecycle is called it will start emitting values. And it will stop collecting after your app goes to the background.
andylamax
04/21/2023, 7:27 AMStylianos Gakis
04/21/2023, 7:37 AMandylamax
04/21/2023, 2:54 PMviewmodels
and repositories. Do nothing during constructions.
What approach would you take to make that compile?It really depends with your use case. But something in the lines of
class MyViewModel(
private val repo: Repository
) {
val state = MutableState<Somethint>()
fun showSomething() = viewModelScope.launch {
repo.getSomeFlow().collect {
state.value = UiState.Success(it)
}
}
}
If you call showSomething() multiple times, you'll have multiple coroutines collecting and updating the stateThen probably don't call it multiple times?? The same way you are not calling the constructor multiple times. If you want to make sure that its just one coroutine is running, that should reflect in your code then. Something like
class MyViewModel(
private val repo: Repository
) {
val state = MutableState<Somethint>()
private var job: Job?
fun showSomething() {
if(job != null) return
job = viewModelScope.launch {
repo.getSomeFlow().collect {
state.value = UiState.Success(it)
}
}
}
override onClose() {
job.cancel()
super.onClose()
}
}
This whole arrangement, makes testing easier, You instantiate all you dependenciess (i.e. repo
in this example), your viewModel.
And then test the behavior of the viewModel when showSomething()
is called.dewildte
04/21/2023, 7:15 PMStylianos Gakis
04/21/2023, 8:12 PMThen probably don’t call it multiple times??Sure, then you put the burden on the UI and allow it to make a mistake, while what I suggest you can’t make that mistake, you make it impossible. And you may think “just don’t call it twice, I am not stupid”, sure, but if you get a configuration change, and your activity gets recreated, and your UI is starting this flow onCreate, it will in fact call it again. And since the ViewModel will out-live this configuration change, you will in fact trigger this collection twice, so the first suggestion you give is not viable, so we cross that out. So with
class MyViewModel(
private val repo: Repository
) {
val state = MutableState<Somethint>()
fun showSomething() = viewModelScope.launch {
repo.getSomeFlow().collect {
state.value = UiState.Success(it)
}
}
}
• No coroutine starting on construction time.
• Calling showSomething
from your UI starts a coroutine which never stops. If you put the app in the background it still is collecting. If you do a config change and you therefore call it again onCreate you then launch yet another coroutine, collecting this more than once, potentially as many times as you’ve gotten a configuration change
With
class MyViewModel(
private val repo: Repository
) {
val state = MutableState<Somethint>()
private var job: Job?
fun showSomething() {
if(job != null) return
job = viewModelScope.launch {
repo.getSomeFlow().collect {
state.value = UiState.Success(it)
}
}
}
override onClose() {
job.cancel()
super.onClose()
}
}
• No coroutine starting on construction time.
• If you call it more than once you’ve got this safety mechanism which doesn’t let you run multiple collections at the same time, this is good
• You now have to keep a Job reference for each collection that you may be doing, scaling terribly if you want to do more like this. Also have to remember to call cancel
onClose.
• If you put the app in the background, nothing stops this collection, and it will keep collecting as long as your ViewModel is alive, despite the app being in the background and are likely not interested in the updates anymore, wasting resources
With
class MyViewModel(
private val repo: Repository
) {
val state: StateFlow<Something> = repo
.getSomeFlow()
.map { UiState.Success(it) }
.stateIn(viewModelScope, WhileSubscribed(), UIState.Loading) // Can add a number inside WhileSubscribed too, so that on a config change you don't start over, but keep it alive for a bit before it dies out
}
you get
• No coroutine starting on construction time.
• Your UI can’t mess up and call the flow more than once and turn two collections on at the same time
• The collection will stop when your UI is not in the resumed state when combined with collectAsStateWithLifecycle()
• You get super easy testing capabilities by properly faking your Repository
dependency as I explain here. Same as you’d do with the other solutions too actually.