How test a ViewModel function that launch a viewModelScope coroutine? Android Kotlin
Asked Answered
P

2

21

I´m trying to figure out the simplest way to test this kind on function members, I´ve seen more complex cases like Coroutines - unit testing viewModelScope.launch methods but didn´t solved

ListScreenViewModel.kt

@HiltViewModel
class ListScreenViewModel @Inject constructor(): ViewModel() {

    private var _itemsNumber = mutableStateOf(0)

    private var _testList = mutableStateOf(listOf<String>())
    val testList = _testList

    fun addItem() {
        viewModelScope.launch {
            _itemsNumber.value++
            _testList.value += (
                "Item ${_itemsNumber.value}"
                )
        }
    }
}

ListScreenViewModelTest.kt

class ListScreenViewModelTest{

    private lateinit var viewModel: ListScreenViewModel

    @Before
    fun setup(){
        viewModel = ListScreenViewModel()
    }

    @Test
    fun `add an item to the list of items`(){
        val numberOfItems = viewModel.testList.value.size
        viewModel.addItem()
        assert(viewModel.testList.value.size == numberOfItems+1)
    }
}

Error message

Exception in thread "Test worker" java.lang.IllegalStateException: Module with the Main dispatcher had failed to initialize. For tests Dispatchers.setMain from kotlinx-coroutines-test module can be used

Proviso answered 9/4, 2022 at 12:15 Comment(0)
C
31

You need to use something called TestCoroutineDispatcher during local unit tests & the best way to use it creating a Rule.

You can read about this in detail here: https://developer.android.com/codelabs/advanced-android-kotlin-training-testing-survey#3

I would recommend you go through this whole codelab. It will be really helpful.

Update for version 1.6.1:

Based on this migration guide: https://github.com/Kotlin/kotlinx.coroutines/blob/master/kotlinx-coroutines-test/MIGRATION.md

testImplementation ("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.1") {
        // https://github.com/Kotlin/kotlinx.coroutines/tree/master/kotlinx-coroutines-debug#debug-agent-and-android
        exclude group: "org.jetbrains.kotlinx", module: "kotlinx-coroutines-debug"
    } 

Then create a rule like this in your test directory, Notice the StandardTestDispatcher change:

@ExperimentalCoroutinesApi
class MainCoroutineRule(private val dispatcher: TestDispatcher = StandardTestDispatcher()) :
    TestWatcher() {

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

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

Use it like this, Notice the usage of runTest & advanceUntilIdle:

@OptIn(ExperimentalCoroutinesApi::class)
class ListScreenViewModelTest {

    @ExperimentalCoroutinesApi
    @get:Rule
    var mainCoroutineRule = MainCoroutineRule()

    private lateinit var viewModel: ListScreenViewModel

    @Before
    fun setUp() {
        viewModel = ListScreenViewModel()
    }


    @Test
    fun `add an item to the list of items`() = runTest {
        val numberOfItems = viewModel.testList.value.size
        viewModel.addItem()
        advanceUntilIdle()
        assert(viewModel.testList.value.size == numberOfItems + 1)
    }
}

Original Answer:

For the solution

Add this dependency:

 testImplementation ("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.5.2") {
        // https://github.com/Kotlin/kotlinx.coroutines/tree/master/kotlinx-coroutines-debug#debug-agent-and-android
        exclude group: "org.jetbrains.kotlinx", module: "kotlinx-coroutines-debug"
    }

Then Create a rule like this in your test directory:

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestCoroutineDispatcher
import kotlinx.coroutines.test.TestCoroutineScope
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.rules.TestWatcher
import org.junit.runner.Description

@ExperimentalCoroutinesApi
class MainCoroutineRule(val dispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher()) :
    TestWatcher(),
    TestCoroutineScope by TestCoroutineScope(dispatcher) {

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

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

Use it like this:

import kotlinx.coroutines.ExperimentalCoroutinesApi
import org.junit.Before
import org.junit.Rule
import org.junit.Test

class ListScreenViewModelTest {

    @ExperimentalCoroutinesApi
    @get:Rule
    var mainCoroutineRule = MainCoroutineRule()

    private lateinit var viewModel: ListScreenViewModel

    @Before
    fun setup(){
        viewModel = ListScreenViewModel()
    }

    @Test
    fun `add an item to the list of items`(){
        val numberOfItems = viewModel.testList.value.size
        viewModel.addItem()
        assert(viewModel.testList.value.size == numberOfItems+1)
    }
}
Crosspollination answered 9/4, 2022 at 12:53 Comment(8)
In the last releases TestCoroutineScope and TestCoroutineDispatcher were deprecated. Can you update the solution using that lastest apis? ThanksAnachronistic
@Anachronistic Are you using coroutines-test:1.6.1 version?Crosspollination
I'm using v1.6.1Anachronistic
@Anachronistic Updated answer for 1.6.1Crosspollination
It was suggested to have UnconfinedTestDispatcher in the rule and use StandartTestDispatcher for specific use cases which set up over Dispatchers.setMain(). In many cases you need to check only final result where the first dispatcher fits better, some cases would require more granular control and that's where second dispatcher is used. Test suite uses the mix of them.Saury
However personally I faced with issue. advanceUntilIdle should wait until all coroutines are finished, but it seems there is a race condition in the next assertThat() call which causes ambiguity in the behaviour of my test case -- sometimes it passes, sometimes it fails.Saury
update, now Description is not nullable anymorePhilanthropic
Is this solution working if I use Dispatchers.io ?? I am facing the racing conditions issue even if I use the advanceUntilIdle methodTipstaff
P
0

Expanding on the accepted answer, you can use UnconfinedTestDispatcher and avoid calling advanceUntilIdle(). Using the previously specified MainCoroutineRule implementation, you can do it like:

@OptIn(ExperimentalCoroutinesApi::class)
class ListScreenViewModelTest {

    @ExperimentalCoroutinesApi
    @get:Rule
    var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher())

    private lateinit var viewModel: ListScreenViewModel

    @Before
    fun setUp() {
        viewModel = ListScreenViewModel()
    }


    @Test
    fun `add an item to the list of items`() = runTest {
        val numberOfItems = viewModel.testList.value.size
        viewModel.addItem()

        assert(viewModel.testList.value.size == numberOfItems + 1)
    }
}
Perk answered 27/6, 2023 at 12:23 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.