Юнит тесты при использовании корутин в Android приложении

    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

    • +10
    • 2.8k
    • 6
    Share post

    Similar posts

    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More
    Ads

    Comments 6

      0

      Поправьте, пожалуйста, код в соответствии с официальным стайл гайдом: 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
      ) {
        0
        UbuRus спасибо за комментарий. Поправил в гите и в статье.
        0

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


        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
        }
          0

          Спасибо за комментарий. В данном случае можно использовать и ваш способ, но для этого необходимо создать ещё одну абстракцию в коде в виде интерфейса. Я предпочитаю избегать лишних абстракций по возможности. Плата за это — добавление модификатора open к имени переменных. Нам в любом случае приходится делать это, чтобы можно было использовать библиотеку mockito.

          0
          Спасибо за примеры.
          Насколько сложно использовать Mockito вместе с котлином?
            0

            Совсем не сложно. Полная аналогия с использованием Mockito в java. Только приходится добавлять модификатор open к имени классов, методов, т.к. Mockito не можеть подменить финальные классы, методы.

          Only users with full accounts can post comments. Log in, please.