Coroutines - unit testing viewModelScope.launch methods

13,034

Solution 1

What you need to do is wrap your launching of a coroutine into a block with given dispatcher.

var ui: CoroutineDispatcher = Dispatchers.Main
var io: CoroutineDispatcher =  Dispatchers.IO
var background: CoroutineDispatcher = Dispatchers.Default

fun ViewModel.uiJob(block: suspend CoroutineScope.() -> Unit): Job {
    return viewModelScope.launch(ui) {
        block()
    }
}

fun ViewModel.ioJob(block: suspend CoroutineScope.() -> Unit): Job {
    return viewModelScope.launch(io) {
        block()
    }
}

fun ViewModel.backgroundJob(block: suspend CoroutineScope.() -> Unit): Job {
    return viewModelScope.launch(background) {
        block()
    }
}

Notice ui, io and background at the top. Everything here is top-level + extension functions.

Then in viewModel you start your coroutine like this:

uiJob {
    when (val result = fetchRubyContributorsUseCase.execute()) {
    // ... handle result of suspend fun execute() here         
}

And in test you need to call this method in @Before block:

@ExperimentalCoroutinesApi
private fun unconfinifyTestScope() {
    ui = Dispatchers.Unconfined
    io = Dispatchers.Unconfined
    background = Dispatchers.Unconfined
}

(Which is much nicer to add to some base class like BaseViewModelTest)

Solution 2

As @Gergely Hegedus mentions above, the CoroutineScope needs to be injected into the ViewModel. Using this strategy, the CoroutineScope is passed as an argument with a default null value for production. For unit tests the TestCoroutineScope will be used.

SomeUtils.kt

/**
 * Configure CoroutineScope injection for production and testing.
 *
 * @receiver ViewModel provides viewModelScope for production
 * @param coroutineScope null for production, injects TestCoroutineScope for unit tests
 * @return CoroutineScope to launch coroutines on
 */
fun ViewModel.getViewModelScope(coroutineScope: CoroutineScope?) =
    if (coroutineScope == null) this.viewModelScope
    else coroutineScope

SomeViewModel.kt

class FeedViewModel(
    private val coroutineScopeProvider: CoroutineScope? = null,
    private val repository: FeedRepository
) : ViewModel() {

    private val coroutineScope = getViewModelScope(coroutineScopeProvider)

    fun getSomeData() {
        repository.getSomeDataRequest().onEach {
            // Some code here.            
        }.launchIn(coroutineScope)
    }

}

SomeTest.kt

@ExperimentalCoroutinesApi
class FeedTest : BeforeAllCallback, AfterAllCallback {

    private val testDispatcher = TestCoroutineDispatcher()
    private val testScope = TestCoroutineScope(testDispatcher)
    private val repository = mockkClass(FeedRepository::class)
    private var loadNetworkIntent = MutableStateFlow<LoadNetworkIntent?>(null)

    override fun beforeAll(context: ExtensionContext?) {
        // Set Coroutine Dispatcher.
        Dispatchers.setMain(testDispatcher)
    }

    override fun afterAll(context: ExtensionContext?) {
        Dispatchers.resetMain()
        // Reset Coroutine Dispatcher and Scope.
        testDispatcher.cleanupTestCoroutines()
        testScope.cleanupTestCoroutines()
    }

    @Test
    fun topCafesPoc() = testDispatcher.runBlockingTest {
        ...
        val viewModel = FeedViewModel(testScope, repository)
        viewmodel.getSomeData()
        ...
    }
}

Solution 3

As others mentioned, runblocking just blocks the coroutines launched in it's scope, it's separate from your viewModelScope. What you could do is to inject your MyDispatchers.Background and set the mainDispatcher to use dispatchers.unconfined.

Share:
13,034

Related videos on Youtube

Prem
Author by

Prem

Updated on February 12, 2022

Comments

  • Prem
    Prem over 2 years

    I am writing unit tests for my viewModel, but having trouble executing the tests. The runBlocking { ... } block doesn't actually wait for the code inside to finish, which is surprising to me.

    The test fails because result is null. Why doesn't runBlocking { ... } run the launch block inside the ViewModel in blocking fashion?

    I know if I convert it to a async method that returns a Deferred object, then I can get the object by calling await(), or I can return a Job and call join(). But, I'd like to do this by leaving my ViewModel methods as void functions, is there a way to do this?

    // MyViewModel.kt
    
    class MyViewModel(application: Application) : AndroidViewModel(application) {
    
        val logic = Logic()
        val myLiveData = MutableLiveData<Result>()
    
        fun doSomething() {
            viewModelScope.launch(MyDispatchers.Background) {
                System.out.println("Calling work")
                val result = logic.doWork()
                System.out.println("Got result")
                myLiveData.postValue(result)
                System.out.println("Posted result")
            }
        }
    
        private class Logic {
            suspend fun doWork(): Result? {
              return suspendCoroutine { cont ->
                  Network.getResultAsync(object : Callback<Result> {
                          override fun onSuccess(result: Result) {
                              cont.resume(result)
                          }
    
                         override fun onError(error: Throwable) {
                              cont.resumeWithException(error)
                          }
                      })
              }
        }
    }
    
    // MyViewModelTest.kt
    
    @RunWith(RobolectricTestRunner::class)
    class MyViewModelTest {
    
        lateinit var viewModel: MyViewModel
    
        @get:Rule
        val rule: TestRule = InstantTaskExecutorRule()
    
        @Before
        fun init() {
            viewModel = MyViewModel(ApplicationProvider.getApplicationContext())
        }
    
        @Test
        fun testSomething() {
            runBlocking {
                System.out.println("Called doSomething")
                viewModel.doSomething()
            }
            System.out.println("Getting result value")
            val result = viewModel.myLiveData.value
            System.out.println("Result value : $result")
            assertNotNull(result) // Fails here
        }
    }
    
    
    • Andre Artus
      Andre Artus about 5 years
      Please ensure that your question shows a Minimal, Complete, and Verifiable example (stackoverflow.com/help/mcve), it would make it easier to answer your question.
    • Vairavan
      Vairavan about 5 years
      runBlocking will only wait for child coroutines. Coroutines created using viewModelScope is not related to the scope inside runBlocking.
    • Vahab Ghadiri
      Vahab Ghadiri almost 4 years
      Better approach is to pass dispatcher context to viewmodel, so you can pass your test dispather.. in your tests!
  • Prem
    Prem about 5 years
    I don't think the issue is with the live data not propagating the value. If I change the executor in my viewmodel to a synchronous executor, then the test passes. So it definitely has something to do with the coroutines
  • Prem
    Prem about 5 years
    Also the InstantTaskExecutorRule rule makes sure livedata posts value instantly
  • Andre Artus
    Andre Artus about 5 years
    Did you try it? InstantTaskExecutorRule only ensure the AAC run synchronously, you still need an observer.
  • Andre Artus
    Andre Artus about 5 years
  • AdamHurwitz
    AdamHurwitz about 4 years
    Thanks @Gergely Hegedus. Craig Russell outlines a similar strategy. Do you have any simple samples to show how to organize the production/test-code?
  • AdamHurwitz
    AdamHurwitz about 4 years
    Here is a strategy for injecting viewModelScope into the ViewModel: How to inject viewModelScope for Android unit test with Kotlin coroutines?.
  • USMAN osman
    USMAN osman about 4 years
    Don’t use Dispatchers.Unconfined as a replacement of Dispatchers.Main, it will break all assumptions and timings for code that does use Dispatchers.Main. Since a unit test should run well in isolation and without any side effects, you should call Dispatchers.resetMain() and clean up the executor when the test finishes running. reference: medium.com/androiddevelopers/… .. heading: Unit Testing viewModelScope 4th paragraph...
  • Admin
    Admin about 4 years
    thanks for the info. I am editing my answer as I found TestCoroutineDispatcher a better fit, and I am also applying rest after each test. I remember reading over that same blog you posted but it's kind of old now..
  • Edgar Khimich
    Edgar Khimich about 4 years
    You are not allowed to use ui dispatcher inside the viewmodel scope Nothing related to the UI should be inside the ViewModel Read more how to use ViewModels
  • Jim Ovejera
    Jim Ovejera about 3 years
    No injectScope for my ViewModel
  • Igor
    Igor about 2 years
    The default dispatcher of the viewModeScope is already the Main Dispatcher @EdgarKhimich