Lukasz Kalnik
03/14/2024, 9:42 AMrunTest
automatically advance until idle instead of pausing and letting the test advance the coroutine?Joffrey
03/14/2024, 9:43 AMLukasz Kalnik
03/14/2024, 9:43 AMLukasz Kalnik
03/14/2024, 9:43 AMclass MyViewModel : ViewModel() {
init {
viewModelScope.launch {
var counter = 0
while (true) {
println("Loop ${counter++}")
delay(1000)
}
}
}
}
Lukasz Kalnik
03/14/2024, 9:46 AMrunTest
, setting coroutineContext[CoroutineDispatcher]
in Dispatchers.setMain()
, the second test below runs into an endless loop
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
}
}
Lukasz Kalnik
03/14/2024, 9:49 AMcoroutineContext[CoroutineDispatcher]
Lukasz Kalnik
03/14/2024, 9:50 AMStandardTestDispatcher
and TestCoroutineScheduler
?Sam
03/14/2024, 9:50 AMrunTest
. If you want your runTest
block to complete without waiting for all its coroutines, you can launch them in the test's backgroundScope
.Sam
03/14/2024, 9:53 AMrunTest
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.Lukasz Kalnik
03/14/2024, 9:58 AMtest 1; MyViewModel created
Loop 0
Loop 1
test 1; after advanceTimeBy
Lukasz Kalnik
03/14/2024, 9:59 AMLukasz Kalnik
03/14/2024, 10:00 AMDmitry Khalanskiy [JB]
03/14/2024, 10:03 AMDmitry Khalanskiy [JB]
03/14/2024, 10:04 AMLukasz Kalnik
03/14/2024, 10:04 AMLukasz Kalnik
03/14/2024, 10:06 AMDmitry Khalanskiy [JB]
03/14/2024, 10:10 AMDmitry Khalanskiy [JB]
03/14/2024, 10:10 AMadvanceTimeBy
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/3919Lukasz Kalnik
03/14/2024, 10:12 AMdelay
inside the loopLukasz Kalnik
03/14/2024, 10:12 AMrunCurrent()
or advance...
Dmitry Khalanskiy [JB]
03/14/2024, 10:12 AMDmitry Khalanskiy [JB]
03/14/2024, 10:13 AMrunTest
in the process.Lukasz Kalnik
03/14/2024, 10:15 AMLukasz Kalnik
03/14/2024, 10:15 AMLukasz Kalnik
03/14/2024, 10:17 AMclass MyViewModel : ViewModel() {
init {
viewModelScope.launch {
var counter = 0
while (true) {
api.getCurrentAction() // suspending function
delay(1000)
}
}
}
}
Lukasz Kalnik
03/14/2024, 10:17 AMLukasz Kalnik
03/14/2024, 10:17 AMLukasz Kalnik
03/14/2024, 10:18 AMadvanceTimeBy()
if the delay
anyway advances automatically?Dmitry Khalanskiy [JB]
03/14/2024, 10:20 AM@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,
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.Dmitry Khalanskiy [JB]
03/14/2024, 10:20 AMDmitry Khalanskiy [JB]
03/14/2024, 10:21 AMCoroutineScope
with backgroundScope
, it will automatically work.Lukasz Kalnik
03/14/2024, 10:21 AMStandardTestDispatcher
with Dispatchers.setMain()
, runTest
will take it over?Dmitry Khalanskiy [JB]
03/14/2024, 10:21 AMrunTest
automatically checks the current Dispatchers.Main
for the test schedulers, yes.Lukasz Kalnik
03/14/2024, 10:22 AMLukasz Kalnik
03/14/2024, 10:23 AMDmitry Khalanskiy [JB]
03/14/2024, 10:23 AMLukasz Kalnik
03/14/2024, 10:23 AMDmitry Khalanskiy [JB]
03/14/2024, 10:24 AMLukasz Kalnik
03/14/2024, 10:24 AMLukasz Kalnik
03/14/2024, 11:33 AMclass 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")
}
}
Lukasz Kalnik
03/14/2024, 11:34 AMLukasz Kalnik
03/14/2024, 11:35 AMStandardTestDispatcher
should wait at apiClient.pollAction()
Lukasz Kalnik
03/14/2024, 11:35 AMdelay()
and would probably be skipped)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?Lukasz Kalnik
03/14/2024, 11:36 AMLukasz Kalnik
03/14/2024, 11:36 AMLukasz Kalnik
03/14/2024, 11:36 AMLukasz Kalnik
03/14/2024, 11:37 AMDmitry Khalanskiy [JB]
03/14/2024, 11:37 AMafter advanceTimeBy
doesn't get executed?Lukasz Kalnik
03/14/2024, 11:37 AMDmitry Khalanskiy [JB]
03/14/2024, 11:38 AMmockk
, I'll look into it.Dmitry Khalanskiy [JB]
03/14/2024, 11:38 AMAt the end, you should cancel the work.
If you mock `MyViewModel`'swithCoroutineScope
, it will automatically work.backgroundScope
Lukasz Kalnik
03/14/2024, 11:39 AMLukasz Kalnik
03/14/2024, 11:39 AMLukasz Kalnik
03/14/2024, 11:39 AMViewModel.onCleared()
visible for tests and then call itLukasz Kalnik
03/14/2024, 11:39 AMDmitry Khalanskiy [JB]
03/14/2024, 11:40 AMDmitry Khalanskiy [JB]
03/14/2024, 11:40 AMMyViewModel
can take the scope as a parameter for testing, with the default value being viewModelScope
.Lukasz Kalnik
03/14/2024, 11:41 AMLukasz Kalnik
03/14/2024, 11:41 AMDispatchers.setMain()
would suffice, but then you cannot cancel the scopeLukasz Kalnik
03/14/2024, 11:43 AMMyViewModel created
after advanceTimeBy
Lukasz Kalnik
03/14/2024, 11:43 AMadvanceTimeBy()
or runCurrent()
, which is not what I wantLukasz Kalnik
03/14/2024, 11:45 AMdelay()
?Dmitry Khalanskiy [JB]
03/14/2024, 11:45 AMdelay
?Lukasz Kalnik
03/14/2024, 11:45 AMrunTest
Dmitry Khalanskiy [JB]
03/14/2024, 11:48 AMrunTest {
launch {
repeat(10) {
delay(1.seconds)
println("coroutine 1: $it")
}
}
launch {
repeat(3) {
delay(1500.milliseconds)
println("coroutine 2: $it")
}
}
}
Lukasz Kalnik
03/14/2024, 11:50 AMadvanceTimeBy()
and runCurrent()
if the suspending functions are executed by the test immediately?Lukasz Kalnik
03/14/2024, 11:50 AMlaunch
blocks, ie. the coroutines themselves, and not the suspending functions they launchDmitry Khalanskiy [JB]
03/14/2024, 11:51 AMadvanceTimeBy
, you can use delay
instead.Lukasz Kalnik
03/14/2024, 11:52 AMLukasz Kalnik
03/14/2024, 11:52 AMLukasz Kalnik
03/14/2024, 12:10 PMbackgroundScope
into MyViewModel
fun ViewModel.getCurrentViewModelScope(providedCoroutineScope: CoroutineScope?) = providedCoroutineScope ?: viewModelScope
Lukasz Kalnik
03/14/2024, 12:11 PMclass MyViewModel(providedCoroutineScope: CoroutineScope? = null) : ViewModel() {
val coroutineScope = getCurrentViewModelScope(providedCoroutineScope)
init {
coroutineScope.launch {
var counter = 0
while (true) {
println("Loop ${counter++}")
delay(1000)
}
}
}
}
Lukasz Kalnik
03/14/2024, 12:12 PMclass MyViewModelTest {
@Test
fun `viewmodel test`() = runTest {
MyViewModel(backgroundScope)
println("MyViewModel created")
testScheduler.advanceTimeBy(2000)
testScheduler.runCurrent()
println("after advanceTimeBy")
}
}
Lukasz Kalnik
03/14/2024, 12:12 PMMyViewModel created
Loop 0
Loop 1
Loop 2
after advanceTimeBy
and finishes immediately!