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

Lukasz Kalnik

03/14/2024, 9:42 AM
Why does
runTest
automatically advance until idle instead of pausing and letting the test advance the coroutine?
j

Joffrey

03/14/2024, 9:43 AM
Could you please given an example? I don't understand exactly what you mean
l

Lukasz Kalnik

03/14/2024, 9:43 AM
Consider a ViewModel starting a loop
Copy code
class MyViewModel : ViewModel() {

    init {
        viewModelScope.launch {
            var counter = 0
            while (true) {
                println("Loop ${counter++}")
                delay(1000)
            }
        }
    }
}
When I test it with
runTest
, setting
coroutineContext[CoroutineDispatcher]
in
Dispatchers.setMain()
, the second test below runs into an endless loop
Copy code
import org.junit.jupiter.api.Test

class MyViewModelTest {

    val testScheduler = TestCoroutineScheduler()
    val testDispatcher = StandardTestDispatcher(testScheduler)

    @OptIn(ExperimentalCoroutinesApi::class)
    @Test
    fun `runs correctly`() = runTest {
        Dispatchers.setMain(testDispatcher)

        MyViewModel()
        println("test 1; MyViewModel created")
        this@MyViewModelTest.testScheduler.advanceTimeBy(2000)
        println("test 1; after advanceTimeBy")
    }

    @OptIn(ExperimentalStdlibApi::class, ExperimentalCoroutinesApi::class)
    @Test
    fun `never finishes`() = runTest {
        Dispatchers.setMain(coroutineContext[CoroutineDispatcher]!!)

        MyViewModel()
        println("test 2 finished") // Never finishes
    }
}
So I wonder if the second test is simply written incorrectly by me and you should not access `runTest`'s dispatcher via
coroutineContext[CoroutineDispatcher]
What is the best practice here? Like in the first test, always inject your own
StandardTestDispatcher
and
TestCoroutineScheduler
?
s

Sam

03/14/2024, 9:50 AM
I think it's actually the opposite. You shouldn't mix extra custom dispatchers with
runTest
. If you want your
runTest
block to complete without waiting for all its coroutines, you can launch them in the test's
backgroundScope
.
In your question, you asked > Why does
runTest
automatically advance until idle instead of pausing and letting the test advance the coroutine? But I think that's a misunderstanding of what's happening here. Both test dispatchers are behaving the same way. The difference is that in your first test, your viewModel is using a different dispatcher from the one created by
runTest
. That means that
runTest
doesn't know about the coroutines and can't wait for them. The actual behaviour of the loop is unchanged between the two tests. The only difference is whether the test waits for the loop to finish or not.
l

Lukasz Kalnik

03/14/2024, 9:58 AM
The difference I see is that the first test lets me manually advance the loop (which I want). It prints
Copy code
test 1; MyViewModel created
Loop 0
Loop 1
test 1; after advanceTimeBy
The second test prints the loop endlessly without pausing
Yes, I'm sure I don't understand completely how it works. It's not easy to find good explanations of the intricacies of coroutine testing.
d

Dmitry Khalanskiy [JB]

03/14/2024, 10:03 AM
If you find the explanation in https://github.com/Kotlin/kotlinx.coroutines/tree/master/kotlinx-coroutines-test unclear, please file an issue.
thank you color 1
l

Lukasz Kalnik

03/14/2024, 10:04 AM
Thank you Dmitry, I will read the explanation in detail and file an issue if there is anything missing
I'm using coroutines-test 1.8.0
👍 1
d

Dmitry Khalanskiy [JB]

03/14/2024, 10:10 AM
@Sam is completely right: the first test is incorrect, as there are two sources of virtual time and you are leaking unfinished coroutines there. The second test correctly shows that there are still computations going on.
👍 1
In fact, we are planning to deprecate
advanceTimeBy
in favor of just
delay
and replace explicit
advanceUntilIdle
and
runCurrent
with the
suspend
versions that work on the dispatcher in the current coroutine context: https://github.com/Kotlin/kotlinx.coroutines/issues/3919
l

Lukasz Kalnik

03/14/2024, 10:12 AM
My expectation was that the second test would suspend and wait on the first
delay
inside the loop
And then only advance if I trigger
runCurrent()
or
advance...
d

Dmitry Khalanskiy [JB]

03/14/2024, 10:12 AM
What would it wait for?
If you provide a complete example of what you want to test, we can look for a way to express it in the test framework, clearing up the mental model of
runTest
in the process.
l

Lukasz Kalnik

03/14/2024, 10:15 AM
My actual ViewModel performs a suspending API call in the loop (as a kind of backend polling)
After the delay
Copy code
class MyViewModel : ViewModel() {

    init {
        viewModelScope.launch {
            var counter = 0
            while (true) {
                api.getCurrentAction() // suspending function
                delay(1000)
            }
        }
    }
}
I want to test that the polling happens
That's why I was thinking I can advance time by 1 second and verify that the polling happened
What's the use of
advanceTimeBy()
if the
delay
anyway advances automatically?
d

Dmitry Khalanskiy [JB]

03/14/2024, 10:20 AM
Copy code
@BeforeTest
fun initMain() {
  Dispatchers.setMain(StandardTestDispatcher())
}

@AfterTest
fun resetMain() {
  Dispatchers.resetMain()
}

runTest {
  MyViewModel()
  // check that polling didn't happen
  runCurrent()
  // check that polling happened
  delay(999.milliseconds)
  // check that the next polling didn't happen
  delay(1.milliseconds)
  // check that the next polling happened
}
Or, alternatively,
Copy code
runTest {
  MyViewModel()
  // check that polling didn't happen
  runCurrent()
  // check that polling happened
  advanceTimeBy(1.seconds)
  // check that the next polling didn't happen
  runCurrent()
  // check that the next polling happened
}
Or, if you use
UnconfinedTestDispatcher
, `MyViewModel`'s
launch
will be entered immediately.
At the end, you should cancel the work.
If you mock `MyViewModel`'s
CoroutineScope
with
backgroundScope
, it will automatically work.
l

Lukasz Kalnik

03/14/2024, 10:21 AM
Ok, so after I inject
StandardTestDispatcher
with
Dispatchers.setMain()
,
runTest
will take it over?
d

Dmitry Khalanskiy [JB]

03/14/2024, 10:21 AM
runTest
automatically checks the current
Dispatchers.Main
for the test schedulers, yes.
l

Lukasz Kalnik

03/14/2024, 10:22 AM
But in the second test the main dispatcher is not injected
Or is it just an alternative for the test, and the setup stays the same?
d

Dmitry Khalanskiy [JB]

03/14/2024, 10:23 AM
Yes, the setup is always the same.
l

Lukasz Kalnik

03/14/2024, 10:23 AM
Thank you for the quick and helpful answer, I will try it out!
d

Dmitry Khalanskiy [JB]

03/14/2024, 10:24 AM
Also, please make sure to check the links I provided; they do answer some of your questions. Maybe you'll find more useful information there. And if something's missing or wouldn't be clear from the docs alone, please let us know.
❤️ 1
l

Lukasz Kalnik

03/14/2024, 10:24 AM
I will, thank you!
So I tried this solution you suggested like this, but the loop still runs endlessly without stopping:
Copy code
class MyViewModel(apiClient: ApiClient) : ViewModel() {

    init {
        viewModelScope.launch {
            var counter = 0
            while (true) {
                println("Loop ${counter++}")
                apiClient.pollAction()
                delay(1000)
            }
        }
    }
}

class ApiClient {

    suspend fun pollAction() = delay(1000)
}

class MyViewModelTest {

    val apiClient = mockk<ApiClient> {
        coEvery { pollAction() } just runs
    }

    @BeforeEach
    fun setup() {
        Dispatchers.setMain(StandardTestDispatcher())
    }

    @Test
    fun `viewmodel test`() = runTest {
        MyViewModel(apiClient)
        println("MyViewModel created") // runs into an endless loop
        testScheduler.advanceTimeBy(2000) // doesn't even get here
        testScheduler.runCurrent()
        println("after advanceTimeBy")
    }
}
The test causes an out of memory error, I guess something is cached there as well
I mock and inject the ApiClient, so my understanding is that
StandardTestDispatcher
should wait at
apiClient.pollAction()
(Note that I don't use the actual implementation, which is indeed just a
delay()
and would probably be skipped)
d

Dmitry Khalanskiy [JB]

03/14/2024, 11:36 AM
``` println("MyViewModel created") // runs into an endless loop
testScheduler.advanceTimeBy(2000) // doesn't even get here```
Does
println
get executed? What does "doesn't even get here" means?
l

Lukasz Kalnik

03/14/2024, 11:36 AM
Yes, println gets executed
Sorry, "doesn't even get here" is probably wrong
Just the loop runs in parallel apparently
This is a self contained example, maybe it's best you run it and see
d

Dmitry Khalanskiy [JB]

03/14/2024, 11:37 AM
So, the
after advanceTimeBy
doesn't get executed?
l

Lukasz Kalnik

03/14/2024, 11:37 AM
Let me run this again, without the loop printing
d

Dmitry Khalanskiy [JB]

03/14/2024, 11:38 AM
If you can provide an example without
mockk
, I'll look into it.
If the test body does run to the end, the issue is just with
At the end, you should cancel the work.
If you mock `MyViewModel`'s
CoroutineScope
with
backgroundScope
, it will automatically work.
l

Lukasz Kalnik

03/14/2024, 11:39 AM
Ah yes, that's a good point
There's no good support to canceling viewModelScope from Google
One has to make
ViewModel.onCleared()
visible for tests and then call it
But definitely this would cause the test polling forever
MyViewModel
can take the scope as a parameter for testing, with the default value being
viewModelScope
.
l

Lukasz Kalnik

03/14/2024, 11:41 AM
Yes, that's probably what I have to do. Thanks!
I was hoping
Dispatchers.setMain()
would suffice, but then you cannot cancel the scope
So my test indeed goes through to the end. It prints
Copy code
MyViewModel created
after advanceTimeBy
But the loop runs in parallel without waiting for
advanceTimeBy()
or
runCurrent()
, which is not what I want
For an example without mockk, how can I simulate a suspending function other than just by using a
delay()
?
d

Dmitry Khalanskiy [JB]

03/14/2024, 11:45 AM
Why don't you want to use
delay
?
l

Lukasz Kalnik

03/14/2024, 11:45 AM
I thought delays get skipped automatically by
runTest
d

Dmitry Khalanskiy [JB]

03/14/2024, 11:48 AM
They aren't skipped, they complete instantaneously. Not the same thing. The following test will complete instantly, but the interleaving of the coroutines will be as if real time has passed.
Copy code
runTest {
  launch {
    repeat(10) {
      delay(1.seconds)
      println("coroutine 1: $it")
    }
  }
  launch {
    repeat(3) {
      delay(1500.milliseconds)
      println("coroutine 2: $it")
    }
  }
}
l

Lukasz Kalnik

03/14/2024, 11:50 AM
Ok, so why do we need
advanceTimeBy()
and
runCurrent()
if the suspending functions are executed by the test immediately?
Or is it just to execute
launch
blocks, ie. the coroutines themselves, and not the suspending functions they launch
d

Dmitry Khalanskiy [JB]

03/14/2024, 11:51 AM
You usually don't need
advanceTimeBy
, you can use
delay
instead.
l

Lukasz Kalnik

03/14/2024, 11:52 AM
But I think I get it slowly. My test runs correctly, I just need to stop it after advancing the virtual time to the polling point, so that the loop stops.
Thank you for your patient explanations. I really appreciate it!
👍 1
I ended up using an extension function to inject the
backgroundScope
into
MyViewModel
Copy code
fun ViewModel.getCurrentViewModelScope(providedCoroutineScope: CoroutineScope?) = providedCoroutineScope ?: viewModelScope
I then use it like this in `MyViewModel`:
Copy code
class MyViewModel(providedCoroutineScope: CoroutineScope? = null) : ViewModel() {
    val coroutineScope = getCurrentViewModelScope(providedCoroutineScope)

    init {
        coroutineScope.launch {
            var counter = 0
            while (true) {
                println("Loop ${counter++}")
                delay(1000)
            }
        }
    }
}
And in the test replace it with `backgroundScope`:
Copy code
class MyViewModelTest {

    @Test
    fun `viewmodel test`() = runTest {
        MyViewModel(backgroundScope)
        println("MyViewModel created")
        testScheduler.advanceTimeBy(2000)
        testScheduler.runCurrent()
        println("after advanceTimeBy")
    }
}
The test prints:
Copy code
MyViewModel created
Loop 0
Loop 1
Loop 2
after advanceTimeBy
and finishes immediately!
5 Views