Testing Coroutines — Simplified

Waqas Younis
3 min readOct 15, 2024

--

Picture taked by me ;)

I assume you know how coroutines work and how to get the most out of them.

Now let me help you build test for it.

Take a look at this function in some RegisterViewModel Viewmodel

class RegisterViewModel: ViewModel(){

private val _isLoading: MutableStateFlow(false)
val isLoading = _isLoading.asStateFlow()

fun register() {
viewModelScope.launch {
_isLoading.emit(true)
delay(1000)
_isLoading.emit(false)

}
}

}

Let’s try writing test to test this specific function

class Test {

private val viewModel: RegisterViewModel

@Before
fun setup(){
viewModel = RegisterViewModel()
}


@Test
fun testRegistration() = runTest {
assertk.assertThat(viewModel.isLoading.value).isFalse()
viewModel.register()
assertk.assertThat(viewModel.isLoading.value).isTrue()
assertk.assertThat(viewModel.isLoading.value).isFalse()
}

}

We are assuming the initial value will be false,
then we call the function, and expect the value to be update to true
and lastly, again back to false.

But if you run this, you will get the following error:

🛑 Module with the Main dispatcher had failed to initialise. For tests Dispatchers.setMain from kotlinx-coroutines-test module can be used

Because, by default viewmodelScope uses Main dispatcher, which is part of Android, we can not access it in Unit Test.

The fix?
We can tell the test class to use a specific Dispatcher through out the test.

In setup function, which is annotated with @Before we can use the following line of code:
Dispatchers.setMain(StandardTestDispatcher())

And reset it in tearDown function annotated with @After
Dispatchers.resetMain()

The StandardTestDispatcher which is designed for testing, will be used through out the test function. Explained in details below.

Ok, now we set the dispatcher, and the test is running successfully but it fails, every single time. Why?

Let me explain,

The register function, launches the coroutine and finishes immediately, we are not waiting for the coroutine to finish, we are asserting it right away. So by the time we asserting it, the coroutine may not even have started.

We need more control on underlying coroutine.

That’s where StandardTestDispatcher comes to rescue.

It provides you tools to command the coroutine.

We just need to pass an instance of StandardTestDispatcher to runTest function, so we can control it as we want.

StandardTestDispatcher will NOT enter the launch block automatically, in the function being tested, unless you tell it to.

For our function, it means viemodelScope.launch will never get executed.

To execute it, we call runCurrent() function, this will check which coroutine has been launched but not executed, and will execute it.

If we had more than one launch blocks, we will have to call runCurrent for each of them.

Here is how our update test code looks like:

@Test
fun testRegistration() = runTest(dispatcher) {
assertk.assertThat(viewModel.isLoading.value).isFalse()
viewModel.register()
runCurrent()
assertk.assertThat(viewModel.isLoading.value).isTrue()
assertk.assertThat(viewModel.isLoading.value).isFalse()
}

We are making sure the coroutine get’s executed before checking isTrue , by calling runCurrent

It will still fail, not like previously, but the last assertion, we are expecting false, but it will be true.

Why?

Because of delay task, even though we made sure the coroutine get’s executed before we made the isTrue assertion, we are still not waiting for delay, and the delay is not getting skipped automatically either.

Fix?

advanceUntilIdle()

It will, as the name suggests, advance until idle, or in another words, complete the coroutine.

So if we put it before isFalse assertion, everything will fall in place and code will execute properly.

Here is the updated and working test function:


@Test
fun testRegistration() = runTest(dispatcher) {
assertk.assertThat(viewModel.isLoading.value).isFalse()
viewModel.register()
runCurrent()
assertk.assertThat(viewModel.isLoading.value).isTrue()
advanceUntilIdle()
assertk.assertThat(viewModel.isLoading.value).isFalse()
}

Wasn’t it awesome? Yes, it was.

There are more tools in our arsenal provided by StandardTestDispatcher

One of them is advanceTimeBy() which we can use to skip the delays manually, as if we have a delay of 3000, we can use advanceTimeBy(3005) to skip that delay.

I hope it helped you.

Read more here:

Cheers,

--

--