https://kotlinlang.org logo
Title
l

Lukasz Kalnik

02/07/2022, 6:06 PM
I have a test where a flow should emit with delay, but it doesn't emit:
@Test
fun `test flow`() = runTest {
    val eventUpdateService = mockk { 
        every { subscribeToEvents() } returns flowOf(SomeEvent).onStart { delay(1000) } 
    }
    val presenter = Presenter(
        UnconfinedTestDispatcher(),
        eventUpdateService
    )
    presenter.attachView(view)
    advanceTimeBy(2000)
    verify { view.displayEvent() } // fails because the flow didn't emit
}

class Presenter(
    coroutineContext: CoroutineContext,
    val eventUpdateService: EventUpdateService    
) {

    val coroutineScope = CoroutineScope(coroutineContext)

    fun attachView(view: View) {
        coroutineScope.launch {
            eventUpdateService.subscribeToEvents().collect { event ->
                view.displayEvent()
            }
        }
    }
}
Presenter for the test has injected an
UnconfinedTestDispatcher
for the
CoroutineScope
to collect the flow.
j

Joffrey

02/07/2022, 6:08 PM
There is no code here that collects the flow here AFAICT. The flow is cold, nothing happens until some consumer collects it
l

Lukasz Kalnik

02/07/2022, 6:09 PM
The consumer is inside the tested presenter, consuming should cause a side effect. Sorry, I didn't post all relevant code.
j

Joffrey

02/07/2022, 6:09 PM
presenter.subscribeToEvents()
is mocked to return a flow that is never used or collected, what is inside the presenter is irrelevant
l

Lukasz Kalnik

02/07/2022, 6:10 PM
You are obviously right, sorry. By trying to make my example minimal I actually made it wrong...
Edited, should be correct now.
Thanks for pointing this out.
j

Joffrey

02/07/2022, 6:23 PM
My guess is that the
advanceTimeBy
call doesn't affect the
Presenter
because you're passing a new
UnconfinedTestDispatcher
(which is not linked to
runTest
) to the
Presenter
instance
l

Lukasz Kalnik

02/07/2022, 6:24 PM
Yes, that's also my suspicion.
Looks like for this case I need to use e.g. common
TestScope
instance for both running the test and initializing the presenter.
j

Joffrey

02/07/2022, 6:25 PM
Yep. You should have access to the
coroutineContext
of the current test scope inside
runTest
, so you should pass this one directly to the presenter without creating a new dispatcher
l

Lukasz Kalnik

02/07/2022, 6:28 PM
@Test
fun `test flow`() = runTest {
    val presenter = Presenter(coroutineContext, //...)
    // ...
}
This indeed works. Thank you!
👍 1
n

Nick Allen

02/07/2022, 7:52 PM
btw, creating a
CoroutineScope
from an external
CoroutineContext
seems a little odd. If the context has a
Job
then that means the caller owns the lifetime, If it doesn't have a
Job
then
CoroutineScope
factory function creates a
Job
and the
Presenter
is in charge of managing the lifetime. I generally pass in
CoroutineScope
and it's up to the caller to manage the lifetime or I pass in a specific context like
CoroutineDispatcher
that definitely does not contain a
Job
so the class can create it's own
CoroutineScope
and be in change of cancelling it.
l

Lukasz Kalnik

02/08/2022, 8:52 AM
Yes, that's a very good point. This example is obviously simplified. We don't use the
CoroutineScope
factory function itself. We have our own
PresenterCoroutineScope
which creates a
Job
and lets the
Presenter
internally manage its own lifecycle. But you are right, in this case we should only pass a dispatcher and not the whole context, including the externally created
Job
. Thank you for bringing this to my attention.
j

Joffrey

02/08/2022, 8:55 AM
I kinda disagree (at least as a general statement). Depending on how generic a component is, it may be nice to allow users to specify other things like an exception handler or a coroutine name, not just a dispatcher. And yet you may want control over the lifecycle using
cancel()
which would be unadvisable on a user-provided scope
l

Lukasz Kalnik

02/08/2022, 8:56 AM
In production code we actually always inject in the
PresenterCoroutineScope
a
Dispatchers.Default
.
j

Joffrey

02/08/2022, 8:57 AM
Yep it might be correct for your use case. I was disagreeing with @Nick Allen's general statement
l

Lukasz Kalnik

02/08/2022, 8:57 AM
I wonder how it works for tests though. Tests need to inject an exception handler I guess?
Because
runTest
has to rethrow all exceptions that possibly happened in coroutines launched by
Presenter
during the test in order for the test to fail.