I am testing a ViewModel using a fake repository which uses a StateFlow
to store the fake data. This StateFlow is exposed as a normal Flow
from the repository. In the ViewModel, I am mapping the received data from repository to a UiState class. When I test the UiState using a local test, I am not getting the updated UiState after adding a new entry i.e. I am stuck on the Loading state and not getting any new updates. Here are the files for viewmodel, repository and the test class.
class FakeMedicineRepository : MedicineRepository {
private val _medicines = MutableStateFlow<List<Medicine>>(emptyList())
override val allMedicines: Flow<List<Medicine>> = _medicines.asStateFlow()
suspend fun emit(value: List<Medicine>) = _medicines.emit(value)
override suspend fun addMedicine(
name: String,
purchasePrice: BigDecimal,
sellingPrice: BigDecimal
) {
_medicines.update {
it.plus(
Medicine(
it.size + 1L,
name,
purchasePrice,
sellingPrice
)
)
}
}
override suspend fun isNameTaken(name: String): Boolean {
return _medicines.value.any { it.name == name }
}
}
class MedicinesViewModel(
medicineRepository: MedicineRepository
) : ViewModel() {
val uiState = medicineRepository.allMedicines
.map {
MedicinesUiState.Success(it.map { it.toUiState() })
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = MedicinesUiState.Loading
)
}
class MedicinesViewModelTest {
private lateinit var repository: FakeMedicineRepository
private lateinit var viewModel: MedicinesViewModel
@Before
fun setup() {
repository = FakeMedicineRepository()
viewModel = MedicinesViewModel(repository)
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `when observe medicines should return empty list`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {
println("State: $it")
}
}
var uiState = viewModel.uiState.value
uiState.shouldBeTypeOf<MedicinesUiState.Loading>()
repository.addMedicine("Test", 0.toBigDecimal(), 0.toBigDecimal())
// This assertion fails
viewModel.uiState.value.shouldBeTypeOf<MedicinesUiState.Success>()
}
}
I followed the testing guidance from Testing Kotlin flows on Android. I am using the exact steps as mentioned there, the only difference being they use a SharedFlow
in the repository. But I also tried replacing the StateFlow in the fake repository with a SharedFlow with no success.
Looks like I forgot set the Main dispatcher in my unit test that's why the StateFlow
collection wasn't happening inside the ViewModel. I had seen it being mentioned in the documentation for testing coroutines and to use it whenever we are testing a coroutine which gets started in the viewModelScope
as it uses a hardcoded Main dispatcher.
But I ignored it as on the documentation for testing flows then don't mention it anywhere and just write the test case without setting the dispatcher.
Anyways, here is the final working test file:
class MedicinesViewModelTest {
private lateinit var repository: FakeMedicineRepository
private lateinit var viewModel: MedicinesViewModel
@get:Rule
val mainRule = MainDispatcherRule()
@Before
fun setup() {
repository = FakeMedicineRepository()
viewModel = MedicinesViewModel(repository)
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `when observe medicines should return empty list`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {
println("State: $it")
}
}
var uiState = viewModel.uiState.value
uiState.shouldBeTypeOf<MedicinesUiState.Loading>()
repository.addMedicine("Test", 0.toBigDecimal(), 0.toBigDecimal())
// This assertion is now working
viewModel.uiState.value.shouldBeTypeOf<MedicinesUiState.Success>()
}
}
And, here's the dispatcher rule which I added in the test file above:
class MedicinesViewModelTest {
private lateinit var repository: FakeMedicineRepository
private lateinit var viewModel: MedicinesViewModel
@get:Rule
val mainRule = MainDispatcherRule()
@Before
fun setup() {
repository = FakeMedicineRepository()
viewModel = MedicinesViewModel(repository)
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `when observe medicines should return empty list`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {
println("State: $it")
}
}
var uiState = viewModel.uiState.value
uiState.shouldBeTypeOf<MedicinesUiState.Loading>()
repository.addMedicine("Test", 0.toBigDecimal(), 0.toBigDecimal())
// This assertion is now working
viewModel.uiState.value.shouldBeTypeOf<MedicinesUiState.Success>()
}
}
It's straight from the official documentation on testing coroutines.