Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How do I run coroutines as blocking for unit testing?

I've begun writing unit tests for my MVP Android project, but my tests dependent on coroutines intermittently fail (through logging and debugging I've confirmed verify sometimes occurs early, adding delay fixes this of course)

I've tried wrapping with runBlocking and I've discovered Dispatchers.setMain(mainThreadSurrogate) from org.jetbrains.kotlinx:kotlinx-coroutines-test, but trying so many combinations hasn't yielded any success so far.

abstract class CoroutinePresenter : Presenter, CoroutineScope {
    private lateinit var job: Job

    override val coroutineContext: CoroutineContext
        get() = job + Dispatchers.Main

    override fun onCreate() {
        super.onCreate()
        job = Job()
    }

    override fun onDestroy() {
        super.onDestroy()
        job.cancel()
    }
}

class MainPresenter @Inject constructor(private val getInfoUsecase: GetInfoUsecase) : CoroutinePresenter() {
    lateinit var view: View

    fun inject(view: View) {
        this.view = view
    }

    override fun onResume() {
        super.onResume()

        refreshInfo()
    }

    fun refreshInfo() = launch {
        view.showLoading()
        view.showInfo(getInfoUsecase.getInfo())
        view.hideLoading()
    }

    interface View {
        fun showLoading()
        fun hideLoading()

        fun showInfo(info: Info)
    }
}

class MainPresenterTest {
    private val mainThreadSurrogate = newSingleThreadContext("Mocked UI thread")

    private lateinit var presenter: MainPresenter
    private lateinit var view: MainPresenter.View

    val expectedInfo = Info()

    @Before
    fun setUp() {
        Dispatchers.setMain(mainThreadSurrogate)

        view = mock()

        val mockInfoUseCase = mock<GetInfoUsecase> {
            on { runBlocking { getInfo() } } doReturn expectedInfo
        }

        presenter = MainPresenter(mockInfoUseCase)
        presenter.inject(view)
        presenter.onCreate()
    }

    @Test
    fun onResume_RefreshView() {
        presenter.onResume()

        verify(view).showLoading()
        verify(view).showInfo(expectedInfo)
        verify(view).hideLoading()
    }

    @After
    fun tearDown() {
        Dispatchers.resetMain()
        mainThreadSurrogate.close()
    }
}

I believe the runBlocking blocks should be forcing all child coroutineScopes to run on the same thread, forcing them to complete before moving on to verification.

like image 588
kgbier Avatar asked Dec 22 '18 23:12

kgbier


2 Answers

In CoroutinePresenter class you are using Dispatchers.Main. You should be able to change it in the tests. Try to do the following:

  1. Add uiContext: CoroutineContext parameter to your presenters' constructor:

    abstract class CoroutinePresenter(private val uiContext: CoroutineContext = Dispatchers.Main) : CoroutineScope {
    private lateinit var job: Job
    
    override val coroutineContext: CoroutineContext
        get() = uiContext + job
    
    //...
    }
    
    class MainPresenter(private val getInfoUsecase: GetInfoUsecase, 
                        private val uiContext: CoroutineContext = Dispatchers.Main 
    ) : CoroutinePresenter(uiContext) { ... }
    
  2. Change MainPresenterTest class to inject another CoroutineContext:

    class MainPresenterTest {
        private lateinit var presenter: MainPresenter
    
        @Mock
        private lateinit var view: MainPresenter.View
    
        @Mock
        private lateinit var mockInfoUseCase: GetInfoUsecase
    
        val expectedInfo = Info()
    
        @Before
        fun setUp() {
            // Mockito has a very convenient way to inject mocks by using the @Mock annotation. To
            // inject the mocks in the test the initMocks method needs to be called.
            MockitoAnnotations.initMocks(this)
    
            presenter = MainPresenter(mockInfoUseCase, Dispatchers.Unconfined) // here another CoroutineContext is injected 
            presenter.inject(view)
            presenter.onCreate()
    }
    
        @Test
        fun onResume_RefreshView() = runBlocking {
            Mockito.`when`(mockInfoUseCase.getInfo()).thenReturn(expectedInfo)
    
            presenter.onResume()
    
            verify(view).showLoading()
            verify(view).showInfo(expectedInfo)
            verify(view).hideLoading()
        }
    }
    
like image 200
Sergey Avatar answered Oct 21 '22 12:10

Sergey


@Sergey's answer caused me to read further into Dispatchers.Unconfined and I realised that I was not using Dispatchers.setMain() to its fullest extent. At the time of writing, note this solution is experimental.

By removing any mention of:

private val mainThreadSurrogate = newSingleThreadContext("Mocked UI thread") 

and instead setting the main dispatcher to

Dispatchers.setMain(Dispatchers.Unconfined)

This has the same result.

A less idiomatic method but one that may assist anyone as a stop-gap solution is to block until all child coroutine jobs have completed (credit: https://stackoverflow.com/a/53335224/4101825):

this.coroutineContext[Job]!!.children.forEach { it.join() }
like image 37
kgbier Avatar answered Oct 21 '22 12:10

kgbier