How to unit test Kotlin suspending functions
Asked Answered
T

3

35

I follow the MVP pattern + UseCases to interact with a Model layer. This is a method in a Presenter I want to test:

fun loadPreviews() {
    launch(UI) {
        val items = previewsUseCase.getPreviews() // a suspending function
        println("[method] UseCase items: $items")

        println("[method] View call")
        view.showPreviews(items)
    }
}

My simple BDD test:

fun <T> givenSuspended(block: suspend () -> T) = BDDMockito.given(runBlocking { block() })

infix fun <T> BDDMockito.BDDMyOngoingStubbing<T>.willReturn(block: () -> T) = willReturn(block())

@Test
fun `load previews`() {
    // UseCase and View are mocked in a `setUp` method

    val items = listOf<PreviewItem>()
    givenSuspended { previewsUseCase.getPreviews() } willReturn { items }

    println("[test] before Presenter call")
    runBlocking { presenter.loadPreviews() }
    println("[test] after Presenter call")

    println("[test] verify the View")
    verify(view).showPreviews(items)
}

The test passes successfully but there's something weird in the log. I expect it to be:

  • "[test] before Presenter call"
  • "[method] UseCase items: []"
  • "[method] View call"
  • "[test] after Presenter call"
  • "[test] verify the View"

But it turns out to be:

  • [test] before Presenter call
  • [test] after Presenter call
  • [test] verify the View
  • [method] UseCase items: []
  • [method] View call

What's the reason of this behaviour and how should I fix it?

Tantrum answered 16/4, 2018 at 19:42 Comment(0)
T
20

I've found out that it's because of a CoroutineDispatcher. I used to mock UI context with EmptyCoroutineContext. Switching to Unconfined has solved the problem

Update 02.04.20

The name of the question suggests that there'll be an exhaustive explanation how to unit test a suspending function. So let me explain a bit more.

The main problem with testing a suspending function is threading. Let's say we want to test this simple function that updates a property's value in a different thread:

class ItemUpdater(val item: Item) {
  fun updateItemValue() {
    launch(Dispatchers.Default) { item.value = 42 }
  }
}

We need to somehow replace Dispatchers.Default with an another dispatcher only for testing purposes. There're two ways how we can do that. Each has its pros and cons, and which one to choose depends on your project & style of coding:

1. Inject a Dispatcher.

class ItemUpdater(
    val item: Item,
    val dispatcher: CoroutineDispatcher  // can be a wrapper that provides multiple dispatchers but let's keep it simple
) {
  fun updateItemValue() {
    launch(dispatcher) { item.value = 42 }
  }
}

// later in a test class

@Test
fun `item value is updated`() = runBlocking {
  val item = Item()
  val testDispatcher = Dispatchers.Unconfined   // can be a TestCoroutineDispatcher but we still keep it simple
  val updater = ItemUpdater(item, testDispatcher)

  updater.updateItemValue()

  assertEquals(42, item.value)
}

2. Substitute a Dispatcher.

class ItemUpdater(val item: Item) {
  fun updateItemValue() {
    launch(DispatchersProvider.Default) { item.value = 42 }  // DispatchersProvider is our own global wrapper
  }
}

// later in a test class

// -----------------------------------------------------------------------------------
// --- This block can be extracted into a JUnit Rule and replaced by a single line ---
// -----------------------------------------------------------------------------------
@Before
fun setUp() {
  DispatchersProvider.Default = Dispatchers.Unconfined
}

@After
fun cleanUp() {
  DispatchersProvider.Default = Dispatchers.Default
}
// -----------------------------------------------------------------------------------

@Test
fun `item value is updated`() = runBlocking {
  val item = Item()
  val updater = ItemUpdater(item)

  updater.updateItemValue()

  assertEquals(42, item.value)
}

Both of them are doing the same thing - they replace the original Dispatchers.Default in test classes. The only difference is how they do that. It's really really up to you which of them to choose so don't get biased by my own thoughts below.

IMHO: The first approach is a little too much cumbersome. Injecting dispatchers everywhere will result into polluting most of the classes' constructors with an extra DispatchersWrapper only for a testing purpose. However Google recommends this way at least for now. The second style keeps things simple and it doesn't complicate the production classes. It's like an RxJava's way of testing where you have to substitute schedulers via RxJavaPlugins. By the way, kotlinx-coroutines-test will bring the exact same functionality someday in future.

Tantrum answered 18/4, 2018 at 18:54 Comment(3)
it is recommended to inject the coroutine scope & dispatchers instead: craigrussell.io/2019/11/…Lykins
I don't like the idea to pollute every constructor with a "DispatchersProvider". It's a little too much cumbersome. I prefer the RxJava way of testing - replace global Schedulers via RxJavaPlugins. It's far more convenient and it doesn't complicate the code. By the way, this approach will be available for Coroutines as well: github.com/Kotlin/kotlinx.coroutines/issues/1365Tantrum
hello, any idea on how we can find a solution for this? #68635396Lecturer
F
12

I see you found out on you own, but I'd like to explain a bit more for the people that might run into the same problem

When you do launch(UI) {}, a new coroutine is created and dispatched to the "UI" Dispatcher, that means that your coroutine now runs on a different thread.

Your runBlocking{} call create a new coroutine, but runBlocking{} will wait for this coroutine to end before continuing, your loadPreviews() function creates a coroutine, start it and then return immediately, so runBlocking() just wait for it and return.

So while runBlocking{} has returned, the coroutine that you created with launch(UI){} is still running in a different thread, that's why the order of your log is messed up

The Unconfined context is a special CoroutineContext that simply create a dispatcher that execute the coroutine right there on the current thread, so now when you execute runBlocking{}, it has to wait for the coroutine created by launch{} to end because it is running on the same thread thus blocking that thread.

I hope my explanation was clear, have a good day

Fotinas answered 25/4, 2018 at 20:31 Comment(0)
R
0

In my case I just added test dependency to the project:

kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" }

And used this in my tests:

@Test
fun `no connection result`() = runTest { // runTest can run any suspend fun
    val useCase = SplashUseCase()
    val result = useCase.suspendFun()
    assertThat(result).isEqualTo(WorkResult.Success(SplashUseCase.SplashResult.GoHome))
}

How you can see runTest {} can help us here.

Ramiform answered 23/7 at 14:6 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.