Unit tests when using corutin in an Android application
Translation of the article. The original is here .
This article does not address the working principle of corutin. If you are not familiar with them, we recommend that you read the introduction to kotlinx git repo .
The article describes the difficulties in writing unit tests for code that uses coroutines. In the end, we show a solution to this problem.
Typical architecture
Imagine we have a simple architecture MVP
in an application. Activity
looks like that:
class ContentActivity : AppCompatActivity(), ContentView {
private lateinit var textView: TextView
private lateinit var presenter: ContentPresenter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
textView = findViewById(R.id.content_text_view)
// emulation of dagger
injectDependencies()
presenter.onViewInit()
}
private fun injectDependencies() {
presenter = ContentPresenter(ContentRepository(), this)
}
override fun displayContent(content: String) {
textView.text = content
}
}
// interface for View Presenter communication
interface ContentView {
fun displayContent(content: String)
}
In Presenter
we use coroutines for asynchronous operations. The repository simply emulates the execution of a long request:
// Presenter class
class ContentPresenter(
private val repository: ContentRepository,
private val view: ContentView
) {
fun onViewInit() {
launch(UI) {
// move to another Thread
val content = withContext(CommonPool) {
repository.requestContent()
}
view.displayContent(content)
}
}
}
// Repository class
class ContentRepository {
suspend fun requestContent(): String {
delay(1000L)
return "Content"
}
}
Unit tests
Everything works well, but now we need to test this code. Although we introduce all the dependencies with explicit constructor use, testing our code will not be easy. We use the Mockito library for testing.
It is also worth paying attention to the use of the function runBlocking
. This is necessary to wait for the result of the test and use the supsend
functions. The test code looks like this:
class ContentPresenterTest {
@Test
fun `Display content after receiving`() = runBlocking {
// arrange
val repository = mock(ContentRepository::class.java)
val view = mock(ContentView::class.java)
val presenter = ContentPresenter(repository, view)
val expectedResult = "Result"
`when`(repository.requestContent()).thenReturn(expectedResult)
// act
presenter.onViewInit()
// assert
verify(view).displayContent(expectedResult)
}
}
The test fails with:
org.mockito.exceptions.base.MockitoException: Cannot mock/spy class sample.dev.coroutinesunittests.ContentRepository Mockito cannot mock/spy because : — final class
We need to add the keyword open
to the class ContentRepository
and to the method requestContent()
so that the library Mockito
can perform a function call override and an object override.
open class ContentRepository {
suspend open fun requestContent(): String {
delay(1000L)
return "Content"
}
}
The test fails again. This time, it happened because the context of the coroutine UI
uses elements from the library Android.
. Since we run tests for JVM
, this leads to an error.
We found a ready-made solution to this problem. You can see it here . The author solves this problem by moving Corutin's execution logic into Activity
. It seems to us that this option is not too correct, because Activity
assumes responsibility for managing workflows.
Using the CoroutineContextProvider Class
Here is another solution: pass the coroutine execution context using the constructor Presenter
, and then use this context to launch coroutine. We need to create a class.CoroutineContextProvider
open class CoroutineContextProvider() {
open val Main: CoroutineContext by lazy { UI }
open val IO: CoroutineContext by lazy { CommonPool }
}
It has only two fields that reference the same context as in the previous code. The class itself and its fields must have a modifier open
in order to be able to inherit this class and redefine field values for testing purposes. We also need to use lazy initialization to assign a value only when we use the value for the first time. (Otherwise, the class always initializes the value UI
and the tests still fail)
// Presenter class
class ContentPresenter(
private val repository: ContentRepository,
private val view: ContentView,
private val contextPool: CoroutineContextProvider = CoroutineContextProvider()
) {
fun onViewInit() {
launch(contextPool.Main) {
// move to another Thread
val content = withContext(contextPool.IO) {
repository.requestContent()
}
view.displayContent(content)
}
}
}
The final step is to create TestContextProvider
and add its use to the test.
Grade TestContextProvider
:
class TestContextProvider : CoroutineContextProvider() {
override val Main: CoroutineContext = Unconfined
override val IO: CoroutineContext = Unconfined
}
We use context Unconfied
. This means that coroutines are executed in the same thread as the rest of the code. He looks like a planner Trampoline
in RxJava
.
Our last step is to pass TestContextProvider
to the constructor Presenter
in the test:
class ContentPresenterTest {
@Test
fun `Display content after receiving`() = runBlocking {
// arrange
val repository = mock(ContentRepository::class.java)
val view = mock(ContentView::class.java)
val presenter = ContentPresenter(repository, view, TestContextProvider())
val expectedResult = "Result"
`when`(repository.requestContent()).thenReturn(expectedResult)
// act
presenter.onViewInit()
// assert
verify(view).displayContent(expectedResult)
}
}
That's all. After the next run, the test will succeed.
Jabber is worth nothing - show us the code! Please - Link to git