Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to wait for suspendCoroutine in unit test?

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?

like image 945
findusl Avatar asked Jul 02 '19 11:07

findusl


3 Answers

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

like image 78
Andrei Tanana Avatar answered Oct 17 '22 02:10

Andrei Tanana


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.

like image 25
findusl Avatar answered Oct 17 '22 01:10

findusl


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()
            }
        }
    }
}
like image 2
sedovav Avatar answered Oct 17 '22 03:10

sedovav