Как сделать Android-приложение тестируемым? Часть 1 — MVP и MVVM

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Хорошее мобильное приложение должно быть не только создано с использованием современных архитектурных шаблонов, но и быть подготовленным к реализации автоматических тестов. Мы рассмотрим с вами на практическом примере как создается приложение с учетом возможностей тестирования, чем отличаются архитектуры и соответствующие подходы к созданию тестов и как создать полный спектр тестов от 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/


Интересные статьи

Интересные статьи

Библиотека Pygame / Часть 2. Работа со спрайтами
Привет, Хабр!В этой статье мы обсудим генерацию псевдо-случайных чисел участниками, которые не доверяют друг другу. Как мы увидим ниже, реализовать “почти” хороший генератор достаточн...
Добрый день. Наверное, все смотрели фильмы про железного человека и хотели себе голосового помощника, похожего на Джарвиса. В этом посте я расскажу, как сделать такого ассистента с ну...
Недавно исследовательская компания «Javelin Strategy & Research» опубликовала отчёт «The State of Strong Authentication 2019». Его создатели собрали информацию о том какие способы аутенти...
Мы публикуем видео с прошедшего мероприятия. Приятного просмотра.