Хорошее мобильное приложение должно быть не только создано с использованием современных архитектурных шаблонов, но и быть подготовленным к реализации автоматических тестов. Мы рассмотрим с вами на практическом примере как создается приложение с учетом возможностей тестирования, чем отличаются архитектуры и соответствующие подходы к созданию тестов и как создать полный спектр тестов от unit-тестирования бизнес-логики до End-to-End тестов приложения как единого целого. В первой части статьи мы поговорим про разработку интерфейсов без использования реактивной модели, последовательно создадим (и доработаем) приложение на архитектурах MVP и MVVM и разработаем тесты (с использованием виртуального времени на тестовом диспетчере, моков и Hilt для подстановки тестовых объектов). Во второй части статьи речь пойдет о MVI и о тестировании приложений для Jetpack Compose (включая анимацию и передачу данных через LocalComposition-объекты). Всех, кому интересна мобильная разработка, приглашаю под кат.
Для примера мы будем разрабатывать и тестировать простое приложение, состоящее из кнопки, текстового поля, которое будет подсчитывать количество нажатий на кнопку, а также текстового блока, полученного из внешнего репозитория (асинхронно в корутине, с эмуляцией задержки получения данных). Рассмотренные подходы могут быть масштабированы для приложений любого уровня сложности (хотя и есть определенные нюансы в тестировании приложения на фрагментах, прежде всего связанные с жизненным циклом, с разделяемыми между фрагментами sharedViewModels и с особенностями навигации).
При создании приложения без использования реактивной модели за обновление интерфейса отвечает непосредственно код, который получает доступ к объекту для модификации через идентификатор и метод поиска findViewById (либо с использованием механизма viewBinding, который делает это действие неявно). Кроме кода для создания визуального представления (класс наследуется от Activity или Fragment и инициализируется с помощью LayoutInflater, который создает иерархию видов (View) на основе ресурсов, сформированных из XML-файлов layout/*.xml), обычно выделяют еще один или несколько классов, которые отвечают за реализацию сохраняемого состояния и за взаимодействие с сервисами и источниками данных. В зависимости от архитектурного подхода можно встретить следующее разделение ответственности между компонентами:
-
MVP — состояние описывается моделью, View только отображает начальное состояние модели и пересылает сигналы о действиях в пользователя в Presenter, дальнейшие модификации интерфейса выполняет Presenter (также он может взаимодействовать с другими слоями, например с репозиторием).
-
MVVM — состояние описывается моделью, которая сохранена в объекте ViewModel. View подписывается на изменения ViewModel (LiveData) и делает изменения в себе в соответствии с обнаруженными изменениями. View вызывает методы ViewModel при возникновении событий, требующих реакции (в нашем случае — нажатие кнопки)
-
MVI — View при возникновении события (нажатия кнопки) создает объект Event с информацией о действии и отправляет в Store, который может или модифицировать состояние (State) или создать единичное действие (Action). При этом view может модифицироваться во время действия render на основе полученного состояния (аналогично MVVM, но подписка не требуется, render будет вызван из Store). В действительности архитектура MVI сильно зависит от используемой библиотеки и может включать другие абстракции, но для нашего примера достаточно и этого набора. Мы рассматриваем MVI, поскольку он максимально близок к идее State в реактивной модели Jetpack Compose.
Тестирование каждого из архитектурных подходов имеет свои особенности и давайте немножко посмотрим на код нашего приложения (и тестов для него) и постараемся выделить ключевые проблемы, которые в дальнейшем будем решать с помощью Jetpack Compose.
Общей частью для всех подходов будет описание иерархии видов, состоящая из контейнера размещения (в нашем случае достаточно использовать LinearLayout с вертикальным позиционирование), текста и кнопки.
activity_main.xml <?xml version="1.0" encoding="utf-8"?> <LinearLayout android:orientation="vertical" android:layout_width="match_parent" android:layout_height="match_parent" xmlns:android="http://schemas.android.com/apk/res/android" > <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="Click below for increment" android:id="@+id/counter"/> <Button android:layout_width="match_parent" android:layout_height="wrap_content" android:text="+" android:id="@+id/increase_button"/> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:id="@+id/description"/> </LinearLayout>
Приложение будет состояние из единственного класса, унаследованного от AppCompatActivity, который также будет являться точкой входа в приложение:
MainActivity.kt class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) } }
MVP
В реализации MVP на стороне View (Activity) будет вызываться действие из Presenter, который будет изменять состояние (счетчик) и обращаться к View для изменения отображаемого текста. Тут сразу оговоримся, что для возможности тестирования нужно иметь возможность делать подмену Presenter, поэтому сразу будем использовать механизмы Dependency Injection на основе Hilt (также можно использовать Koin и делать определение через DSL, это не имеет существенного значения). Для корректной работы Hilt должен быть создан класс-наследник от Application (он же указывается в AndroidManifest.xml в атрибуте android:name тэга application)
@HiltAndroidApp class MainApplication : Application()
Модель данных (определяющая состояние нашего интерфейса) — это обычный data-класс:
class CounterModel(var counter: Int, var description: DescriptionResult? = null)
Также создадим sealed-класс для хранения результата внешнего запроса и промежуточного состояния его извлечения (здесь может быть обращение к сетевому ресурсу), а также интерфейс и реализацию метода извлечения данных из внешнего источника (мы возвращаем просто строку, но здесь может быть обращение к сетевому ресурсу):
sealed class DescriptionResult { class Success(val text: String) : DescriptionResult() class Error : DescriptionResult() class Loading : DescriptionResult() } interface IDescriptionRepository { suspend fun getDescription(): String } class DescriptionRepository @Inject constructor() : IDescriptionRepository { override suspend fun getDescription() = "Text from external data source" }
Для возможности автономного тестирования Presenter-класса мы разделим реализацию и интерфейсы и опишем контракт для действий view (в нашем случае обновление счетчика на экране) и presenter (действие увеличения счетчика и вызов при отображении view на экране для создания начального состояния и иных форм инициализации).
interface CounterContract { interface View { fun updateCounter(counter: Int) fun updateDescription(description: DescriptionResult) } interface Presenter { fun increment() fun onViewCreated() } }
Класс Presenter отвечает за контролируемое изменение состояния и уведомление view о необходимости изменить элементы в соответствии с произошедшими изменениями (для этого он получает ссылку на объект view). Также мы будем эмулировать обновление состояния для результата сетевого запроса (в реальном коде здесь скорее всего был бы запуск корутины в диспетчере IO).
class CounterPresenter @Inject constructor(private val view: CounterContract.View) : CounterContract.Presenter { lateinit var model: CounterModel private val coroutineScope = CoroutineScope(Dispatchers.IO) @Inject lateinit var descriptionRepository: IDescriptionRepository fun updateDescription(description: DescriptionResult) { model.description = description view.updateDescription(description) } override fun onViewCreated() { model = CounterModel(0) coroutineScope.launch { updateDescription(DescriptionResult.Loading()) delay(2000) updateDescription(DescriptionResult.Success(descriptionRepository.getDescription())) } } override fun increment() { model.counter++ view.updateCounter(model.counter) } }
Сама Activity тоже будет дополнена вызовом onViewCreated при создании и increment при нажатии на кнопку (аннотация @AndroidEntryPoint нужна для корректной работы Hilt).
@AndroidEntryPoint class CounterActivity : AppCompatActivity(), CounterContract.View { @Inject lateinit var presenter: CounterContract.Presenter override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) presenter.onViewCreated() findViewById<Button>(R.id.increase_button).setOnClickListener { presenter.increment() } } override fun updateCounter(counter: Int) { findViewById<TextView>(R.id.counter).text = "Counter: $counter" } override fun updateDescription(description: DescriptionResult) { val text = when (description) { is DescriptionResult.Error -> "Error occured" is DescriptionResult.Loading -> "Loading" is DescriptionResult.Success -> description.text } findViewById<TextView>(R.id.description).text = text } }
Добавим также интерфейс и реализацию класса для получения данных из внешнего источника:
interface IDescriptionRepository { fun getDescription(): String } class DescriptionRepository @Inject constructor() : IDescriptionRepository { override fun getDescription() = "Text from external data source" }
Ну и последнее действие — связывание интерфейсов и реализаций через Hilt:
@InstallIn(ActivityComponent::class) @Module abstract class CounterModule { //здесь связывание контракт и реализации для MVP @Binds abstract fun bindActivity(activity: CounterActivity): CounterContract.View @Binds abstract fun bindPresenter(impl: CounterPresenter): CounterContract.Presenter } @InstallIn(ActivityComponent::class) @Module abstract class RepositoryModule { //здесь привязываем источники данных (и/или сервисы) для возможной замены на тестовые реализации @Binds @ActivityScoped abstract fun bindDescription(impl: DescriptionRepository): IDescriptionRepository } @InstallIn(ActivityComponent::class) @Module object CounterActivityModule { //здесь возвращаем объект Activity для корректной инъекции в Presenter @Provides fun provideActivity(activity: Activity): CounterActivity { return activity as CounterActivity } }
Первое связывание связывает реализацию view по контракту (для создания презентера) и реализацию presenter по контракту (для заполнения переменных в view). Второе — определяет реализацию источника данных. Третье связывание предоставляет экземпляр существующей CounterActivity (создается из системы). Полный текст приложения доступен на https://github.com/dzolotov/qa-kotlin-mvp.
Теперь обсудим подходы к тестированию этого приложения. Общий вариант для всех архитектур — инструментальное тестирование (с использованием Espresso или оберток вокруг него, например Kakao). В этом случае эмулируются действия пользователя и проверяется изменение элементов интерфейса. Такой тест будет работать одинаково с любой архитектурой приложения (кроме использования Jetpack Compose). Сейчас мы подготовим тест с использованием библиотеки Kakao, добавим зависимости в build.gradle (dependencies):
androidTestImplementation 'io.github.kakaocup:kakao:3.0.6' androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' androidTestImplementation 'androidx.test:runner:1.4.0' androidTestImplementation 'androidx.test:rules:1.4.0'
При описании инструментального теста необходимо создать модель экрана (описывающего способы обнаружения view и связанные с ними объекты Kakao) и далее описать сценарий проверки и взаимодействия с элементами, например код теста может выглядеть так:
import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.ext.junit.runners.AndroidJUnit4 import io.github.kakaocup.kakao.screen.Screen import io.github.kakaocup.kakao.text.KButton import io.github.kakaocup.kakao.text.KTextView import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith open class CounterActivityScreen : Screen<CounterActivityScreen>() { val counter = KTextView { withId(R.id.counter) } val increaseButton = KButton { withId(R.id.increase_button) } val description = KTextView { withId(R.id.description) } } @RunWith(AndroidJUnit4::class) class CounterTest { @get:Rule val rule = ActivityScenarioRule(CounterActivity::class.java) val counterScreen = CounterActivityScreen() @Test fun checkCounter() { counterScreen { description.hasText("Loading") counter.hasText("Click below for increment") increaseButton.click() counter.hasText("Counter: 1") increaseButton.click() counter.hasText("Counter: 2") } } }
Здесь уже возникает вопрос — как протестировать работу отложенного обновления (у нас в течении двух секунд отображается надпись Loading, которая затем изменяется на строку, полученную из Repository). Мы допустили ошибку в проектировании приложения, когда явно указали использование диспетчера Dispatchers.IO для запуска корутины в отдельном потоке, более правильно было бы использовать Hilt для инъекции подходящего диспетчера и заменить его в тесте. Но перед этим давайте попробуем исправить тест для проверки финального состояния (с пропуском промежуточного отображение индикатора загрузки). Лучше всего для этого подходит применение IdlingResource, который позволяет приостановить выполнение теста на время выполнения операции с непредсказуемым временем ожидания результата. Мы воспользуемся одной из реализации IdlingResource: CountingIdlingResource, который работает как защелка со счетчиком, которая освобождается при обнулении значения счетчика. Добавим необходимые зависимости, изменение счетчика при ожидании значения и регистрацию в тесте:
implementation "androidx.test.espresso:espresso-idling-resource:3.4.0"
//добавим именованный ресурс в глобальную переменную var idling = CountingIdlingResource("counter-presenter") class CounterPresenter @Inject constructor(private val view: CounterContract.View) : CounterContract.Presenter { //... здесь другие методы презентера override fun onViewCreated() { model = CounterModel(0) CoroutineScope(coroutineDispatcher).launch { idling.increment() updateDescription(DescriptionResult.Loading()) delay(2000) updateDescription(DescriptionResult.Success(descriptionRepository.getDescription())) idling.decrement() } } }
class CounterTest { //...здесь определения свойств и другие методы... fun init() { IdlingRegistry.getInstance().register(idling) } @Test fun checkCounter() { counterScreen { description.hasText("Text from external data source") counter.hasText("Click below for increment") increaseButton.click() counter.hasText("Counter: 1") increaseButton.click() counter.hasText("Counter: 2") } }
Но хотелось бы все же протестировать и промежуточное состояние (Loading) и финальное после завершения загрузки. Здесь нам поможет переопределенный диспетчер StandardTestDispatcher, который позволяет выполнять манипуляции с виртуальным временем и управлять выполнением корутин. Для начала исправим жесткую привязку к Dispatchers.IO и сделаем это через инъекцию зависимостей:
@Qualifier annotation class IODispatcher @InstallIn(SingletonComponent::class) @Module object DispatchersModule { @Provides @IODispatcher @Singleton fun provideIODispatcher(): CoroutineDispatcher = Dispatchers.IO }
Здесь мы зарегистрировали диспетчер по умолчанию для использования в приложении, но добавили метку IODispatcher, чтобы иметь возможность использовать несколько диспетчеров. Следующим шагом нужно переопределить диспетчер в тесте, а для этого нам нужно будет выполнить ряд действий:
-
создать класс для точки входа в тестовое приложение и явно указать, что для запуска тестов вместо MainApplication будет использоваться HiltTestApplication
-
переопределить модуль и предоставить для CoroutineDispatcher с меткой IODispatcher реализацию тестового диспетчера
-
запустить обработку запланированной корутины при отображении Activity (без этого действия инициализация презентера не будет выполнена, поскольку по умолчанию StandartTestDispatcher не исполняет корутины).
class CustomTestRunner : AndroidJUnitRunner() { override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application { return super.newApplication(cl, HiltTestApplication::class.java.name, context) } }
build.gradle dependencies { //... kaptAndroidTest "com.google.dagger:hilt-android-compiler:$hilt_version" androidTestImplementation "com.google.dagger:hilt-android-testing:$hilt_version" } android { defaultConfig { //...настройки sdk и версии... testInstrumentationRunner "com.example.counterapp.CustomTestRunner" } //...определение buildTypes, compileOptions, kotlinOptions... }
Теперь добавим переопределение модуля DispatcherModule и будем возвращать тестовый диспетчер:
@TestInstallIn(components = [SingletonComponent::class], replaces = [DispatchersModule::class]) @Module object DispatchersTestModule { @IODispatcher @Provides @Singleton fun provideIODispatcher(): CoroutineDispatcher = StandardTestDispatcher() }
И добавим аннотацию @HiltAndroidTest к классу теста (для использования инъекций, поскольку нам будет нужно получить объект диспетчера для управления временем), а также присоединим обработку onActivity к сценарию правила AndroidScenarioRule для запуска обработки запланированных корутин и выполним «перемотку» виртуального таймера для проверки отображения извлеченных из внешнего источника данных.
@HiltAndroidTest @RunWith(AndroidJUnit4::class) class CounterTest { //запуск Activity и подписка на его жизненный цикл @get:Rule val rule = ActivityScenarioRule(CounterActivity::class.java) //нужно для инъекции зависимостей в тест @get:Rule var hiltRule = HiltAndroidRule(this) //определение экрана (не изменилось) val counterScreen = CounterActivityScreen() //получаем доступ к диспетчеру @Inject @IODispatcher lateinit var dispatcher: CoroutineDispatcher @Before fun init() { hiltRule.inject()//делаем инъекцию зависимостей rule.scenario.onActivity { //запускаем запланированные корутины (в нашем случае - в onViewCreated) (dispatcherDI as TestDispatcher).scheduler.runCurrent() } } @Test fun checkCounter() { counterScreen { description.hasText("Loading")//проверяем сообщение о загрузке (dispatcherDI as TestDispatcher).scheduler.run { advanceTimeBy(2000)//перемещаем виртуальный таймер на +2 секунды runCurrent()//и запускаем ожидающие выполнения задания } description.hasText("Text from external data source") //проверяем загруженные данные counter.hasText("Click below for increment") increaseButton.click() counter.hasText("Counter: 1") increaseButton.click() counter.hasText("Counter: 2") } } }
Аналогично может быть выполнена замена источника данных на тестовый. Для этого создадим тестовую реализацию IDescriptionRepository
и выполним замену модуля:
class TestDescriptionRepository @Inject constructor() : IDescriptionRepository { override suspend fun getDescription(): String = "Data from test" } @TestInstallIn(components = [ActivityComponent::class], replaces = [RepositoryModule::class]) @Module abstract class TestRepositoryModule { @Binds @ActivityScoped abstract fun bindDescription(impl: TestDescriptionRepository): IDescriptionRepository } //...и теперь в тесте проверяем, что были извлечены корректные данные из тестового источника @HiltAndroidTest @RunWith(AndroidJUnit4::class) class CounterTest { //...определения других полей и методов... @Test fun checkCounter() { counterScreen { description.hasText("Loading") (dispatcher as TestDispatcher).scheduler.run { advanceTimeBy(2000) runCurrent() } description.hasText("Data from test")//изменение здесь counter.hasText("Click below for increment") increaseButton.click() counter.hasText("Counter: 1") increaseButton.click() counter.hasText("Counter: 2") } } }
Мы выполнили все возможные проверки инструментальными тестами, но давайте для полноты тестирования попробуем провалидировать логику работы презентера. Поскольку здесь проверяется корректность обработки логики приложения, будет использоваться не инструментальный тест, а обычный unit-тест. Для выполнения проверки мы можем создать экземпляр CounterPresenter непосредственно в коде, но нам нужно будет убедиться, что был вызван метод класса View и для этого удобно использовать мок-объекты. Для этого мы подключим библиотеку mockk:
testImplementation "io.mockk:mockk:1.12.4"
И будем проверять количество вызовов функции updateCounter от мок-объекта для класса View после взаимодействия с презентером:
import io.mockk.every import io.mockk.mockk import io.mockk.verify import org.junit.Before import org.junit.Test class CounterPresenterTest { lateinit var view: CounterContract.View lateinit var presenter: CounterContract.Presenter @Before fun setup() { view = mockk() every { view.updateCounter(any()) } returns Unit presenter = CounterPresenter(view) presenter.onViewCreated() } @Test fun checkIncrement() { presenter.increment() verify(exactly = 1) { view.updateCounter(1) } presenter.increment() verify(exactly = 1) { view.updateCounter(2) } } }
Но в действительности мы не проверили на одну известную проблему MVP: сохраняется ли состояние презентера (и связанного с ним view) при повороте экрана. Дело в том, что изменение ориентации приводит к пересозданию view (а ссылка на него хранится в объекте презентера). Прежде всего добавим в автоматизацию возможность управления устройством, для этого подключим пакет uiautomator:
build.gradle //...другие секции dependencies { //... другие зависимости ... androidTestImplementation "androidx.test.uiautomator:uiautomator:$uiAutomatorVersion" }
И добавим в тест поворот экрана и проверку на сохранение последнего состояния:
val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) device.setOrientationLeft() counter.hasText("Counter: 2")
При запуске обнаружим, что теперь тест не проходит, поскольку в текстовом поле написано «Click below for increment». Давайте исправим наш Activity-класс и сделаем обработку событий жизненного цикла для пересоздания презентера с правильным состоянием. Прежде всего исключим проблему ошибки при доступе к несуществующему Activity и сохраним в презентере слабую ссылку (WeakReference), которая в худшем случае вернет null:
class CounterPresenter @Inject constructor(private val view: CounterContract.View) : CounterContract.Presenter { @Inject @IODispatcher lateinit var coroutineDispatcher: CoroutineDispatcher lateinit var model: CounterModel @Inject lateinit var descriptionRepository: IDescriptionRepository //слабая ссылка на View lateinit var viewRef: WeakReference<CounterContract.View> override fun onViewCreated() { viewRef = WeakReference(view)//создаем ссылку model = CounterModel(0) CoroutineScope(coroutineDispatcher).launch { updateDescription(DescriptionResult.Loading()) delay(2000) updateDescription(DescriptionResult.Success(descriptionRepository.getDescription())) } } override fun increment() { model.counter++ viewRef.get()?.updateCounter(model.counter) //используем ссылку } fun updateDescription(description: DescriptionResult) { model.description = description viewRef.get()?.updateDescription(description) } }
Но проблема все равно останется, поскольку при изменении ориентации экрана повторно вызывается onCreate и объект состояния сбрасывается (из-за повторного вызова onViewCreated). Добавим методы в презентер для сохранения и восстановления состояния и будем использовать возможности Activity по сохранению состояния между созданиями.
interface CounterContract { interface View { fun updateCounter(counter: Int) fun updateDescription(description: DescriptionResult) } interface Presenter { fun increment() fun onViewCreated() fun saveState(bundle: Bundle) fun restoreState(bundle: Bundle) } }
В Activity добавим методы для обработки событий сохранения-восстановления состояния:
override fun onSaveInstanceState(outState: Bundle) { presenter.saveState(outState) super.onSaveInstanceState(outState) } override fun onRestoreInstanceState(savedInstanceState: Bundle) { super.onRestoreInstanceState(savedInstanceState) presenter.restoreState(savedInstanceState) }
И реализацию в Presenter (пока только значение счетчика):
override fun saveState(bundle: Bundle) { bundle.putInt("counter", model.counter) } override fun restoreState(bundle: Bundle) { model.counter = bundle.getInt("counter") viewRef.get()?.updateCounter(model.counter) }
Теперь тест пройдет успешно. Но данные из внешнего источника будут запрашиваться повторно на каждом создании Activity, что не выглядит хорошим решением. Поскольку состояние ошибки или прогресса нет необходимости сохранять, будем записывать (и восстанавливать) только успешное состояние и предусмотрим восстановление непосредственно при создании презентера (чтобы исключить избыточную загрузку данных), перенесем функциональность restoreState в onViewCreated и уберем обработку onRestoreInstanceState из Activity (а также сделаем состояние счетчика в модели как Int? для представления неинициализированного значения):
class CounterModel(var counter: Int?, var description: DescriptionResult? = null) class CounterPresenter @Inject constructor(private val view: CounterContract.View) : CounterContract.Presenter { @Inject @IODispatcher lateinit var coroutineDispatcher: CoroutineDispatcher lateinit var model: CounterModel @Inject lateinit var descriptionRepository: IDescriptionRepository lateinit var viewRef: WeakReference<CounterContract.View> fun updateDescription(description: DescriptionResult) { model.description = description view.updateDescription(description) } override fun onViewCreated(savedInstanceState: Bundle?) { viewRef = WeakReference(view) //инициализация сохраненным состоянием (или пустым при отсутствии) model = CounterModel( savedInstanceState?.getInt("counter"), description = savedInstanceState?.getString("description") ?.let { DescriptionResult.Success(it) }) //обновление view - значение счетчика model.counter?.let { viewRef.get()?.updateCounter(it) //начальное значение } model.description?.let { viewRef.get()?.updateDescription(it) } if (model.description == null) { //загрузка при отсутствии сохраненного успешного результата CoroutineScope(coroutineDispatcher).launch { idling.increment() updateDescription(DescriptionResult.Loading()) delay(2000) updateDescription(DescriptionResult.Success(descriptionRepository.getDescription())) idling.decrement() } } } override fun saveState(bundle: Bundle) { //сохранение состояния (счетчик + успешный результат) model.counter?.let { bundle.putInt("counter", it) } val description = model.description if (description is DescriptionResult.Success) { bundle.putString("description", description.text) } } override fun increment() { //обновление счетчика (с учетом null-safety для model.counter) model.counter = model.counter?.inc() ?: 1 viewRef.get()?.updateCounter(model.counter!!) } }
Также расширим наш тест и убедимся, что текстовое поле с сохраненными данными из внешнего источника сохраняется при повороте экрана:
val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) device.setOrientationLeft() counter.hasText("Counter: 2") increaseButton.click() counter.hasText("Counter: 3") description.hasText("Data from test")
Можно расширить сценарии проверки дополнительными действиями над устройством, которые могут влиять на жизненный цикл Activity (например, сворачивание приложения), что может быть сделано также с использованием UIAutomator. Последняя линия проверок — автоматическое интеграционное тестирование приложения, как бы его видел реальный пользователь (E2E-тест). Здесь мы будем использовать UIAutomator (но можно было бы написать более универсальный тест на Appium, который ретранслирует запросы в команды UiAutomator2). Для успешного запуска теста нужно будет вернуть в build.gradle
значение testInstrumentationRunner
в "androidx.test.runner.AndroidJUnitRunner"
, поскольку здесь мы не взаимодействуем с зависимостями приложения.
class TestAutomator { lateinit var device: UiDevice //объект устройства UiAutomator2 lateinit var packageName: String//пакет приложения (будет получен из gradle) @Before fun setup() { packageName = BuildConfig.APPLICATION_ID device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) //ждем запуска Launcher-процесса (для передачи интента) val launcherPage = device.launcherPackageName device.wait(Until.hasObject(By.pkg(launcherPage).depth(0)), 5000L) //создаем контекст (для доступа к сервисам) и запускаем наше приложение val context = ApplicationProvider.getApplicationContext<Context>() val launchIntent = context.packageManager.getLaunchIntentForPackage(packageName)?.apply { addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK) //каждый запуск сбрасывает предыдущее состояние } context.startActivity(launchIntent) device.wait(Until.hasObject(By.pkg(packageName).depth(0)), 5000L) } @Test fun testCounterE2E() { //надпись со счетчиком val counter = device.findObject(By.res(packageName, "counter")) assertEquals("Click below for increment", counter.text) //кнопка увеличения счетчика val button = device.findObject(By.res(packageName, "increase_button")) assertEquals("+", button.text) //текст с данными из внешней системы val description = device.findObject(By.res(packageName, "description")) //при запуске там индикатор загрузки assertEquals("Loading", description.text) //ждем 2 секунды (до загрузки данных) Thread.sleep(2000) //проверяем появление строки из внешней системы assertEquals("Text from external data source", description.text) //проверяем работу счетчика нажатий button.click() assertEquals("Counter: 1", counter.text) button.click() assertEquals("Counter: 2", counter.text) //проверяем сохранение состояния и корректность работы после поворота экрана device.setOrientationLeft() //ссылка на объекты в UiAutomator2 устаревают при пересоздании/изменении Activity, ищем заново val counter2 = device.findObject(By.res(packageName, "counter")) val button2 = device.findObject(By.res(packageName, "increase_button")) val description2 = device.findObject(By.res(packageName, "description")) assertEquals("Counter: 2", counter2.text) button2.click() assertEquals("Counter: 3", counter2.text) assertEquals("Text from external data source", description2.text) } }
Итак, мы подготовили приложение на архитектуре MVP к тестированию и подготовили тесты на всех уровнях (от юнит-тестирование логики презентера до E2E-теста приложения в целом). Рассмотренные выше подходы к проектированию и тестированию приложений на архитектуре MVP могут быть масштабированы на более крупные приложения, принцип выделения контракта, инъекции диспетчеров и подмены объектов через Hilt (или mockk) останется неизменным.
Теперь мы можем перейти к более современной (и рекомендуемой Google для разработки без использования Jetpack Compose) архитектуре Model-View-ViewModel (MVVM)
MVVM
Наиболее важным отличием MVVM от MVP является использование вместо презентера класса для сохранения состояния с более длительным временем жизни, чем у Activity. Класс-наследник от ViewModel создается при первом отображении Activity, сохраняется при программных пересозданиях (например, при повороте экрана) и это позволяет избежать необходимости сохранения и восстановления промежуточного состояния через Bundle. Второе важное отличие — во ViewModel реализуется шаблон Observable и View сам подписывается на изменения значений и реагирует соответствующими корректировками в объектах интерфейса. Мы последовательно выполним изменения и доработки в коде для использования архитектуры MVVM и соответственно изменим разработанные тесты. Единственный тест, который не потребует изменений — E2E, поскольку с точки зрения пользователя функциональность системы не изменится.
Добавим зависимости для поддержки LiveData и дополнений для удобства разработки, создающий функции расширения и делегаты для получения экземпляра ViewModel:
implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.4.1" implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.1" implementation "androidx.lifecycle:lifecycle-common-java8:2.4.1" implementation "androidx.fragment:fragment-ktx:1.4.1" testImplementation "androidx.arch.core:core-testing:2.1.0" androidTestImplementation "androidx.arch.core:core-testing:2.1.0"
Модель данных представляется через объекты LiveData, на которые мы подписываемся при создании Activity (или фрагмента):
import androidx.lifecycle.* import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.delay import javax.inject.Inject @HiltViewModel class CounterViewModel @Inject constructor(private val descriptionRepository: DescriptionRepository): ViewModel() { private val _counter = MutableLiveData<Int?>(null) //счетчик fun getCounter(): LiveData<Int?> = _counter //доступ только на чтение val description = liveData<DescriptionResult?> { //это мы потом перепишем, сейчас будет сложно тестировать (поскольку здесь LiveDataScope) emit(DescriptionResult.Loading()) delay(2000) emit(DescriptionResult.Success(descriptionRepository.getDescription())) } fun increment() { _counter.value = (_counter.value?.inc() ?: 1) } }
@AndroidEntryPoint class CounterActivity: AppCompatActivity() { private val viewModel: CounterViewModel by viewModels() private fun observe() { viewModel.getCounter().observe(this) { findViewById<TextView>(R.id.counter).text = it?.let { counter -> "Counter: $counter" } ?: "Click below for increment" } viewModel.description.observe(this) { if (it != null) { val text = when (it) { is DescriptionResult.Error -> "Error is occured" is DescriptionResult.Loading -> "Loading" is DescriptionResult.Success -> it.text } findViewById<TextView>(R.id.description).text = text } } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) observe() findViewById<Button>(R.id.increase_button).setOnClickListener { viewModel.increment() } } }
Важно отметить, что для успешной инъекции DescriptionRepository нужно изменить компонент с ActivityComponent на ActivityRetainedComponent (он включает в себя доступность и из Activity из ViewModel).
@InstallIn(ActivityRetainedComponent::class) @Module abstract class RepositoryModule { @Binds @ActivityRetainedScoped abstract fun bindDescription(impl: DescriptionRepository): IDescriptionRepository }
E2E-тест заработает сразу и без изменений. А вот с инструментальными и unit-тестами нужно будет вносить корректировки.
Unit-тестирование ViewModel реализуется относительно просто и прямолинейно — мы можем создать ViewModel-объект непосредственно в тесте, подготовить тестовую реализацию репозитория (в виде тестового класса или мока) и проверить изменение значения LiveData при вызове методов ViewModel. Для корректного функционирования метод ViewModel в юнит-тест должно быть добавлено правило InstantTaskExecutorRule из androidx.arch.core:core-testing:
class CounterViewModelTest { @get:Rule var rule: TestRule = InstantTaskExecutorRule() lateinit var repository: IDescriptionRepository lateinit var viewModel: CounterViewModel @Before fun setup() { repository = mockk() viewModel = CounterViewModel(repository) } @Test fun checkIncrement() { viewModel.increment() assertEquals(1, viewModel.getCounter().value) viewModel.increment() assertEquals(2, viewModel.getCounter().value) } }
Этим способом можно проверить корректность реализации бизнес-логики, которая изменяет MutableLiveData (увеличение счетчика), но при попытке проверить description тест получит значение null, поскольку корутина с builder-функцией liveData не будет запущена. Изменим ViewModel и выделим отдельную функцию для загрузки данных и сразу сделаем ее корутиной (чтобы иметь возможность запускать ее в TestScope для управления виртуальным таймером):
@HiltViewModel class CounterViewModel @Inject constructor(private val descriptionRepository: IDescriptionRepository): ViewModel() { private val _counter = MutableLiveData<Int?>(null) //счетчик fun getCounter(): LiveData<Int?> = _counter //доступ только на чтение private val _description = MutableLiveData<DescriptionResult?>(null) fun getDescription(): LiveData<DescriptionResult?> = _description fun increment() { _counter.value = (_counter.value?.inc() ?: 1) } suspend fun loadData() { _description.postValue(DescriptionResult.Loading()) delay(2000) _description.postValue(DescriptionResult.Success(descriptionRepository.getDescription())) } }
Здесь для изменение MutableLiveData используется метод postValue, который может быть вызван не в основном потоке выполнения (из приложения эта корутина будет вызываться в Dispatchers.IO, из теста — в StandardTestDispatcher). Перепишем тест, используя функции планировщика, связанного с тестовым диспетчером (создается по умолчанию при запуске теста через launcher-функцию runTest):
class CounterViewModelTest { @get:Rule var rule: TestRule = InstantTaskExecutorRule() lateinit var repository: IDescriptionRepository lateinit var viewModel: CounterViewModel @Before fun setup() { repository = mockk() viewModel = CounterViewModel(repository) } @Test fun checkIncrement() { assertNull(viewModel.getCounter().value) viewModel.increment() assertEquals(1, viewModel.getCounter().value) viewModel.increment() assertEquals(2, viewModel.getCounter().value) } @Test fun checkIncrement() = runTest { coEvery { repository.getDescription() } returns "Test data from mock" //загружаем данные (с использованием тестового диспетчера) launch { viewModel.loadData() } testScheduler.apply { //выполняем ожидающие задачи (от запуска loadData до выполнения delay, передающей управление) runCurrent() assert(viewModel.getDescription().value is DescriptionResult.Loading) //пропускаем 2 секунды на виртуальном таймере и запускаем задачи advanceTimeBy(2000) runCurrent() //здесь мы уже получили данные val description = viewModel.getDescription().value assert(description is DescriptionResult.Success) assertEquals("Test data from mock", (description as DescriptionResult.Success).text) } } }
Метод coEvery в библиотеке mockk используется для описание результата вызова корутины. В действительности такую реализацию лучше не делать и ViewModel должна публиковать только обычные функции (не корутины) и уже внутри себя использовать подходящий диспетчер для вызова других корутин (например, выполнения задержки), для этого нам доступен встроенный Scope viewModelScope, который также прерывает выполнение корутины при завершении жизненного цикла ViewModel. Метод loadData может выглядеть следующим образом:
fun loadData() { viewModelScope.launch { _description.postValue(DescriptionResult.Loading()) delay(2000) _description.postValue(DescriptionResult.Success(descriptionRepository.getDescription())) } }
Но такое изменение сразу вызывает несколько вопросов:
-
где вызывать метод loadData()? Запускать его из onCreate не очень рационально, поскольку перезагрузка данных будет происходить на каждом повороте экрана. Можно его добавить в конструктор (или init-блок) во ViewModel, но тогда будет непонятно как тестировать с использованием виртуального таймера
-
будет ли работать вызов такого метода в unit-тесте? (поскольку viewModelScope взаимодействует с Main thread, а его в тесте не сформировано).
Со второй проблемой можно справиться через определение MainThread в тесте через вызов Dispatchers.setMain, при этом можно использовать уже знакомый нам StandardTestDispatcher, который будет использовать общий планировщик с нашим тестом:
@Test fun checkData() = runTest { coEvery { repository.getDescription() } returns "Test data from mock" val dispatcher = StandardTestDispatcher(testScheduler) Dispatchers.setMain(dispatcher) viewModel.loadData() testScheduler.apply { runCurrent() assert(viewModel.getDescription().value is DescriptionResult.Loading) advanceTimeBy(2000) runCurrent() val description = viewModel.getDescription().value assert(description is DescriptionResult.Success) assertEquals("Test data from mock", (description as DescriptionResult.Success).text) } }
С Unit-тестами мы разобрались, теперь нужно переходить к инструментальным тестам. И здесь будет нужно решить первую проблему с loadData — в каком месте рационально ее вызывать, чтобы избежать перезагрузки данных при изменении ориентации экрана? Здесь можно использовать встроенные возможности делегатов Kotlin и использовать lazy-инициализацию поля _description. Загрузка данных будет вызвана косвенно при первом обращении к полю и будет выполнена однократно в течении времени жизни класса CounterViewModel:
@HiltViewModel class CounterViewModel @Inject constructor(private val descriptionRepository: IDescriptionRepository): ViewModel() { private val _counter = MutableLiveData<Int?>(null) //счетчик fun getCounter(): LiveData<Int?> = _counter //доступ только на чтение //используем ленивую инициализацию для загрузки данных однократно (при первом обращении) private val _description by lazy { val liveData = MutableLiveData<DescriptionResult?>(null) loadData(liveData) return@lazy liveData } fun getDescription(): LiveData<DescriptionResult?> = _description fun increment() { _counter.value = (_counter.value?.inc() ?: 1) } //здесь дополнительно принимаем объект MutableLiveData, поскольку _description еще не инициализирован fun loadData(liveData: MutableLiveData<DescriptionResult?>) { viewModelScope.launch { liveData.postValue(DescriptionResult.Loading()) delay(2000) liveData.postValue(DescriptionResult.Success(descriptionRepository.getDescription())) } } }
Для тестирования изменения состояния в инструментальном тесте пока будем использовать паузу через Thread.sleep, поскольку пока нет очевидного способа управлять виртуальным временем во viewModelScope.
@HiltAndroidTest @RunWith(AndroidJUnit4::class) class CounterTest { @get:Rule val rule = ActivityScenarioRule(CounterActivity::class.java) @get:Rule var hiltRule = HiltAndroidRule(this) val counterScreen = CounterActivityScreen() @Before fun init() { hiltRule.inject() } @Test fun checkCounter() { counterScreen { description.hasText("Loading") Thread.sleep(2000)//пока так description.hasText("Data from test") counter.hasText("Click below for increment") increaseButton.click() counter.hasText("Counter: 1") increaseButton.click() counter.hasText("Counter: 2") val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) device.setOrientationLeft() counter.hasText("Counter: 2") increaseButton.click() counter.hasText("Counter: 3") description.hasText("Data from test") } } }
По результатам теста можно увидеть, что Activity со ViewModel успешно сохраняет свое состояние при повороте устройства. Но вариант с Thread.sleep не выглядит очень хорошо, поскольку снижается скорость тестирования, и хотелось бы использовать виртуальное время. Для этого можно использовать два возможных решения:
-
viewModelScope внутри реализуется через Dispatchers.Main.immediate и можно подменить его (обязательно это сделать до вызова viewModelScope, т.е. до onCreate, поскольку там происходит подписка на LiveData и lazy-инициализация с загрузкой данных), технически это можно сделать подменой через Dispatchers.setMain на тестовый диспетчер (например, StandartTestDispatcher)
-
можно подменить scope, который возвращается через viewModelScope, но здесь придется использовать рефлексию для доступа к приватной Map внутри ViewModel
Рассмотрим второй способ более подробно. Добавим метод в реализацию ViewModel (или в общий родительский класс для всех наших ViewModel):
fun overrideScope(scope: CoroutineScope) { val tags = ViewModel::class.java.getDeclaredField("mBagOfTags") tags.isAccessible = true val tagsValue = tags.get(this) as HashMap<String, Any> tagsValue["androidx.lifecycle.ViewModelCoroutineScope.JOB_KEY"] = scope as Any }
Этот метод должен быть вызван до первой подписки на LiveData, для этого расширим onCreate и добавим новую инъекцию зависимости для CoroutineDispatcher (он будет использоваться только для запуска через инструментальный тест и игнорироваться для обычного выполнения приложения).
@Qualifier annotation class CoroutineDispatcherOverride class CounterActivity : AppCompatActivity() { @Inject @CoroutineDispatcherOverride lateinit var overrideDispatcher: CoroutineDispatcher //другие свойства и методы override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) //переопределяем диспетчер на тестовый, если необходимо if (overrideDispatcher != Dispatchers.Main) { viewModel.overrideScope(CoroutineScope(overrideDispatcher)) } observe() findViewById<Button>(R.id.increase_button).setOnClickListener { viewModel.increment() } } } @InstallIn(SingletonComponent::class) @Module object ScopeModule { @Provides @Singleton @CoroutineDispatcherOverride fun provideDispatcher(): CoroutineDispatcher = Dispatchers.Main }
И в тесте добавим переопределение модуля с использованием StandardTestDispatcher и также инжектируем его в класс теста для манипуляции с виртуальным временем:
@TestInstallIn(components = [SingletonComponent::class], replaces = [ScopeModule::class]) @Module object ScopeTestModule { @Provides @CoroutineDispatcherOverride @Singleton fun provideDispatcher():CoroutineDispatcher = StandardTestDispatcher() }
Сам код теста будет очень похож на вариант для MVP-архитектуры:
@HiltAndroidTest @RunWith(AndroidJUnit4::class) class CounterTest @Inject constructor() { @get:Rule val rule = ActivityScenarioRule(CounterActivity::class.java) @get:Rule var hiltRule = HiltAndroidRule(this) @Inject @CoroutineDispatcherOverride lateinit var dispatcher: CoroutineDispatcher val counterScreen = CounterActivityScreen() @Before fun init() { hiltRule.inject() } @Test fun checkCounter() { val scheduler = (dispatcher as TestDispatcher).scheduler counterScreen { scheduler.run { runCurrent() description.hasText("Loading") advanceTimeBy(2000) runCurrent() description.hasText("Data from test") } counter.hasText("Click below for increment") increaseButton.click() counter.hasText("Counter: 1") increaseButton.click() counter.hasText("Counter: 2") val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) device.setOrientationLeft() counter.hasText("Counter: 2") increaseButton.click() counter.hasText("Counter: 3") description.hasText("Data from test") } } }
Исходные тексты приложения на MVVM с тестами и комментариями доступны на https://github.com/dzolotov/qa-kotlin-mvvm.
Мы с вами рассмотрели две архитектуры мобильных приложений и последовательно доработали код приложения для возможности реализации Unit-тестов, инструментальных и E2E-тестов. Во второй части статьи мы поговорим об архитектуре MVI и ее тестировании, а также о том, как готовить приложения и создавать тесты для реактивных интерфейсов на Jetpack Compose.
Ну и по традиции приглашаю всех заинтересованных на бесплатный урок курса Kotlin QA Engineer, который уже 14 июня проведет мой коллега — Дмитрий Синицын.
ссылка на оригинал статьи https://habr.com/ru/company/otus/blog/669688/
Добавить комментарий