2

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?

2 Answers 2

3

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()
            }
        }
    }
}
Sign up to request clarification or add additional context in comments.

Comments

1

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()
}

5 Comments

TestDispatcher is a special dispatcher which uses virtual clock. This means that whle your code may contain delay(30000) for example, it'll be executed instantly and if you check virtual clock, they will always advance exactly by 30000ms. This is very important if you have hundreds of tests and you need to run them in a reasonable time. More about it here: kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-test/… Replacing TestDispatcher with IO works, but it makes tests run hundreds of times slower and it also makes tests flaky.
So. I read your informathin and change my test to use TestScope. And my test ran 1 millisecond. All work.
I've made a small example on github: github.com/Alexey-/InfiniteTest It doesn't work for me, even if I pretty much copy your entire code. Which version of coroutines you are using? I'm starting to think that lib version might be the problem.
Check after viewModel.onStop(). While scope is running assert error doun't work. viewModel.count is 5. We have an asserError when check it with 6. If we check it with 5 all work. If we check it after viewModel.onStop() all work.
I got a second decision. You can change your dispatcher like val dispatcher: CoroutineContext and change val injectedDispatcher = scope.coroutineContext. All will work.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.