Search code examples
androidunit-testingkotlin-coroutines

How can i write unit test for Suspend function and stateflow in android


I am trying to write unit test for my application. I am using Mockk to mock classes.

This is my unit test

@OptIn(ExperimentalCoroutinesApi::class)
class MainViewModalTest {

    private lateinit var viewModel: MainViewModal
    private val savedStateHandle = mockk<SavedStateHandle>(relaxed = true)
    private val randomFactUseCase = mockk<RandomFactUseCase>()

    @get:Rule
    val mainDispatcher = MainDispatcherRule()


    @Before
    fun setup() {
        viewModel = MainViewModal(randomFactUseCase, savedStateHandle)
    }

    @Test
    fun `When fetchRandomFact succeeds, update UI state with fact`() = runBlockingTest {
        // Given
        coEvery { randomFactUseCase.invoke() } returns Result.Success(RandomFact(data = listOf("Some interesting fact")), 200)

        // When
        viewModel.fetchRandomFact()

        // Then
        val expectedUiState = UiState(loading = false, fact = "Some interesting fact", error = "")
        assert(viewModel.uiState.value == expectedUiState)
    }
}
class MainDispatcherRule(private val testDispatcher: TestDispatcher = UnconfinedTestDispatcher()) :
    TestWatcher() {

    override fun starting(description: Description) {
        super.starting(description)
        Dispatchers.setMain(testDispatcher)
    }

    override fun finished(description: Description) {
        super.finished(description)
        Dispatchers.resetMain()
    }
}

it is failing with this error

Exception in thread "Test worker @coroutine#1" io.mockk.MockKException: no answer found for RandomFactUseCase(#2).invoke(continuation {}) among the configured answers: ()

This is my viewModel:

@HiltViewModel
class MainViewModal @Inject constructor(
    private val randomFactUseCase: RandomFactUseCase,
    private val savedStateHandle: SavedStateHandle,

    ) : ViewModel() {
    init {
        fetchRandomFact()
    }

    val uiState: StateFlow<UiState> = savedStateHandle.getStateFlow("fact", UiState(loading = true))

    fun fetchRandomFact() {
        viewModelScope.launch {
            setState(loading = true)
            when (val response = randomFactUseCase()) {
                is Result.Success -> {
                    val fact =
                        if (response.data?.data?.isNotEmpty() == true) response.data.data[0] else ""
                    setState(
                        loading = false,
                        fact = fact,
                        error = if (fact.isBlank()) "No fact available!" else ""
                    )
                }

                is Result.Error -> {
                    setState(
                        loading = false,
                        fact = "",
                        error = response.exception.message
                            ?: "Something went wrong! Please try again"
                    )
                }
            }
        }
    }

    private fun setState(loading: Boolean = true, fact: String = "", error: String = "") {
        savedStateHandle["fact"] = UiState(loading = loading, fact = fact, error = error)
    }
}

@Parcelize
data class UiState(val loading: Boolean = true, val fact: String = "", val error: String = "") :
    Parcelable

Not sure what I am missing here. Although I feel that I am pretty close.

EDIT1: As per @broots suggestion I have updated the viewModel like this:

@HiltViewModel
class MainViewModal @Inject constructor(
    private val randomFactUseCase: RandomFactUseCase,
    private val savedStateHandle: SavedStateHandle,

    ) : ViewModel() {
    init {
        invokeFunction()
    }

    fun invokeFunction() {
        viewModelScope.launch {
            fetchRandomFact()
        }
    }

    val uiState: StateFlow<UiState> = savedStateHandle.getStateFlow("fact", UiState(loading = true))

    suspend fun fetchRandomFact() {
        setState(loading = true)
        when (val response = randomFactUseCase()) {
            is Result.Success -> {
                val fact =
                    if (response.data?.data?.isNotEmpty() == true) response.data.data[0] else ""
                setState(
                    loading = false,
                    fact = fact,
                    error = if (fact.isBlank()) "No fact available!" else ""
                )
            }

            is Result.Error -> {
                setState(
                    loading = false,
                    fact = "",
                    error = response.exception.message ?: "Something went wrong! Please try again"
                )
            }
        }
    }

    private fun setState(loading: Boolean = true, fact: String = "", error: String = "") {
        savedStateHandle["fact"] = UiState(loading = loading, fact = fact, error = error)
    }
}

It did not solve the issue. (Adding this for reference)


Solution

  • You are using test dispatcher that performs actions directly when called, and in your viewmodel you have call to usecase inside constructor. This way the mock is called still being inside @Before block

    If you want to define mock responses in test body, then you could make viewmodel variable lazy

        private val savedStateHandle = mockk<SavedStateHandle>(relaxed = true)
        private val randomFactUseCase = mockk<RandomFactUseCase>()
    
        private val viewModel: MainViewModal by lazy {
            MainViewModal(randomFactUseCase, savedStateHandle)
        }
    
        @Test
        fun `When fetchRandomFact succeeds, update UI state with fact`() = runBlockingTest {
            // Given
            coEvery { randomFactUseCase.invoke() } returns Result.Success(RandomFact(data = listOf("Some interesting fact")), 200)
    
            // When
            viewModel // dereferencing lazy property will call constructor, which calls method under test
    
            // Then
            val expectedUiState = UiState(loading = false, fact = "Some interesting fact", error = "")
            assert(viewModel.uiState.value == expectedUiState)
        }