image


Перевод статьи. Оригинал находиться здесь.


В этой статье не рассматривается принцип работы корутин. Если вы не знакомы с ними, то рекомендуем прочитать введение в 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

Комментарии (2)


  1. UbuRus
    16.02.2018 23:05

    Поправьте, пожалуйста, код в соответствии с официальным стайл гайдом: https://kotlinlang.org/docs/reference/coding-conventions.html#class-header-formatting


    Конструкторы выглядят неестественно для котлин разработчика:


    class ContentPresenter(private val repository: ContentRepository,
                           private val view: ContentView) {

    Лучше (и рекомендуется):


    class ContentPresenter(
        private val repository: ContentRepository,
        private val view: ContentView
    ) {


  1. UbuRus
    16.02.2018 23:11

    Зачем делать класс и открывать его, если можно сделать интерфейс и несколько реализаций?


    interface CoroutineContextProvider {
        val Main: CoroutineContext
        val IO: CoroutineContext
    }
    
    class AppCoroutineContextProvider : CoroutineContextProvider {
        override val Main: CoroutineContext by lazy { UI }
        override val IO: CoroutineContext by lazy { CommonPool }
    }
    
    class TestContextProvider : CoroutineContextProvider {
        override val Main: CoroutineContext = Unconfined
        override val IO: CoroutineContext = Unconfined
    }