Robert
Robert

Reputation: 7053

Kotlin runTest with delay() is not working

I am testing a coroutine that blocks. Here is my production code:

interface Incrementer {
    fun inc()
}

class MyViewModel : Incrementer, CoroutineScope {
    override val coroutineContext: CoroutineContext
        get() = Dispatchers.IO

    private val _number = MutableStateFlow(0)
    fun getNumber(): StateFlow<Int> = _number.asStateFlow()

    override fun inc() {
        launch(coroutineContext) {
            delay(100)
            _number.tryEmit(1)
        }
    }
}

And my test:

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

    @AfterEach
    fun teardown() {
        Dispatchers.resetMain()
    }

    @Test
    fun incrementOnce() = runTest {
        val viewModel = MyViewModel()

        val results = mutableListOf<Int>()
        val resultJob = viewModel.getNumber()
            .onEach(results::add)
            .launchIn(CoroutineScope(UnconfinedTestDispatcher(testScheduler)))

        launch(StandardTestDispatcher(testScheduler)) {
            viewModel.inc()
        }.join()

        assertEquals(listOf(0, 1), results)
        resultJob.cancel()
    }
}

How would I go about testing my inc() function? (The interface is carved in stone, so I can't turn inc() into a suspend function.)

Upvotes: 7

Views: 6733

Answers (1)

zsmb13
zsmb13

Reputation: 89528

There are two problems here:

  1. You want to wait for the work done in the coroutine that viewModel.inc() launches internally.
  2. Ideally, the 100ms delay should be fast-forwarded during tests so that it doesn't actually take 100ms to execute.

Let's start with problem #2 first: for this, you need to be able to modify MyViewModel (but not inc), and change the class so that instead of using a hardcoded Dispatchers.IO, it receives a CoroutineContext as a parameter. With this, you could pass in a TestDispatcher in tests, which would use virtual time to fast-forward the delay. You can see this pattern described in the Injecting TestDispatchers section of the Android docs.

class MyViewModel(coroutineContext: CoroutineContext) : Incrementer {
    private val scope = CoroutineScope(coroutineContext)

    private val _number = MutableStateFlow(0)
    fun getNumber(): StateFlow<Int> = _number.asStateFlow()

    override fun inc() {
        scope.launch {
            delay(100)
            _number.tryEmit(1)
        }
    }
}

Here, I've also done some minor cleanup:

  • Made MyViewModel contain a CoroutineScope instead of implementing the interface, which is an officially recommended practice
  • Removed the coroutineContext parameter passed to launch, as it doesn't do anything in this case - the same context is in the scope anyway, so it'll already be used

For problem #1, waiting for work to complete, you have a few options:

  • If you've passed in a TestDispatcher, you can manually advance the coroutine created inside inc using testing methods like advanceUntilIdle. This is not ideal, because you're relying on implementation details a lot, and it's something you couldn't do in production. But it'll work if you can't use the nicer solution below.

    viewModel.inc()
    advanceUntilIdle() // Returns when all pending coroutines are done
    
  • The proper solution would be for inc to let its callers know when it's done performing its work. You could make it a suspending method instead of launching a new coroutine internally, but you stated that you can't modify the method to make it suspending. An alternative - if you're able to make this change - would be to create the new coroutine in inc using the async builder, returning the Deferred object that that creates, and then await()-ing at the call site.

    override fun inc(): Deferred<Unit> {
        scope.async {
            delay(100)
            _number.tryEmit(1)
        }
    }
    
    // In the test...
    viewModel.inc().await()
    
  • If you're not able to modify either the method or the class, there's no way to avoid the delay() call causing a real 100ms delay. In this case, you can force your test to wait for that amount of time before proceeding. A regular delay() within runTest would be fast-forwarded thanks to it using a TestDispatcher for the coroutine it creates, but you can get away with one of these solutions:

    // delay() on a different dispatcher
    viewModel.inc()
    withContext(Dispatchers.Default) { delay(100) }
    
    // Use blocking sleep
    viewModel.inc()
    Thread.sleep(100)
    

For some final notes about the test code:

  • Since you're doing Dispatchers.setMain, you don't need to pass in testScheduler into the TestDispatchers you create. They'll grab the scheduler from Main automatically if they find a TestDispatcher there, as described in its docs.
  • Instead of creating a new scope to pass in to launchIn, you could simply pass in this, the receiver of runTest, which points to a TestScope.

Upvotes: 8

Related Questions