I want to write tests for my android application. Sometimes the viewModel executes tasks in the background using Kotlins coroutine launch function. These tasks are executed in the viewModelScope that the androidx.lifecycle library so handily provides. In order to still test these functions, I replaced the default android Dispatchers with Dispatchers.Unconfined, which runs the code synchronously.
At least in most of the cases. When using suspendCoroutine, the Dispatchers.Unconfined will not be suspended and later resumed, but instead will simply return. The documentation of Dispatchers.Unconfined
reveals why:
[Dispatchers.Unconfined] lets the coroutine resume in whatever thread that is used by the corresponding suspending function.
So by my understanding, the coroutine is not actually suspended, but the rest of the async function after suspendCoroutine
is run on the thread that calls continuation.resume
. Therefore the test fails.
Example:
class TestClassTest {
var testInstance = TestClass()
@Test
fun `Test with default dispatchers should fail`() {
testInstance.runAsync()
assertFalse(testInstance.valueToModify)
}
@Test
fun `Test with dispatchers replaced by Unconfined should pass`() {
testInstance.DefaultDispatcher = Dispatchers.Unconfined
testInstance.IODispatcher = Dispatchers.Unconfined
testInstance.runAsync()
assertTrue(testInstance.valueToModify)
}
@Test
fun `I need to also test some functions that use suspend coroutine - How can I do that?`() {
testInstance.DefaultDispatcher = Dispatchers.Unconfined
testInstance.IODispatcher = Dispatchers.Unconfined
testInstance.runSuspendCoroutine()
assertTrue(testInstance.valueToModify)//fails
}
}
class TestClass {
var DefaultDispatcher: CoroutineContext = Dispatchers.Default
var IODispatcher: CoroutineContext = Dispatchers.IO
val viewModelScope = CoroutineScope(DefaultDispatcher)
var valueToModify = false
fun runAsync() {
viewModelScope.launch(DefaultDispatcher) {
valueToModify = withContext(IODispatcher) {
sleep(1000)
true
}
}
}
fun runSuspendCoroutine() {
viewModelScope.launch(DefaultDispatcher) {
valueToModify = suspendCoroutine {
Thread {
sleep(1000)
//long running operation calls listener from background thread
it.resume(true)
}.start()
}
}
}
}
I was experimenting around with runBlocking
, however it only helps if you call launch using the CoroutineScope
created by runBlocking
. But since my code is launching on the CoroutineScope
provided by viewModelScope
this will not work. If possible I would prefer not to inject a CoroutineScope everywhere, because any lower level class could (and does) make their own CoroutineScope as in the example, and then the Test has to know a lot about the implementation details. The idea behind the scopes is that every class has control over cancelling their asynchronous operations. The viewModelScope for example is by default cancelled when the viewModel is destroyed.
My question:
What coroutine dispatcher could I use that runs launch and withContext etc blocking (like Dispatchers.Unconfined
) and also runs suspendCoroutine blocking?
How about passing the coroutine context to your model? Something like
class Model(parentContext: CoroutineContext = Dispatchers.Default) {
private val modelScope = CoroutineScope(parentContext)
var result = false
fun doWork() {
modelScope.launch {
Thread.sleep(3000)
result = true
}
}
}
@Test
fun test() {
val model = runBlocking {
val model = Model(coroutineContext)
model.doWork()
model
}
println(model.result)
}
Update for viewModelScope from androidx.lifecycle you can just use this test rule
@ExperimentalCoroutinesApi
class CoroutinesTestRule : TestWatcher() {
private val dispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher()
override fun starting(description: Description?) {
super.starting(description)
Dispatchers.setMain(dispatcher)
}
override fun finished(description: Description?) {
super.finished(description)
Dispatchers.resetMain()
dispatcher.cleanupTestCoroutines()
}
}
here are the test model and the test
class MainViewModel : ViewModel() {
var result = false
fun doWork() {
viewModelScope.launch {
Thread.sleep(3000)
result = true
}
}
}
class MainViewModelTest {
@ExperimentalCoroutinesApi
@get:Rule
var coroutinesRule = CoroutinesTestRule()
private val model = MainViewModel()
@Test
fun `check result`() {
model.doWork()
assertTrue(model.result)
}
}
don't forget to add testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version"
to get TestCoroutineDispatcher
With the helps of other answers, I have found the following solution. As sedovav suggested in his answer, I can run the task async and wait for the Deferred. Basically the same idea, I can run the task with launch and wait for the job that handles the task. The problem in both cases is, how do I get the Deferred or Job.
I found a solution to this. Such a Job is always a child of the Job contained in the CoroutineContext which is part of the CoroutineScope. So the following code does solve the problem, both in my example code as well as in the actual android application.
@Test
fun `I need to also test some functions that use suspend coroutine - How can I do that?`() {
replaceDispatchers()
testInstance.runSuspendCoroutine()
runBlocking {
(testInstance.viewModelScope.coroutineContext[Job]?.children?.forEach { it.join() }
}
assertTrue(testInstance.valueToModify)//fails
}
It does seem a bit like a hack, so if someone has a reason why this is dangerous, please tell. Also it won't work if there is another underlying CoroutineScope created by some other class. But it is the best solution I have.
As I understood, you want everything in your production code to run in the main thread of your test. But it doesn't seem to be achievable as something may be run in a regular thread pool/background, and if have no means to synchronize/join the background process in your code you probably in trouble. The best thing is to join background threads somehow in a coroutine and await
that coroutine in the test before the assertions. It may require you to change your existing production code.
I'm talking about a different approach to write your code. Surely, it's not always possible. I've updated your example accordingly to illustrate my point:
class TestClassTest {
var testInstance = TestClass()
@Test
fun `Test with default dispatchers should fail`() = runBlocking {
val valueToModify = testInstance.runAsync().await()
assertTrue(valueToModify)
}
@Test
fun `Test with dispatchers replaced by Unconfined should pass`() = runBlocking {
testInstance.DefaultDispatcher = Dispatchers.Unconfined
testInstance.IODispatcher = Dispatchers.Unconfined
val valueToModify = testInstance.runAsync().await()
assertTrue(valueToModify)
}
@Test
fun `I need to also test some functions that use suspend coroutine - How can I do that?`() = runBlocking {
testInstance.DefaultDispatcher = Dispatchers.Unconfined
testInstance.IODispatcher = Dispatchers.Unconfined
val valueToModify = testInstance.runSuspendCoroutine().await()
assertTrue(valueToModify)//fails
}
}
class TestClass {
var DefaultDispatcher: CoroutineContext = Dispatchers.Default
var IODispatcher: CoroutineContext = Dispatchers.IO
val viewModelScope = CoroutineScope(DefaultDispatcher)
fun runAsync(): Deferred<Boolean> {
return viewModelScope.async(DefaultDispatcher) {
withContext(IODispatcher) {
sleep(1000)
true
}
}
}
fun runSuspendCoroutine(): Deferred<Boolean> {
return viewModelScope.async(DefaultDispatcher) {
suspendCoroutine<Boolean> {
Thread {
sleep(1000)
//long running operation calls listener from background thread
it.resume(true)
}.start()
}
}
}
}
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With