Search code examples
androidkotlinviewmodelandroid-unit-testingkotlinx.coroutines.flow

Why ViewModel's StateFlow is not updating when inside a unit test and is mapped from a repository to UI state using stateIn?


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.

FakeMedicinesRepository

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 }
    }
}

MedicinesViewModel

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
        )
}

MedicinesViewModelTest

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.


Solution

  • 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.