7

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.)

3
  • 1
    It fails, because I believe you don't wait for emit anywhere in this code. inc() doesn't wait, so join() doesn't as well and then it goes straight to the assert. But honestly, I have problems understanding what you try to achieve here. You try to wait for the producer to finish, but verify the results on the consumer side. Even if producer emitted an item, we don't have guarantees consumer already consumed it. I think you should wait for the consumer, not producer, for example by assuming there are exactly 2 items to consume or by closing the flow after emitting. Commented Jun 6, 2022 at 8:00
  • @broot I want to test that the producer is actually being called and that the result collected in the resultJob is correct. I really need the test to block until the job launched in inc() completes. I suspect that I need to pass in a test scheduler, but I can't figure out how. Commented Jun 6, 2022 at 10:24
  • If you need to block inc() until it finishes then well, use runBlocking() inside it instead of launch(). You use a lot of launches in your code, that makes pretty hard to wait for anything. Still, I believe even if you wait for inc() to finish, you aren't guaranteed that collector/consumer running concurrently already consumed the item. Even if this is deterministic when running inside the simulated test environment, it may fail in the real application. Commented Jun 6, 2022 at 10:50

1 Answer 1

8

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

1 Comment

Thank you. I solved the problem by using an ArrayBlockingQueue, which gets updated every time a new value is emitted. I then block on changes to my queue and validate the results. Your solution is better.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.