Перевод статьи. Оригинал находится здесь.
В этой статье не рассматривается принцип работы корутин. Если вы не знакомы с ними, то рекомендуем прочитать введение в kotlinx git repo.
Статья описывает трудности при написании юнит тестов для кода, использующего корутины. В конце мы покажем решение этой проблемы.
Типичная архитектура
Представьте, что у нас есть простая архитектура MVP
в приложении. Activity
выглядит так:
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)
}
В Presenter
мы используем корутины для асинхронных операций. Репозиторий просто эмулирует выполнение длительного запроса:
// 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"
}
}
Юнит тесты
Все работает хорошо, но теперь нам нужно протестировать этот код. Хотя мы вводим все зависимости с явным использованием конструктора, протестировать наш код будет не совсем просто.Мы используем библиотеку Mockito для тестирования.
Также стоит обратить внимание на использование функции runBlocking
. Это необходимо, чтобы дождаться результата выполнения теста и использовать supsend
функции. Код теста выглядит так:
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)
}
}
Тест выполняется с ошибкой:
org.mockito.exceptions.base.MockitoException: Cannot mock/spy class sample.dev.coroutinesunittests.ContentRepository Mockito cannot mock/spy because : — final class
Нам необходимо добавить ключевое слово open
к классу ContentRepository
и к методу requestContent()
, чтобы библиотека Mockito
могла выполнить подмену вызова функции и подмену самого объекта.
open class ContentRepository {
suspend open fun requestContent(): String {
delay(1000L)
return "Content"
}
}
Тест опять выполняется с ошибкой. На этот раз это произошло из-за того, что контекст корутины UI
использует элементы из библеотеки Android.
. Так как мы выполняем тесты для JVM
, это приводит к ошибке.
Мы нашли готовое решение этой проблемы. Вы можете видеть его по ссылке. Автор решает эту проблему, перемещая логику выполнения корутин в Activity
. Нам кажется такой вариант не слишком правильным, т.к. Activity
получает ответственность за управление потоками выполнения задач.
Использование класса CoroutineContextProvider
Вот еще одно решение: передать контекст выполнения корутин с помощью конструктора Presenter
, а затем использовать этот контекст для запуска корутин. Нам нужно создать класс CoroutineContextProvider
open class CoroutineContextProvider() {
open val Main: CoroutineContext by lazy { UI }
open val IO: CoroutineContext by lazy { CommonPool }
}
Он имеет только два поля, которые ссылаются на тот же контекст, что и в предыдущем коде. Сам класс и его поля должны иметь модификатор open
, чтобы иметь возможность наследовать этот класс и переопределять значения полей для целей тестирования. Также нам нужно использовать ленивую инициализацию, чтобы присвоить значение только тогда, когда мы будем использовать значение в первый раз. (Иначе класс всегда инициализирует значение UI
и тесты по-прежнему падают)
// 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)
}
}
}
Последним шагом является создание TestContextProvider
и добавление его использования в тест.
Класс TestContextProvider
:
class TestContextProvider : CoroutineContextProvider() {
override val Main: CoroutineContext = Unconfined
override val IO: CoroutineContext = Unconfined
}
Мы используем контекст Unconfied
. Это означает, что корутины выполняются в том же потоке, в котором выполняется остальной код. Он похож на планировщик Trampoline
в RxJava
.
Наш последний шаг — передать TestContextProvider
в конструктор Presenter
в тесте:
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)
}
}
На этом всё. После следующего запуска тест выполнится успешно.
Болтовня ничего не стоит — покажи нам код! Пожалуйста — Ссылка на git