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