Как стать автором
Обновить
737.63
OTUS
Цифровые навыки от ведущих экспертов

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

Время на прочтение27 мин
Количество просмотров4.3K

В первой части статьи мы последовательно рассмотрели шаги по созданию и преобразования приложения для Android, необходимыми для реализации тестов, начиная от Unit-тестирования и заканчивая E2E-тестами. Сегодня мы рассмотрим архитектурные подходы Model-View-Intent (MVI), создадим собственную реализацию MVI и на ее примере разберем особенности разработки и тестирования приложений на MVI и подготовимся к обсуждению разработки тестируемых реактивных интерфейсов на Jetpack Compose в следующей части статьи.

Напомню, что мы разрабатываем простое приложение-счетчик с имитацией извлечения данных из сети через репозиторий, который реализован с использованием корутин Kotlin. Рассмотренные здесь подходы применимы и к более сложным приложениям, но наша основная задача - разобраться с архитектурой приложения и взаимодействием компонентов, не отвлекая внимание на сложную бизнес-логику. Начнем наше исследование с архитектуры MVI и прежде обсудим ее основные понятия.

Если вспомнить ранее рассмотренные архитектуры, то может показаться, что MVVM уже решило все проблемы, View и ViewModel напрямую не связаны (есть только опосредованная подписка на объект LiveData) и View всегда отражает текущее состояние LiveData. Кроме того, мы могли протестировать бизнес-логику независимо от интерфейса (без необходимости создания View-моков, поскольку состояние может быть извлечено из теста после выполнения действия). Но все же у MVVM есть ряд недостатков:

  • изменение состояния выполняется в коде ViewModel, таким образом смешивается ответственность хранения состояния и его изменения;

  • крайне затруднительно реализовать one-shot-действия (например, отображение уведомления или какое-либо действие, которое должно выполняться в главном потоке или с использованием контекста), поскольку ViewModel не может это осуществить из-за отсутствия объекта контекста Activity, а в Activity/Fragment observe будет выполняться каждый раз при вызове onCreate (а обычно там и выполняется подписка) и уведомление будет возникать повторно при повороте экрана;

  • при наличии нескольких объектов LiveData необходимо подписываться на каждый из них и выполнять модификации интерфейса (а некоторые из них могут оказаться противоречивыми и финальное состояние будет зависеть от последовательности обработки подписок);

  • сложно переиспользовать код между различными ViewModel;

Наиболее разумным представляется решение, при котором состояние хранится отдельно от методов модификации (переходов между состояниями) и одновременно с этим реализуется решение (например, на основе StateFlow) для выполнения однократных действий. Эту концепцию в общем виде и реализует архитектура Model-View-Intent и мы попробуем смоделировать ее самостоятельно, а затем посмотрим на готовые реализации.

Прежде всего нужно отметить, что View в MVI должен реализовывать возможность изменения себя при изменении связанного с ним состояния. Здесь важный момент состоит в том, что нам нет необходимости подписываться на каждый объект LiveData, поскольку изменение состояния происходит транзакционно и мы всегда остаемся в согласованном состоянии. Нам нужен дополнительный метод, который выполнит модификацию атрибутов View для отражения текущего состояния на экране.

Вторая важная особенность - любые изменения состояния обрабатываются единообразно, для чего мы отправляем во внешний класс объект намерения (Intent), по которому может быть определен тип действия и его аргументы (если необходимо). Для определения Intent'ов хорошо подходят Sealed-классы в Kotlin и в нашем случае Intent будет соответствовать действию Increment.

Логика работы приложения на архитектуре MVI может быть представлена следующей диаграммой (здесь хорошо видно, что MVI-приложение реализует однонаправленный поток данных, Unidirectional Data Flow - UDF), что уменьшает связанность компонентов приложения и позволяет упростить интерфейсы.

Поток данных в MVI
Поток данных в MVI

При получении Intent состояние модели изменяется единой транзакцией в новое согласованное состояние и после этого View уведомляется о необходимости актуализации. Попробуем теперь реализовать концепцию в коде (очень неудачно с точки зрения тестируемости), а позже займемся рефакторингом. Поскольку нам необходимо избежать прямой зависимости View (в нашем случае MainActivity) от класса, отвечающего за изменения состояния, мы будем использовать в качестве посредника StateFlow - разновидность Flow-классов (во многом аналогично Observable в RxJava), которые могут сохранять только одно (последнее) состояние.

sealed class Intent {
    object Increment : Intent()
    object LoadingData : Intent()
    class DataIsLoaded(val data: String) : Intent()
    object DataError : Intent()
}

Здесь мы предусмотрели интенты, созданные по действиям пользователя и отправляемые в процессе загрузки данных из внешнего источника. Следующим действием определим структуру класса для хранения состояния приложения (или отдельного экрана). Обратите внимание, что мы используем val (неизменяемые поля), чтобы избежать перезаписи значений и всегда использовать генерацию нового объекта с модификацией части полей. В действительности это требование нельзя назвать строго обязательным, но во многих случаях это повышает понятность и предсказуемость кода обработчика интентов.

data class State(val counter:Int, val message:DescriptionResult?)

Дальше создадим класс, который будет хранить наше состояние и метод для преобразования. В действительности так лучше не делать, потому что мы снова связываем хранение и изменение в одном классе (как было во ViewModel) и в дальнейшем мы это изменим:

class Reducer {
    //текущее (начальное) состояние
    private var state = State(0, null)

    //внутренний StateFlow (в него можно отправлять изменение состояния)
    private var _stateFlow: MutableStateFlow<State> = MutableStateFlow(state)
    //внешний StateFlow (на него можно только подписываться, позволяет избежать отправку несогласованного состояния)
    val stateFlow: StateFlow<State> = _stateFlow.asStateFlow()

    //метод для обработки сообщения (Intent'а)
    suspend fun reduce(intent: Intent) {
        state = when (intent) {
            is Intent.Increment -> state.copy(counter = state.counter+1)
            is Intent.LoadingData -> state.copy(message = DescriptionResult.Loading)
            is Intent.DataIsLoaded -> state.copy(message = DescriptionResult.Success(intent.data))
            is Intent.DataError -> state.copy(message = DescriptionResult.Error)
        }
        _stateFlow.emit(state)
    }
}

Здесь нужно сделать некоторые пояснения. Поскольку используется sealed-класс, на уровне компилятора выполняется проверка, что все возможные виды интентов будут обработаны (поэтому здесь нет необходимости указывать else ветку). Так как наш объект состояния неизменяемый, транзакционное изменение (создание нового состояния) происходит через копирование текущего с изменением одного или нескольких полей. Новое состояние также отправляется в StateFlow и будет обработано в подписанных View (их может быть больше одного). С точки зрения готовности к тестированию в целом все неплохо, но нужно будет каким-то образом проверить значения, отправленные во Flow (посмотрим на примере библиотеки Turbine).

Следующим шагом добавим необходимые подписки и действия по загрузке данных. Пока сознательно разместим все в Activity и позже распределим по нескольким классам:

@AndroidEntryPoint
class CounterActivity : AppCompatActivity() {

    private val reducer = Reducer()

    private val coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.IO)

    @Inject
    lateinit var repository: IDescriptionRepository

    private fun loading() {
        coroutineScope.launch {
            reducer.reduce(Intent.LoadingData)
            delay(2000)
            reducer.reduce(Intent.DataIsLoaded(repository.getDescription()))
        }
    }

    private fun subscribe() {
        coroutineScope.launch {
            reducer.stateFlow.collect {
                //обновление view по новому состоянию
                findViewById<TextView>(R.id.counter).text = "Counter: ${it.counter}"
                val description = findViewById<TextView>(R.id.description)
                if (it.message!=null) {
                    description.text = when (it.message) {
                        is DescriptionResult.Loading -> "Loading data"
                        is DescriptionResult.Error -> "Error"
                        is DescriptionResult.Success -> it.message.text
                    }
                } else {
                    findViewById<TextView>(R.id.description).text = ""
                }
            }
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        subscribe()
        loading()
        findViewById<Button>(R.id.increase_button).setOnClickListener {
            coroutineScope.launch {
                reducer.reduce(Intent.Increment)
            }
        }
    }
}

Из кода можно увидеть, что любые изменения интерфейса возникают опосредованно через передачу Intent в Reducer. Вызванные им изменения StateFlow отслеживаются и отражаются в соответствующих View. Работать это будет (до первого поворота экрана, при котором Activity будет пересоздан), но тестировать это невозможно. Сначала решим вопрос с перезагрузкой данных при повороте экрана, - это мы уже умеем, используем связанный с Activity класс с более длительным жизненным циклом - ViewModel. Несмотря на то, что у нас будет использоваться архитектурный компонент ViewModel мы не перейдем к архитектуре MVVM, поскольку не будет хранить в нем состояние и не будем на него подписываться во View. Здесь нужно будет решить один важный вопрос - если с загрузкой данных и реакцией на нажатие все понятно (создаем Reducer во ViewModel и передаем туда соответствующие Intent'ы), то с обновление View по подписке все становится сложнее. Начнем с переноса методов для отправки Intent:

@HiltViewModel
class MainViewModel : ViewModel() {

    @Inject
    lateinit var repository: IDescriptionRepository

    private val reducer: Reducer = Reducer()
    
    init {
        loadingData()
    }

    fun increment() {
        viewModelScope.launch {
            reducer.reduce(Intent.Increment)
        }
    }

    private fun loadingData() {
        viewModelScope.launch {
            reducer.reduce(Intent.LoadingData)
            delay(2000)
            reducer.reduce(Intent.DataIsLoaded(repository.getDescription()))
        }
    }
}

Пока оставим подписку на обновления Flow в Activity:

@AndroidEntryPoint
class CounterActivity : AppCompatActivity() {

    private val reducer = Reducer()

    private val coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.IO)

    private val viewModel by viewModels<MainViewModel>()
    
    //здесь код метода subscribe
    
		override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        subscribe()
        findViewById<Button>(R.id.increase_button).setOnClickListener {
            viewModel.increment()
        }
    }
}

Теперь нужно выделить подписку во viewModel, но оставить за View ответственность за обновление содержания при изменении состояния, для этого создадим метод для связывания ViewModel и Activity (лучше всего это делать в OnCreate или в OnViewCreated для фрагмента) и интерфейс для определения метода render, отвечающего за актуализацию View:

interface MainView {
    fun render(state: State)
}

@AndroidEntryPoint
class CounterActivity : MainView, AppCompatActivity() {

    private val viewModel by viewModels<MainViewModel>()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        viewModel.bindView(this)
        findViewById<Button>(R.id.increase_button).setOnClickListener {
            viewModel.increment()
        }
    }

    override fun render(state: State) {
        //обновление view по новому состоянию
        findViewById<TextView>(R.id.counter).text = "Counter: ${state.counter}"
        val description = findViewById<TextView>(R.id.description)
        if (state.message!=null) {
            description.text = when (state.message) {
                is DescriptionResult.Loading -> "Loading"
                is DescriptionResult.Error -> "Error"
                is DescriptionResult.Success -> state.message.text
            }
        } else {
            findViewById<TextView>(R.id.description).text = ""
        }
    }
}

@HiltViewModel
class MainViewModel : ViewModel() {
  //здесь код существующих полей и методов
  fun bindView(view: MainView) {
    viewModelScope.launch {
      reducer.stateFlow.collect {
        view.render(it)
      }
    }
  }
}

Теперь давайте добавим еще одно действие (побочный эффект), которое должно возникать однократно на экране при значении счетчика = 3. Здесь добавить действие в render не получится, поскольку он будет вызываться каждый раз при изменении состояния или пересоздании Activity/Fragment (например, при повороте экрана). Для отслеживания побочных эффектов будем использовать шаблон, предложенный Google для ViewModel events и адаптируем его для нашей архитектуры через использование буферизированного канала и подпишемся на события жизненного цикла:

Также для упрощения тестирования в будущем, выделим отдельный класс для объединения побочных эффектов, состояния и класса модификации состояния и назовем его Store (читатели, знающие Redux, в этом месте завершили ассоциативную цепочку и обнаружили, что MVI подозрительно похож на идеи Redux), а также исключим хранение состояния отдельно от StateFlow (из него всегда можно получить наиболее актуальное значение в свойстве value)

class Reducer(val store: Store) {
    fun reduce(state: State, intent: Intent): State = when (intent) {
        is Intent.Increment -> state.copy(counter = state.counter + 1)
        is Intent.LoadingData -> state.copy(message = DescriptionResult.Loading)
        is Intent.DataIsLoaded -> state.copy(message = DescriptionResult.Success(intent.data))
        is Intent.DataError -> state.copy(message = DescriptionResult.Error)
    }
}

sealed class SideEffect {
    object ShowToast : SideEffect()
}

class Store {
    var reducer = Reducer(this)

    //сюда будем отправлять одноразовые действия
    private val _sideEffectsChannel = Channel<SideEffect>(Channel.BUFFERED)
    val sideEffectsFlow = _sideEffectsChannel.receiveAsFlow()

    //здесь будет сохраняться новое состояние
    private var _stateFlow: MutableStateFlow<State> = MutableStateFlow(State(0, null))
    val stateFlow: StateFlow<State> = _stateFlow.asStateFlow()

    //отправить эффект
    suspend fun effect(sideEffect: SideEffect) {
        _sideEffectsChannel.send(sideEffect)
    }

    //обработать Intent и сохранить новое состояние
    suspend fun dispatch(intent: Intent) {
        _stateFlow.emit(reducer.reduce(_stateFlow.value, intent))
    }
}

Подписку и реакцию на побочные эффекты реализует во ViewModel и подпишемся на них в OnCreate:

fun bindEffects(lifecycleOwner: LifecycleOwner, context: Context) {
    store.sideEffectsFlow.observeOnLifecycle(lifecycleOwner) {
        when (it) {
            is SideEffect.ShowToast -> Toast.makeText(context, "Counter = 3", Snackbar.LENGTH_LONG).show()
        }
    }
}

Регистрация в OnCreate: viewModel.bindEffects(this, applicationContext). Для вызова эффекта добавим соответствующий код в Reducer:

class Reducer(private val store: Store) {
    suspend fun reduce(state: State, intent: Intent): State = when (intent) {
        is Intent.Increment -> {
            if (state.counter + 1 == 3) {
                store.effect(SideEffect.ShowToast)
            }
            state.copy(counter = state.counter + 1)
        }
        is Intent.LoadingData -> state.copy(message = DescriptionResult.Loading)
        is Intent.DataIsLoaded -> state.copy(message = DescriptionResult.Success(intent.data))
        is Intent.DataError -> state.copy(message = DescriptionResult.Error)
    }
}

До того, как мы перейдем к тестированию, добавим еще одну сущность для расширения Intent'ов и описания сложной бизнес-логики (например, загрузки данных) и избавимся от создания объектов внутри Store с заменой их на инъекции через Hilt (чтобы избежать циклической зависимости, зависимость Store от Reducer и Middleware объявим через Provider<T>):

interface IStore {
    val sideEffectsFlow: Flow<SideEffect>
    val stateFlow: StateFlow<State>
    suspend fun effect(sideEffect: SideEffect)
    suspend fun dispatch(intent: Intent)
}

class Store @Inject constructor(
    private val reducer: Provider<IReducer>,
    private val middleware: Provider<IMiddleware>
) : IStore {
  //другие свойства и методы Store
  
    override suspend fun dispatch(intent: Intent) {
        middleware.get().process(intent)
        _stateFlow.emit(reducer.get().reduce(stateFlow.value, intent))
    }
}

interface IMiddleware {
    suspend fun process(intent: Intent)
}

class Middleware @Inject constructor(
    private val store: IStore,
    private val repository: IDescriptionRepository
) : IMiddleware {

    override suspend fun process(intent: Intent) {
        when (intent) {
            is Intent.Load -> {
                store.dispatch(Intent.LoadingData)
                delay(2000)
                store.dispatch(Intent.DataIsLoaded(repository.getDescription()))
            }
            else -> {}
        }
    }
}

interface IReducer {
    suspend fun reduce(state: State, intent: Intent): State
}

class Reducer @Inject constructor(private val store: IStore) : IReducer {
   override suspend fun reduce(state: State, intent: Intent): State {
        return when (intent) {
            is Intent.Increment -> {
                if (state.counter + 1 == 3) {
                    store.effect(SideEffect.ShowToast)
                }
                state.copy(counter = state.counter + 1)
            }
            is Intent.LoadingData -> state.copy(message = DescriptionResult.Loading)
            is Intent.DataIsLoaded -> state.copy(message = DescriptionResult.Success(intent.data))
            is Intent.DataError -> state.copy(message = DescriptionResult.Error)
            else -> state			//если неизвестная команда - не изменять состояние
        }
    }
}

Мы закончили реализацию MVI-архитектуры и теперь надо адаптировать приложения для тестов. Прежде всего можно увидеть, что у нас существует несколько классов, которые могут быть протестированы независимо и два Flow, через которые можно отслеживать изменения состояния и возникающие события. Начнем с тестирования логики изменений состояния и событий (классы Reducer, Middleware и Store). Стратегий тестирования здесь несколько:

  • создать экземпляр Store и тестировать изменение stateFlow или sideEffectsFlow при вызове действий;

  • реализовать мок-объект для Store и подменить операции с Flow, чтобы перехватить сохранение изменения состояния;

  • протестировать поведение ViewModel (инициализация, реакция на вызовы действий)

  • проверить поведение элементов управления в Activity

Первые два теста могут быть созданы как Unit-тесты, третий можно сделать и как Unit-тест (с подменой MainThread) и как инструментальный, последний - только как инструментальный. Посмотрим последовательно все возможные варианты реализации теста:

В Unit-тестах нам недоступна инъекция зависимостей через Hilt, но мы по прежнему можем сделать Factory-метод в Provider и предоставить настоящие реализации Reducer и Middleware в Store (нужно будет только создать мок-объект для репозитория, чтобы подменить результат из внешнего источника). Для тестирования изменения состояния никаких особых хитростей не требуется, можно непосредственно получить значение из stateFlow.value), но для проверки потока событий (в частности SideEffect для отображения Toast) нам понадобится подключить дополнительную библиотеку Turbine, которая позволяет проверять последовательность возникновения значений в Flow без необходимости ручной обработки в collect.

package tech.dzolotov.counterappmvi

import app.cash.turbine.test
import io.mockk.mockk
import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.assertNull
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import javax.inject.Provider

class CounterStoreTest {
    private lateinit var repository: IDescriptionRepository

    @Before
    fun setup() {
        repository = mockk()
        coEvery { repository.getDescription() } returns "Message from test"
    }

    @Test
    fun checkReducer() = runTest {
        lateinit var store: IStore
        //здесь мы можем ссылаться на lateinit переменную, потому что при первом обращении к reducer/
        //middleware значение в store уже будет инициализировано
        val reducer = Provider<IReducer> { Reducer(store) }
        val middleware = Provider<IMiddleware> { Middleware(store, repository) }
        store = Store(reducer, middleware)
        val stateFlow = store.stateFlow

        assertEquals(0, stateFlow.value.counter)
        assertNull(stateFlow.value.message)

        store.dispatch(Intent.Increment)
        assertEquals(1, stateFlow.value.counter)

        store.dispatch(Intent.Increment)
        assertEquals(2, stateFlow.value.counter)

        store.dispatch(Intent.Increment)
        assertEquals(3, stateFlow.value.counter)
        store.sideEffectsFlow.test {
            assertEquals(SideEffect.ShowToast, awaitItem())
        }
        
        store.dispatch(Intent.Load)
        assertEquals(DescriptionResult.Success("Message from test"), stateFlow.value.message)
    }
}

Но некоторые сложности возникнут с тестированием загрузки данных, поскольку после выполнения dispatch, который отправляется в Middleware и преобразуется в несколько действий (Intent: Loading / задержка 2 секунды / Intent: DataIsLoaded), но в контексте теста задержка пропускается и мы видим только финальный результат, а хотелось бы проверить и промежуточное состояние. Самое время вспомнить про виртуальные часы в TestDispatcher и выполним обработку Intent'а во вложенном контексте и проверим промежуточные состояния обработки:

  @OptIn(ExperimentalCoroutinesApi::class)
  @Test
  fun checkReducer() = runTest {
      //...проверка счетчика (см.выше)...
	  	launch {
  	  	store.dispatch(Intent.Load)
		  }
		  runCurrent()
		  assertEquals(DescriptionResult.Loading, stateFlow.value.message)
		  advanceTimeBy(2000)
		  runCurrent()
		  assertEquals(DescriptionResult.Success("Message from test"), stateFlow.value.message)
  }

Альтернативный вариант - создание мок-объекта для Store:

    @OptIn(ExperimentalCoroutinesApi::class)
    @Test
    fun checkReducerWithStateMock() = runTest {
        lateinit var store: IStore
        val reducer = Provider<IReducer> { Reducer(store) }
        val middleware = Provider<IMiddleware> { Middleware(store, repository) }
        store = spyk(Store(reducer, middleware))

        coEvery { store.effect(any()) } returns Unit
        coEvery { store.dispatch(any()) } coAnswers {
            callOriginal()
        }

        store.dispatch(Intent.Increment)
        store.dispatch(Intent.Increment)
        store.dispatch(Intent.Increment)
        runCurrent()
        coVerify(exactly = 1) { store.effect(SideEffect.ShowToast) }
    }

Но если мы попытаемся здесь добавить проверку на последовательность Intent, которую сформирует Intent.Load мы увидим, что из оригинального метода вызывается Middleware, который возвращает нас в вызов этой же функции и это срабатывает некорректно. Обойти эту проблему можно через добавление Flow для интентов и подписку на него в Store, тогда можно будет тестировать последовательность событий во Flow:

interface IStore {
    val sideEffectsFlow: Flow<SideEffect>
    val stateFlow: StateFlow<State>
    val intentsFlow: SharedFlow<Intent>
    suspend fun effect(sideEffect: SideEffect)
    suspend fun dispatch(intent: Intent)
    suspend fun subscribe()
}

class Store @Inject constructor(
    private val reducer: Provider<IReducer>,
    private val middleware: Provider<IMiddleware>
) : IStore {

    val backgroundScope = CoroutineScope(Dispatchers.Default)

    //будем делать буферизацию, чтобы накопить интенты и проверить их одним блоком
    private val _intentsFlow = MutableSharedFlow<Intent>(replay = 8)
    override val intentsFlow = _intentsFlow.asSharedFlow()

    private val _sideEffectsChannel = Channel<SideEffect>(Channel.BUFFERED)
    override val sideEffectsFlow = _sideEffectsChannel.receiveAsFlow()

    private var _stateFlow: MutableStateFlow<State> = MutableStateFlow(State(0, null))
    override val stateFlow: StateFlow<State> = _stateFlow.asStateFlow()

    //при запуске делаем подписку к flow и реагируем на интенты
    override suspend fun subscribe() {
        backgroundScope.launch {
            intentsFlow.collect { intent ->
                middleware.get().process(intent)
                _stateFlow.emit(reducer.get().reduce(stateFlow.value, intent))
            }
        }
    }

    override suspend fun effect(sideEffect: SideEffect) {
        _sideEffectsChannel.send(sideEffect)
    }

    override suspend fun dispatch(intent: Intent) {
        _intentsFlow.emit(intent)
    }
}

Подписка выполняется в ViewModel:

@HiltViewModel
class MainViewModel @Inject constructor(private val store: IStore) : ViewModel() {
    init {
        viewModelScope.launch {
            store.subscribe()
        }
        loadingData()
    }
    //...свойства и методы...
}

Важно отметить, что следствием такой модификации будет отложенное выполнение dispatch и, как следствие, ранее разработанный тест для изменений состояния перестанет работать. Чтобы его исправить, добавим механизмы синхронизации к Store, для этого реализуем класс от IdlingResource (из Espresso) и сделаем дополнительный метод-декоратор для ожидания завершения выполнения действия:

interface IStore {
    val sideEffectsFlow: Flow<SideEffect>
    val stateFlow: StateFlow<State>
    val intentsFlow: SharedFlow<Intent>
    suspend fun effect(sideEffect: SideEffect)
    suspend fun dispatch(intent: Intent)
    fun subscribe()
    val idlingResource: CoroutineIdlingResource
    suspend fun wait(action: suspend IStore.() -> Unit)
}

class CoroutineIdlingResource(val resourceName: String) : IdlingResource {

    var counter = 0
    var completableDeferred: CompletableDeferred<Unit> = CompletableDeferred()

    var callback: IdlingResource.ResourceCallback? = null

    override fun getName() = resourceName

    override fun isIdleNow() = counter == 0

    override fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback?) {
        this.callback = callback
    }

    fun reset() {
        counter = 0
        completableDeferred = CompletableDeferred()
    }

    fun increment() {
        counter++
    }

    fun decrement() {
        counter--
        if (counter == 0) {
            completableDeferred.complete(Unit)
            callback?.onTransitionToIdle()
        }
    }

    suspend fun wait() = completableDeferred.await()
}


class Store @Inject constructor(
    private val reducer: Provider<IReducer>,
    private val middleware: Provider<IMiddleware>
) : IStore {

    override val idlingResource: CoroutineIdlingResource = CoroutineIdlingResource("store")
    override suspend fun wait(action: IStore.() -> Unit) {
        idlingResource.reset()
        action()
        idlingResource.wait()
    }

    //здесь определение свойств класса
    override fun subscribe() {
        backgroundScope.launch {
            intentsFlow.collect { intent ->
                _stateFlow.emit(reducer.get().reduce(stateFlow.value, intent))
                idlingResource.decrement()
            }
        }
    }
    //...другие методы Store...
}

И переработаем тест, обернув вызовы dispatch в wait:

    @OptIn(ExperimentalCoroutinesApi::class)
    @Test
    fun checkReducer() = runTest {
        lateinit var store: IStore
        //здесь мы можем ссылаться на lateinit переменную, потому что при первом обращении к reducer/
        //middleware значение в store уже будет
        val reducer = Provider<IReducer> { Reducer(store) }
        val middleware = Provider<IMiddleware> { Middleware(store, repository) }
        store = Store(reducer, middleware)
        store.subscribe()
        val stateFlow = store.stateFlow

        assertNull(stateFlow.value.counter)
        assertNull(stateFlow.value.message)

        store.wait {
            dispatch(Intent.Increment)
        }
        assertEquals(1, stateFlow.value.counter)

        store.wait {
            dispatch(Intent.Increment)
        }
        assertEquals(2, stateFlow.value.counter)

        store.wait {
            dispatch(Intent.Increment)
        }
        assertEquals(3, stateFlow.value.counter)

        store.sideEffectsFlow.test {
            assertEquals(SideEffect.ShowToast, awaitItem())
        }
        launch {
            store.wait {
                store.dispatch(Intent.Load)
            }
        }
        runCurrent()

        assertEquals(DescriptionResult.Loading, stateFlow.value.message)
        advanceTimeBy(2000)
        store.wait {
            runCurrent()
        }

        assertEquals(DescriptionResult.Success("Message from test"), stateFlow.value.message)
    }

И теперь можно проверить последовательность Intent'ов, которые сформированы в результате развертывания сложных действий в Middleware:

    @OptIn(ExperimentalCoroutinesApi::class)
    @Test
    fun checkReducerWithStateMock() = runTest {
        lateinit var store: IStore
        val reducer = Provider<IReducer> { Reducer(store) }
        val middleware = Provider<IMiddleware> { Middleware(store, repository) }
        store = spyk(Store(reducer, middleware))
        store.subscribe()

        coEvery { store.effect(any()) } returns Unit

        store.dispatch(Intent.Increment)
        store.dispatch(Intent.Increment)
        store.dispatch(Intent.Increment)
        runCurrent()
        coVerify(exactly = 1) { store.effect(SideEffect.ShowToast) }

        //запускаем действие в контексте StandardTestDispatcher
        launch {
            store.dispatch(Intent.Load)
        }
        //выполняем действия до delay
        runCurrent()
        store.intentsFlow.test {
            //проверяем последовательность событий
            //сначала было 3 инкремента (можно их пропустить)
            assertEquals(Intent.Increment, awaitItem())
            assertEquals(Intent.Increment, awaitItem())
            assertEquals(Intent.Increment, awaitItem())
            //затем исходное действие
            assertEquals(Intent.Load, awaitItem())
            //и результат его преобразования в Middleware
            assertEquals(Intent.LoadingData, awaitItem())
            advanceTimeBy(2000)
            runCurrent()
            //после delay должны получить Intent DataIsLoaded
            val item = awaitItem()
            assertTrue(item is Intent.DataIsLoaded)
            assertEquals("Message from test", (item as Intent.DataIsLoaded).data)
        }
    }

Тестирование ViewModel принципиально не отличается от аналогично в архитектуре MVVM, но во всех инструментальных тестах также нужно будет учитывать асинхронный характер обработки действий и использовать IdlingResource для синхронизации. Также при существующей организации CoroutineScope будет затруднительно проверить промежуточное состояние в Middleware, но в то же время, использование IdlingResource позволит избежать необходимости переопределения viewModelScope (поскольку загрузка разделяется на несколько Intent'ов и можно отслеживать последовательность их возникновения.

Для регистрации IdlingResource в тесте, будет необходимо получить тот же экземпляр ViewModel, что и в Activity. Один из самых простых способов это реализовать - использовать ViewModelFactory с кэшированием (сам объект фабрики создается как singleton через Hilt):

class ViewModelFactory @Inject constructor() : ViewModelProvider.Factory {
    val cached = mutableMapOf<String, ViewModel?>()

    @Inject
    lateinit var store: IStore

    override fun <T : ViewModel?> create(modelClass: Class<T>): T {
        val key = modelClass.canonicalName
        if (!cached.containsKey(key)) {
            cached[key] = MainViewModel(store)
        }
        return cached[key] as T
    }
}

@InstallIn(SingletonComponent::class)
@Module
abstract class ViewModelModule {
    @Binds
    @Singleton
    abstract fun bindFactory(factory: ViewModelFactory) : ViewModelProvider.Factory
}

Также для корректного выполнения теста нужно не только убедиться, что Intent был преобразован в изменение состояния, но и само состояние было отражено во View. Для этого перенесем уменьшение счетчика в подписку на stateFlow во ViewModel (раньше был в подписке IntentFlow):

    fun bindView(view: MainView) {
        viewModelScope.launch {
            store.stateFlow.collect {
                view.render(it)
                store.idlingResource.decrement()
            }
        }
    }

Также изменим метод dispatch в Store и перенесем обработку Middleware в отдельный CoroutineScope, чтобы избежать ожидания завершения паузы в 2 секунды при загрузке страницы (при обработке Intent.Load).

    override suspend fun dispatch(intent: Intent) {
        idlingResource.increment()
        _intentsFlow.emit(intent)
        backgroundScope.launch {
            middleware.get().process(intent)
        }
    }

В Activity немного изменим получение экземпляра ViewModel (теперь будет использоваться фабрика):

    @Inject
    lateinit var factory: ViewModelProvider.Factory

    val viewModel by viewModels<MainViewModel>(factoryProducer = { factory })

В тесте мы должны будем зарегистрировать IdlingResource, который обеспечит необходимую синхронизацию (отправка Intent'а во Flow, чтение из очереди и отправка изменения состояния, обновление интерфейса) и код теста может быть описан без дополнительных задержек и проверок состояния:

@HiltAndroidTest
@RunWith(AndroidJUnit4::class)
class CounterTest @Inject constructor() {
    @get:Rule
    val rule = ActivityScenarioRule(CounterActivity::class.java)

    @get:Rule
    var hiltRule = HiltAndroidRule(this)

    @Inject
    lateinit var factory: ViewModelProvider.Factory

    val counterScreen = CounterActivityScreen()

    @Before
    fun init() {
        hiltRule.inject()
    }

    @Test
    fun checkCounter() {
        //получаем из фабрики тот же экземпляр ViewModel
        val viewModel = factory.create(MainViewModel::class.java)
        
        //регистрируем IdlingResource для синхронизации теста
        IdlingRegistry.getInstance().register(viewModel.store.idlingResource)
        counterScreen {
            counter.hasText("Click below for increment")
            //теперь можем не беспокоиться о синхронизации
            increaseButton.click()
            //всю цепочку подписок до обновления интерфейса отслеживает idlingResource
            counter.hasText("Counter: 1")
            increaseButton.click()
            counter.hasText("Counter: 2")
            increaseButton.click()
            counter.hasText("Counter: 3")
            val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
            device.setOrientationLeft()
            counter.hasText("Counter: 3")
            increaseButton.click()
            counter.hasText("Counter: 4")
        }
    }
}

В целом тест работает, но непонятно как проверить корректность обновления состояния после двухсекундной задержки (кроме как с использованием Thread.sleep). Решением этой проблемы может быть замена backgroundScope в dispatch (внутри Store) на управляемый из теста Scope (через инъекцию зависимостей).

Добавим новую аннотацию и тестовую реализацию (с использованием StandardTestDispatcher):

@Qualifier
annotation class BackgroundDispatcherOverride

@TestInstallIn(components = [SingletonComponent::class], replaces = [ScopeModule::class])
@Module
object ScopeTestModule {
    @Provides
    @BackgroundDispatcherOverride
    @Singleton
    fun provideDispatcher(): CoroutineDispatcher = StandardTestDispatcher()
}

Также создадим модуль для обычного приложения:

@InstallIn(SingletonComponent::class)
@Module
object ScopeModule {
    @Provides
    @Singleton
    @BackgroundDispatcherOverride
    fun provideDispatcher(): CoroutineDispatcher = Dispatchers.Default
}

И соответственно заменим Scope для обработке Middleware (в котором и реализуются сетевые запросы или их имитация, как в нашем случае):

class Store @Inject constructor(
    private val reducer: Provider<IReducer>,
    private val middleware: Provider<IMiddleware>,
    @BackgroundDispatcherOverride private val backgroundDispatcherOverride: CoroutineDispatcher
) : IStore {
  
  //другие свойства и методы
      override suspend fun dispatch(intent: Intent) {
        idlingResource.increment()
        _intentsFlow.emit(intent)
        CoroutineScope(backgroundDispatcherOverride).launch {
            middleware.get().process(intent)
        }
    }
}

И теперь будет возможно использовать виртуальный таймер при тестировании:

@HiltAndroidTest
@RunWith(AndroidJUnit4::class)
class CounterTest @Inject constructor() {
	//...подключение правил...
    @Inject
    @BackgroundDispatcherOverride
    lateinit var backgroundDispatcher: CoroutineDispatcher

    val counterScreen = CounterActivityScreen()

    @Before
    fun init() {
        hiltRule.inject()
    }

    @Test
    fun checkCounter() {
        val viewModel = factory.create(MainViewModel::class.java)
        IdlingRegistry.getInstance().register(viewModel.store.idlingResource)
        counterScreen {
            counter.hasText("Click below for increment")
            (backgroundDispatcher as TestDispatcher).scheduler.run {
								runCurrent()		                //отправляем интент Intent.LoadingData
                description.hasText("Loading")	//синхронизацию делает IdlingResource
                advanceTimeBy(2000)							//изменяем виртуальное время
                runCurrent()			  						//отправляем интент DataIsLoaded
                description.hasText("Data from test")
            }
            increaseButton.click()
            counter.hasText("Counter: 1")
            increaseButton.click()
            counter.hasText("Counter: 2")
            increaseButton.click()
            counter.hasText("Counter: 3")
            val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
            device.setOrientationLeft()
            counter.hasText("Counter: 3")
            increaseButton.click()
            counter.hasText("Counter: 4")
            description.hasText("Data from test")
        }
    }
}

В Unit-тесте checkReducer при создании Store можно передавать текущий экземпляр диспетчера, ассоциированное с TestScope, а также нужно имитировать подписку на StateFlow для уменьшения счетчика IdlingResource (и не забыть ее отменить в конце теста):

@Test
fun checkReducer() = runTest {
    lateinit var store: IStore
		val reducer = Provider<IReducer> { Reducer(store) }
		val middleware = Provider<IMiddleware> { Middleware(store, repository) }
		store = Store(reducer, middleware, this.coroutineContext[CoroutineDispatcher]!!)
		store.subscribe()
		val stateFlow = store.stateFlow
		//workaround for idling resource synchronization
		val viewMockCoroutine = launch {
    		stateFlow.collect {
        		store.idlingResource.decrement()
    		}
		}
		//...здесь тест на счетчик и эффект Effect.ShowToast...
		launch {
    		store.dispatch(Intent.Load)
		}
		runCurrent()

		assertEquals(DescriptionResult.Loading, stateFlow.value.message)
		advanceTimeBy(2000)
		store.wait {
    		runCurrent()
		}
		assertEquals(DescriptionResult.Success("Message from test"), stateFlow.value.message)
		//отмена подписки на Flow, чтобы не было висящих контекстов корутин
  	viewMockCoroutine.cancel()
}

Мы подготовили Unit-тесты для проверки обработки интентов, для тестирования ViewModel, а также инструментальный тест для тестирования взаимодействия с интерфейсом. Осталось реализовать E2E-тест для проверки приложения в целом через UIAutomator, а также найти способ проверить отображение уведомления в автоматических UI-тестах.

Для решения второй задачи будем использовать альтернативную реализацию Android API - Robolectric, которая позволяет запустить приложение в искусственной среде без запуска эмулятора Android и отследить взаимодействия с системными сервисами через использования Shadow-классов. Тест разрабатывается и запускается как обычный Kotlin-код (т.е. как Unit-тест), но позволяет взаимодействовать с интерфейсом тем же образом, как если бы он был написан в коде View.

Установим дополнительные зависимости и разрешим доступ к ресурсам (включая идентификаторы) для Unit-тестов в build.gradle:

android {
  testOptions {
    unitTests {
      includeAndroidResources = true
      returnDefaultValues = true
    }
  }
}

dependencies {
  //другие зависимости
    testImplementation "androidx.test.espresso:espresso-core:$espresso_version"
    testImplementation "androidx.test.espresso:espresso-contrib:$espresso_version"
    testImplementation "com.google.dagger:hilt-android-testing:$hilt_version"
    testImplementation 'org.robolectric:robolectric:4.8.1'
    kaptTest "com.google.dagger:hilt-android-compiler:$hilt_version"
}

Сам тест будет использовать Espresso и ActivityScenarioRule для описания проверок (аналогично инструментальному тесту). Для проверки отображения Toast будет использоваться соответствующий Shadow-класс. Для синхронизации с обновлением интерфейса используется IdlingRegistry от Espresso, в который регистрируется idlingResource из Store.

@TestInstallIn(
    components = [SingletonComponent::class],
    replaces = [RepositoryModule::class]
)
@Module
abstract class TestRepositoryModule {
    @Binds
    @Singleton
    abstract fun bindDescription(impl: TestDescriptionRepository): IDescriptionRepository
}

class TestDescriptionRepository @Inject constructor() : IDescriptionRepository {
    override suspend fun getDescription(): String = "Data from test"
}

@HiltAndroidTest
@Config(application = HiltTestApplication::class, instrumentedPackages = ["androidx.loader.content"])
@RunWith(AndroidJUnit4::class)
class RobolectricTest {
    @get:Rule(order = 1)
    var scenario = ActivityScenarioRule(CounterActivity::class.java)

    @get:Rule(order = 0)
    var hiltRule = HiltAndroidRule(this)

    @Before
    fun init() {
        hiltRule.inject()
    }

    @Test
    fun testCounter() {
        scenario.scenario.onActivity {
            println(it.viewModel.store)
            IdlingRegistry.getInstance().register(it.viewModel.store.idlingResource)
            val increase_button = onView(withId(R.id.increase_button))
            onView(withId(R.id.description)).apply {
                check(matches(isDisplayed()))
                check(matches(withText("Loading")))
            }
            onView(withId(R.id.counter)).apply {
                check(matches(isDisplayed()))
                check(matches(withText("Click below for increment")))
                increase_button.perform(click())
                check(matches(withText("Counter: 1")))
                increase_button.perform(click())
                check(matches(withText("Counter: 2")))
                increase_button.perform(click())
                check(matches(withText("Counter: 3")))
            }
            assertEquals("Counter = 3", ShadowToast.getTextOfLatestToast())
            Thread.sleep(2000)
            onView(withId(R.id.description)).check(matches(withText("Data from test")))
        }
    }
}

И последний тест, который нам необходимо сделать - интеграционный тест приложения в целом с использованием UIAutomator2. Но теперь, поскольку наш интерфейс обновляется опосредованно после обработки данных из StateFlow, нужно изменить способ проверки обновления TextView на device.wait(Until.hasObject(By.text("...")) (или добавить задержку перед проверкой через assertEquals). Также добавим код для отключения анимации. Обратите внимание, что Automator-тест должен запускаться с testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner", в то время как другие инструментальные (не юнит) тесты подразумевают использование другого Runner'а (для корректной инициализации тестовых модулей Hilt): testInstrumentationRunner "tech.dzolotov.counterappmvi.CustomTestRunner"

@RunWith(AndroidJUnit4::class)
class TestAutomator {

    lateinit var device: UiDevice
    lateinit var packageName: String

    @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)
    }

    private fun setDeviceAnimationsValue(uiAutomation: UiAutomation, value: Float) {
        listOf(
            "settings put global animator_duration_scale $value",
            "settings put global transition_animation_scale $value",
            "settings put global window_animation_scale $value"
        ).forEach { command ->
            uiAutomation.executeShellCommand(command).run {
                checkError() // throws IOException on error
                close()
            }
        }
    }

    @Test
    fun testCounterE2E() {
        //отключить анимацию
        setDeviceAnimationsValue(InstrumentationRegistry.getInstrumentation().uiAutomation, 0f)
        //надпись со счетчиком
        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()
        device.wait(Until.hasObject(By.text("Counter: 1")), 1000)
        button.click()
        device.wait(Until.hasObject(By.text("Counter: 2")), 1000)
        //проверяем сохранение состояния и корректность работы после поворота экрана
        device.setOrientationLeft()
        //ссылка на объекты в UiAutomator2 устаревают при пересоздании/изменении Activity, ищем заново
        val button2 = device.findObject(By.res(packageName, "increase_button"))
        val description2 = device.findObject(By.res(packageName, "description"))

        device.wait(Until.hasObject(By.text("Counter: 2")), 1000)
        button2.click()
        device.wait(Until.hasObject(By.text("Counter: 3")), 1000)
        assertEquals("Text from external data source", description2.text)
    }
}

Мы проделали большой путь и давайте подведем итоги по архитектуре MVI и тестированию приложений, созданных с ее использованием:

  • MVI позволяет максимально уменьшить связанность компонентов (через использование Flow) и реализовать однонаправленный поток данных (UDF - Unidirectional Data Flow), View отправляет Intent, который может породить побочные эффекты (которые выполняются однократно) или последовательность других интентов (через Middleware)

  • Intent обрабатывается в Reducer и на основе текущего состояния и содержания интента возвращается новое состояние (которое также приводит к актуализации интерфейса)

  • Мы создали собственную реализацию MVI, но существует большое количество готовых библиотек (например, MVICore, MVIKotlin, Roxie, ...)

  • При создании unit-тестов возможно отдельно проверить Reducer (с созданием mock-объекта для Store) или Store целиком (тогда можно еще и протестировать Middleware). Особое внимание нужно уделить семафорам синхронизации, чтобы гарантировать обновление состояния после получения Intent'а

  • Для UI-тестирования особое значения приобретает использование IdlingResource для гарантированного обновления интерфейса после цепочки Intent + State -> (Reducer) -> State -> UI.

  • Для тестирования эффектов (например, отображения Toast или иных визуальных подсказок) можно использовать Roboletric и перехватывать факт вызова/содержание сообщения через Shadow-классы, для синхронизации можно использовать тот же примитив синхронизации, который применялся в UI-тестировании.

  • Тестирование через UiAutomator реализуется обычным сценарием, но для устойчивости тестов предпочтительно использовать ожидающие функции (device.wait) вместо проверки атрибутов View из-за асинхронного обновления.

Исходные тексты проекта и тестов доступны в Github: https://github.com/dzolotov/qa-kotlin-mvi

В третьей части статьи мы поговорим про Jetpack Compose и его тестирование, обсудим отличия реактивного интерфейса от традиционного подхода и сходство с MVI и, конечно, создадим тесты на всех уровнях - от unit-тестирования domain-слоя и до интеграционных UI-тестов.

Традиционно хочу пригласить всех желающих на бесплатный урок в рамках которого рассмотрим основы нативной разработки для Android/iOS, попробуем сделать и протестировать простое приложение по работе с данными на стороне платформы, а также научимся подключать сторонние библиотеки для Android/iOS (на примере OpenCV).

Теги:
Хабы:
Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку
+8
Комментарии1

Публикации

Информация

Сайт
otus.ru
Дата регистрации
Дата основания
Численность
101–200 человек
Местоположение
Россия
Представитель
OTUS