Kotlinx.coroutines: suspendCoroutine is not testtable

Created on 24 Aug 2018  路  11Comments  路  Source: Kotlin/kotlinx.coroutines

Consider following scenario

Function to test:

fun execute(appConfig: AppConfig): Deferred<String> = async {
   registerDevice(appConfig.serviceId)    
}

private suspend fun registerDevice(serviceId: String): String = suspendCoroutine {
   //some nasty 3rd party library delivering data in a callback
   lib.registerDeviceForService(object: Callback {
          override fun onSuccess(response) {
             it.resume(response.data)
          }
      }
   )
}

Here is the test:

    private val callbackCaptor = argumentCaptor<Callback<Response>>()

    @Test
    fun `ok result of registration results in success`() = blocking {
        val deferred = classToTest.execute(appConfig)
        mockRegisterResponseSuccess()
        val result = deferred.await()
        result.should.be.equal(trackingId)
    }

    private fun mockRegisterResponseSuccess(result: ApiResponseStatus) {
        then(lib).should().registerDeviceForService(callbackCaptor.capture())
        callbackCaptor.firstValue.onSuccess(Response(data = "testdata"))
    }

suspendCoroutine blocks the call and code never gets to the mockRegisterReponseSuccess call to mock and capture the callback

question waiting for clarification

Most helpful comment

  @Test
    fun `executes and gets result`() = blocking {
        val result = classToTest.execute().await()
        then(lib).should().doStuff(libCallbackCaptor.capture())
        libCallbackCaptor.firstValue.onResultReady("result ready")
                result.should.be.equal("result ready")

    }

Why are you expecting this test to work? You call await (suspending the whole coroutine) and then when deferred is complete, you are mocking the service which is supposed to complete deferred.
That's exactly the case I've described above with CompletableFuture. You should first mock the service and only then call await on it.

About mocking Kotlin code -- it's not a kotlinx.coroutines issue, just use a Kotlin-mockito.

Task is to test that calling testClassInstance.execute() returns back "result ready"

Something like this modulo proper mocking

 @Test
    fun sampleTest() = runTest {
        val lib = mock(Lib::class.java)
        val testInstance = TestClass(lib)
        Mockito.`when`(lib.doStuff(ArgumentMatchers.any(LibCallback::class.java))).thenAnswer { invocation -> (invocation.getArgument(0) as LibCallback).onResultReady("result ready") }
        val deferred = testInstance.execute()
        assertEquals("result ready", deferred.await())
    }

All 11 comments

execute returns Deferred<String> or String? Because these snippets are inconsistent: execute(appConfig: AppConfig): String, but val deferred = classToTest.execute(appConfig); ... deferred.await().

If it returns Deferred, then there shouldn't be any problems (just mock response before calling await).

If not:

suspendCoroutine blocks the call and code never gets to the mockRegisterReponseSuccess call to mock and capture the callback

That's more or less self-explanatory. You should mock response-related class before suspending current thread.
For example, consider following piece of blocking code:

val future: CompletableFuture<> = foo()
future.get()
mockServiceResponsibleForFutureCompletion()

This toy example will block forever, but it's not because CompletableFuture is untestable :)

Please provide self-contained example of the problem and behaviour you'd want to have.

@qwwdfsad updated to Deferred.
I actually tried both with async and with just suspend 鈥撀燽oth cases do not work

@qwwdfsad To make this test run successfully I need to capture the value passed into lib.registerDeviceForService and manually execute it's onSuccess() method
With Mockito it only works if you do:
verify(lib).registerDeviceForService(callbackCaptor.capture())
So only after the call to the test method was made, mocks counted all the execution paths and it's possible to capture some value

Basically, the real issue isn't with testing.
Real issue is that suspendCoroutine is the only way to work with callbacks, and it's blocking
With RxJava same code looks like

val testObserver = classToTest.execute().test()
        mockRegisterResponseSuccess(ApiResponseStatus.OK)
        mockInitAppPurchasesSuccess()
        testObserver.assertValue(trackingId)

and it works fine

We need similar test tooling for coroutines

Sorry, you're providing too much your project-specific details, which I'm not aware of due to the lack of the context.

I'm happy to help you if you will provide a self-contained example of the problem: either a sample project or a single snippet which I can copy-paste into IDE and run (+include some dependencies like mockito if it's required). Without it, I can only speculate that it's some problem in mocking.
Please provide a reproducer with Deferred.

Here is a more generic example:

import kotlinx.coroutines.experimental.Deferred
import kotlinx.coroutines.experimental.async
import kotlin.coroutines.experimental.suspendCoroutine

interface LibCallback {
    fun onResultReady(data: String)
}

class Lib {
    fun doStuff(callback: LibCallback) {
        callback.onResultReady("result ready")
    }
}

class TestClass(private val lib: Lib) {
    fun execute(): Deferred<String> = async {
        callSuspendedCallback()
    }

    private suspend fun callSuspendedCallback(): String = suspendCoroutine {
        lib.doStuff(object: LibCallback {
            override fun onResultReady(data: String) {
                it.resume(data)
            }
        })
    }
}

Task is to test that calling testClassInstance.execute() returns back "result ready"

Test that fails.
Libraries used: Mockito, Mockito-kotlin, expekt

import kotlinx.coroutines.experimental.runBlocking
import org.junit.Test

fun <T> blocking(block: suspend () -> T) {
    runBlocking { block() }
}

class TestClassTest {

    private val lib: Lib = mock()
    private val classToTest = TestClass()

    val libCallbackCaptor = argumentCaptor<LibCallback>()

    @Test
    fun `executes and gets result`() = blocking {
        val result = classToTest.execute().await()
        then(lib).should().doStuff(libCallbackCaptor.capture())
        libCallbackCaptor.firstValue.onResultReady("result ready")
                result.should.be.equal("result ready")

    }

}

That basically means that it's not possible to use Mockito ArgumentCaptor approach with suspendCoroutine

  @Test
    fun `executes and gets result`() = blocking {
        val result = classToTest.execute().await()
        then(lib).should().doStuff(libCallbackCaptor.capture())
        libCallbackCaptor.firstValue.onResultReady("result ready")
                result.should.be.equal("result ready")

    }

Why are you expecting this test to work? You call await (suspending the whole coroutine) and then when deferred is complete, you are mocking the service which is supposed to complete deferred.
That's exactly the case I've described above with CompletableFuture. You should first mock the service and only then call await on it.

About mocking Kotlin code -- it's not a kotlinx.coroutines issue, just use a Kotlin-mockito.

Task is to test that calling testClassInstance.execute() returns back "result ready"

Something like this modulo proper mocking

 @Test
    fun sampleTest() = runTest {
        val lib = mock(Lib::class.java)
        val testInstance = TestClass(lib)
        Mockito.`when`(lib.doStuff(ArgumentMatchers.any(LibCallback::class.java))).thenAnswer { invocation -> (invocation.getArgument(0) as LibCallback).onResultReady("result ready") }
        val deferred = testInstance.execute()
        assertEquals("result ready", deferred.await())
    }

Thanks
I also found a workaround just now:

given(lib.doStuff(argThat {
   it.onResultReady("result ready")
   true
}).willAnswer {}

Version with argument captors worked nice with RxJava and testsubscribers

for posterity: using MockK you can write something like this
val lib: Lib = mockk()
val slot = slot()
every {
lib.doStuff(capture(slot))
} answers {
slot.captured.onResultReady("result ready")
}

Was this page helpful?
0 / 5 - 0 ratings

Related issues

streetsofboston picture streetsofboston  路  40Comments

elizarov picture elizarov  路  35Comments

fvasco picture fvasco  路  70Comments

asfdfdfd picture asfdfdfd  路  51Comments

elizarov picture elizarov  路  45Comments