https://kotlinlang.org logo
#coroutines
Title
# coroutines
v

Vincent Williams

11/03/2019, 7:25 PM
I assume you would just call
collect { }
in the unit test and then verify that the correct item comes through, however this throws
IllegalStateException: This job has not completed yet
m

Mark Murphy

11/03/2019, 7:27 PM
Hold onto the
Job
from your coroutine builder, then
cancel()
the
Job
at the bottom of the unit test.
v

Vincent Williams

11/03/2019, 7:28 PM
Ive actually tried injecting dispatchers, then using a test dispatcher in tests. My test looks like
Copy code
fun test() = testDispatcher.runBlockingTest {
    //test stuff here

    testDispatcher.cancel()
}
it doesnt work
if I use
runBlocking
instead of
runBlockingTest
my test hangs
m

Mark Murphy

11/03/2019, 7:31 PM
Copy code
fun test() = runBlockingTest {
  val job = launch {
    flow.collect { TODO() }
  }

  // rest of stuff here

  job.cancel()
}
I don't know that this is the best solution, but it is working for me.
v

Vincent Williams

11/03/2019, 7:34 PM
oh that is interesting... I hadnt thought of starting another coroutine inside
runBlockingTest
I will try that for now but I would like to know if anyone has found an easier way
i also wonder if anyone has written some extension functions for testing like rx does
Copy code
channel.test()
    .assertValue(someValue)
that would be super helpful
o

octylFractal

11/03/2019, 8:03 PM
d

Dragan Stanojević - Nevidljivi

11/03/2019, 9:28 PM
I don't think so, since with launch {} inside @test fun you're launching another coroutine which may, in this case usually does, run longer then the @test fun. So the error is expected and welcome. What you need to do is run the channel listening coroutine and use yield on each Event received. We don't have something nice like rx test() for this so it looks ugly, and it seems most of us are unhappy with our hacks. For what is worth, here's example of mine, but there's got to be something nicer...
Copy code
@ExperimentalCoroutinesApi
class CoroutineChannelExampleTests {
    private val testDispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher()

    private var mockedResources: Resources = mock()
    private val osRuntimeValues = OSRuntimeValues(isDebuggingEnabled = true)
    private var logger = object: InstallerAppLogger(osRuntimeValues) {
        override fun logToSystem(logLevel: LogLevel, applicationTag: String, classTag: String, message: String) {
            println("$classTag:: $message") // override logger's logToSystem() to use println while testing
        }
    }
    private var dummyPersistence = spy(DummyPersistence())
    private val viewModel = YourViewModel(
            language = "DE",
            resources = mockedResources,
            osRuntimeValues = osRuntimeValues,
            logger = logger,
            persistence = dummyPersistence
    )

    private val eventChannelSubscription = viewModel.getEventsChannel().openSubscription()
    private var event: YourViewModel.Event? = null
    private var messageReceived = false
    private var fragmentChangedEvent: FragmentChanged? = null

    @Before
    fun setUp() {
        Dispatchers.setMain(testDispatcher)
    }

    @After
    fun tearDown() {
        Dispatchers.resetMain() // reset main dispatcher to the original Main dispatcher
        testDispatcher.cleanupTestCoroutines()
    }

    @Test
    fun `test happy path`() = testDispatcher.runBlockingTest {
        val eventsChannelListener = initEventsChannelListener()

        viewModel.getCategories()
        waitForEvent()
        verify(dummyPersistence).getCategories()
        fragmentChangedEvent = event as FragmentChanged
        assertThat(fragmentChangedEvent!!.fragmentType).isEqualTo(CATEGORIES)
        assertThat(viewModel.selectedCategory).hasSize(2)
        assertThat(viewModel.selectedCategory).isEqualTo(dummyPersistence.getMockedCategoryUIModels())
        
        
        eventChannelSubscription.cancel()

        while (!eventsChannelListener.isCompleted)
            yield()
    }
    

    // helper method to initialise EventChannelListener in each test
    private fun TestCoroutineScope.initEventsChannelListener(): Job {
        return launch {
            eventChannelSubscription.consumeEach {
                while (messageReceived) yield() // force consuming one-by-one

                event = it
                messageReceived = true
            }
        }
    }

    // helper function used for waiting for an event from coroutines channel
    private suspend fun waitForEvent() {
        while (!messageReceived) yield()
        messageReceived = false
    }
}
Most interesting portions are:
initEventsChannelListener()
,
waitForEvent()
calls, canceling the subscription at the end of tests and waiting at the end of @test fun with:
Copy code
while (!eventsChannelListener.isCompleted)
    yield()
Some past code sample from Google in testing multiple threads also used latches with similarly ugly code, but, again, there has to be a better way to do it... I hoped @Manuel Vivo @Sean McQuillan [G] would have touched this in

https://www.youtube.com/watch?v=KMb0Fs8rCRs

part of testing coroutines, since it's not trivial, but quite essential in any ViewModel testing...
Also, in that video @Manuel Vivo explains how launch {} inside runBlockingTest {} will easily squirm outside of @test fun and show dreaded
IllegalStateException: This job has not completed yet
.
m

Mark Murphy

11/03/2019, 9:31 PM
I don't think so, since with launch {} inside @test fun you're launching another coroutine which may, in this case usually does, run longer then the @test fun
Since I
cancel()
the
Job
before the test function ends, AFAIK the coroutine should not run longer than the test function. Admittedly, this assumes cooperative behavior, which may not exist in some cases.
d

Dragan Stanojević - Nevidljivi

11/03/2019, 9:32 PM
you are right...
The ugliness rises when you have to force cooperative behavior between viewModel code run inside one coroutine and channel listener run inside another, since you don't want to miss Events and you want to stop the test code while event is consumed, and in my case assigned to event variable so I can run asserts on them...
If I went with .cancel() of the channel listener's coroutine at the end of @test fun I'm missing those events if they're not received. This is why I end the @test fun with a yield statements and have ugly waitForEvent() calls before I assert Event's values...
v

Vincent Williams

11/03/2019, 11:24 PM
so it actually works fine for me if i
launch
inside of
runBlockingTest
if I dont do that it hangs or shows
This job has not completed yet
All this testing stuff makes me still want to stick with RxJava and ditch coroutines altogether
g

Giorgos Neokleous

11/04/2019, 9:37 AM
@Vincent Williams I had the same issue yesterday. What I did without using
runBlockingTest
or any of the coroutines test dependencies. Is to
take(numberOfEmissions)
before collection. This way my test run successfully. You can even do
collectIndexed { index, value-> }
to have different assertions for different emissions
v

Vincent Williams

11/04/2019, 3:50 PM
How did you do that without coroutines?
collect
or
collectIndexed
are suspend functions
g

Giorgos Neokleous

11/04/2019, 3:55 PM
with the
runBlocking
v

Vincent Williams

11/04/2019, 3:56 PM
for me
runBlocking
results in a test that hangs
g

Giorgos Neokleous

11/04/2019, 3:59 PM
Are you using instant task rule?
v

Vincent Williams

11/04/2019, 3:59 PM
what is that? An extra rule shouldnt be necessary if you inject dispatchers then swap them out for test dispatchers during tests
g

Giorgos Neokleous

11/04/2019, 4:01 PM
I am using the rule anyway in my tests for more than coroutine things. But anyway adding
.take(numberOfEmissions)
before collecting your flow should do the trick
v

Vincent Williams

11/04/2019, 5:03 PM
ok Ill test that out!
thanks for the help
4 Views