Reputation: 7053
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
Reputation: 89528
There are two problems here:
viewModel.inc()
launches internally.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:
MyViewModel
contain a CoroutineScope
instead of implementing the interface, which is an officially recommended practicecoroutineContext
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 usedFor 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:
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.launchIn
, you could simply pass in this
, the receiver of runTest
, which points to a TestScope
.Upvotes: 8