8

I have this view model:

class MyViewModel(private val myUseCase: MyUseCase) : ViewModel() {

    val stateLiveData = MutableLiveData(State.IDLE)

    fun onButtonPressed() {
        viewModelScope.launch {
            stateLiveData.value = State.LOADING
            myUseCase.loadStuff() // Suspend
            stateLiveData.value = State.SUCCESS
        }
    }
}

I'd like to write a test that checks whether the state is really LOADING while myUseCase.loadStuff() is running. I'm using MockK for that. Here's the test class:

@ExperimentalCoroutinesApi
class MyViewModelTest {

    @get:Rule
    val rule = InstantTaskExecutorRule()

    private lateinit var myUseCase: MyUseCase
    private lateinit var myViewModel: MyViewModel

    @Before
    fun setup() {
        myUseCase = mockkClass(MyUseCase::class)
        myViewModel = MyViewModel(myUseCase)
    }

    @Test
    fun `button click should put screen into loading state`() = runBlockingTest {
        coEvery { myUseCase.loadStuff() } coAnswers  { delay(2000) }
        myViewModel.onButtonPressed()
        advanceTimeBy(1000)
        val state = myViewModel.stateLiveData.value
        assertEquals(State.LOADING, state)
    }
}

It fails:

java.lang.AssertionError: 
Expected :LOADING
Actual   :IDLE

How can I fix this?

Milack27
  • 1,619
  • 2
  • 20
  • 31

2 Answers2

5

I only needed to make a few changes in the test class to make it pass:

@ExperimentalCoroutinesApi
class MyViewModelTest {

    @get:Rule
    val rule = InstantTaskExecutorRule()

    private val dispatcher = TestCoroutineDispatcher()

    private lateinit var myUseCase: MyUseCase
    private lateinit var myViewModel: MyViewModel

    @Before
    fun setup() {
        Dispatchers.setMain(dispatcher)

        myUseCase = mockkClass(MyUseCase::class)
        myViewModel = MyViewModel(myUseCase)
    }

    @After
    fun cleanup() {
        Dispatchers.resetMain()
    }

    @Test
    fun `button click should put screen into loading state`() {
        dispatcher.runBlockingTest {
            coEvery { myUseCase.loadStuff() } coAnswers  { delay(2000) }
            myViewModel.onButtonPressed()

            // This isn't even needed.
            advanceTimeBy(1000)

            val state = myViewModel.stateLiveData.value
            assertEquals(State.LOADING, state)
        }
    }
}

No changes needed in the view model at all! :D

Thanks Kiskae for such helpful advice!

Milack27
  • 1,619
  • 2
  • 20
  • 31
  • Have you run into an issue where only the first test runs and the rest fail? I'm trying to use mockk and my current test class looks just like yours but this problem is preventing me from moving forward. Even if I copy the same @Test method and just change the method name it still fails after the first test passes. – MikeOscarEcho Dec 18 '19 at 19:44
  • 1
    No, actually I think I didn't write any other test. Maybe try to use a different dispatcher instance for each test run. – Milack27 Dec 18 '19 at 20:06
2

Your problem lies in the fact that viewModelScope dispatches to Dispatcher.MAIN, not the testing dispatcher created by runBlockingTest. This means that even with the call to advanceTimeBy the code does not get executed.

You can solve the issue by using Dispatcher.setMain(..) to replace the MAIN dispatcher with your test dispatcher. This will require managing the dispatcher yourself instead of relying on the stand-alone runBlockingTest.

Kiskae
  • 24,655
  • 2
  • 77
  • 74
  • Thanks for answering! Unfortunately, it's impossible to pass `viewModelScope` to the constructor, because it's an extension property of ViewModel. – Milack27 Aug 30 '19 at 16:48
  • I tried calling `runBlockingTest(Dispatchers.Main)`, but the main dispatcher doesn't implement DelayController. So you're right, I need to find a way to change the dispatcher used by the view model. – Milack27 Aug 30 '19 at 16:52
  • 1
    `Dispatcher.setMain(testDispatcher)` would do the job, but you would need to manage it yourself in `@Before/@After` and use `testDispatcher.runBlockingTest {` as well. – Kiskae Aug 30 '19 at 17:08