Home > Back-end >  Test infinite kotlin coroutine
Test infinite kotlin coroutine

Time:07-06

I have a ViewModel. When it's visible on screen, it's started. When user leaves the screen, it stops. While the ViewModel is started, I want to execute some code every 5 seconds. The code looks somewhat like this:

fun onStart() {
    interval = launch(injectedDispatcher) {
        while (true) {
            doSomething()
            delay(5000.milliseconds)
        }
    }
}

fun onStop() {
    interval.cancel()
}

I want to write an integration test that will test this ViewModel along with it's dependencies. I use TestScope to make this integration tests instant:

val scope = TestScope()
val injectedDispatcher = StandardTestDispatcher(scope.testScheduler)

@Test
fun interval() = scope.runTest {
   val viewModel = get(injectedDispatcher)
   viewModel.onStart()
   delay(30000) // <- execution will get stuck at this point
   assertSomething(...)
   viewModel.onStop()
}

This testing code runs great if there are no infinite loops inside the code being tested. However, if there is at least one infinite coroutine, delay(30000) will never exit. Instead, execuition will get stuck inside the while (true) loop, even after 30000ms has passed. I've also verified that scope.currentTime can be increased way over 30000ms and the while loop still won't quit.

I presume that this is because StandardTestDispatcher keeps cycling inside the while loop because it cannot suspend a job once it's started.

I've made a small example to illustrate the problem: https://github.com/Alexey-/InfiniteTest

Is there a way to suspend infinite loop after running it for a specific time with TestDispatcher?

CodePudding user response:

What is the StandardTestDispatcher and TestScope? A wrote my opinion. All work.
Test looks like.

val scope = TestScope()
val injectedDispatcher = StandardTestDispatcher(scope.testScheduler)
val model = TestViewModel()

@Test
fun interval() = scope.runTest {
    val viewModel = model
    viewModel.injectedDispatcher = injectedDispatcher
    viewModel.onStart()
    delay(30000) // <- execution will get stuck at this point   
    viewModel.onStop()
    Assert.assertTrue(model.count > 0)
}

Model looks like

var injectedDispatcher = Dispatchers.IO
var interval: Job? = null

var count = 0

fun onStart() {
    interval = viewModelScope.launch(injectedDispatcher) {
        while (true) {
            delay(5000)
            count  
        }
    }
}

fun onStop() {
    interval?.cancel()
}

So the decision is assert results when all jobs is closed. When we have assert error we have break from test. Scope is working and test can't finish.

@Test
fun interval() = scope.runTest {
    val viewModel = get(injectedDispatcher)
    viewModel.onStart()
    delay(30000) // <- execution will get stuck at this point
    val result = getFromSomeWere() // <- what we wanna check
    viewModel.onStop()
    assertSomething(...) // <- check it here
}

Or

@Test
fun androidInterval() = scope.runTest {
    val viewModel = AndroidTestViewModel(injectedDispatcher)
    try {
        viewModel.onStart()
        delay(30000)
        assertEquals(6, viewModel.count)
    } catch (e: AssertionError) {
        viewModel.onStop()
        throw e
    }
    viewModel.onStop()
}

CodePudding user response:

The problem appears to be that TestScope.runTest will wait for all child coroutines to complete before delivering test results.
The execution does not get stuck in delay(30_000). What causes the test to run forever is that your assertion fails and throws an AssertionError. Because an Error was thrown, the next line viewModel.onStop() is never called. This means the coroutine launched in your ViewModel never completes and hence TestScope.runTest will never deliver the result.
You can test this easily:

...
println("after delay; before assertion")
try{
    assertEquals(6, viewModel.count)
}catch (e: AssertionError){
    e.printStackTrace()
    throw e
}
println("after assertion")
viewModel.onStop()

The most simple solution would be to call viewModel.onStop() first, and then run whatever assertion you want to.


If you care for a completely alternative approach, you could avoid starting and stopping your viewmodel by hand altogether, and opt for a more "coroutine-y" way:


class AndroidTestViewModel(
    val injectedDispatcher: CoroutineDispatcher = Dispatchers.IO
) : ViewModel() {
    var count = 0

    suspend fun doWhileInForeground(){
        withContext(injectedDispatcher){
            while (true) {
                delay(5000)
                count  
            }
        }
    }
}

Testing this would probably look more like this:

@Test
fun interval() = scope.runTest {
    val viewModel = AndroidTestViewModel(injectedDispatcher)
    launch {
        viewModel.doWhileInForeground()
    }
    delay(30_000)
    assertEquals(6,viewModel.count)
}

And an example usage in a Fragment, this can be easily adapted to an Activity or jetpack compose:

class SampleFragment: Fragment(){
    val viewModel: AndroidTestViewModel = TODO()
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        viewLifecycleOwner.lifecycleScope.launch { 
            viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED){
                viewModel.doWhileInForeground()
            }
        }
    }
}
  • Related