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 advanceTimeByLukasz 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 advanceTimeByLukasz 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 AMrunTestDmitry 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 ?: viewModelScopeLukasz 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!