Circuit-фреймворк для Jetpack Compose и тестирование с Robolectric

от автора

Тестирование приложений Jetpack Compose обычно основано на использовании библиотеки Compose UI Test и создании юнит-тестов поверх библиотек мокирования или DI. Однако этот подход требует наличия эмулятора и не всегда применим для использования в конвейере CI/CD, где обычно используется Robolectric вместо настоящего Android Runtime. При этом нередко в тестах используется скриншотное тестирование (например, через использование captureToImage в Compose UI Test) и сравнение рендеров с образцом, что изначально недоступно в Robolectric из-за особенностей рендеринга. В этой статье мы рассмотрим использование библиотеки Roborazzi, которая решает эту проблему, совместно с новым подходом к архитектуре Jetpack Compose приложений, которая была предложена Slack в библиотеке Circuit.

Изначально Jetpack Compose предлагал подход к созданию реактивного пользовательского интерфейса, основанного на модификации кода компилируемого приложения с помощью плагина, предназначенного для отслеживания изменения внешней конфигурации или внутреннего состояния для Composable-функции. Также Compose используется альтернативное представление структуры интерфейса через иерархию вложенных функций (которые в действительности могут также отвечать за логику и хранение данных). Процесс обновления дерева называется «рекомпозицией» и он может затрагивать как все дерево целиком, так и отдельные поддеревья, что помогает оптимизировать обновление экрана. Состояние Composable-функции могло быть определено как внутри нее, так и хранится во внешнем объекте (например, ViewModel) и использоваться как аргумент функции (в этом случае рекомпозиция происходила при изменении внешнего состояния). Например, приложение с простым счетчиком можно реализовать как с использовать внутреннего состояния:

package tech.dzolotov.mycounter1  import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.layout.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.*  class MainActivity : ComponentActivity() {     override fun onCreate(savedInstanceState: Bundle?) {         super.onCreate(savedInstanceState)         setContent {             Counter()         }     } }  @Composable fun Counter() {     var counter by remember { mutableStateOf(0) }     Surface {         Column(             Modifier.fillMaxSize(),             horizontalAlignment = Alignment.CenterHorizontally,             verticalArrangement = Arrangement.Center         ) {             Text("Counter value is $counter")             Button({                 counter++             }) {                 Text("Increment")             }         }     } }

С этим приложением возникнут проблемы при тестировании, поскольку состояние находится внутри и нет никакой возможности проверить логику счетчика через модульные тесты. Для реализации логики хранения состояния и его изменения при действии пользователя можно использовать отдельный класс контроллера:

interface CounterController {     var counter:Int     fun increment() }  class CounterControllerImpl : CounterController {     override var counter by mutableStateOf(0)      override fun increment() {         counter++     } }  class MainActivity : ComponentActivity() {     override fun onCreate(savedInstanceState: Bundle?) {         super.onCreate(savedInstanceState)         setContent {             val controller = CounterControllerImpl()             Counter(controller)         }     } }  @Composable fun Counter(controller: CounterController) {     Surface {         Column(             Modifier.fillMaxSize(),             horizontalAlignment = Alignment.CenterHorizontally,             verticalArrangement = Arrangement.Center         ) {             Text("Counter value is ${controller.counter}")             Button({                 controller.increment()             }) {                 Text("Increment")             }         }     } }

В этой реализации мы уже можем подменить реализацию контроллера на тестовый объект или выполнить проверку контроллера независимо от интерфейса. Поскольку состояние может обновляться асинхронно, здесь также можно использовать корутины и выполнять отложенное изменение состояние после получения данных или по внешнему сигналу (например, при получении push-уведомления). В этом случае в Composable-функции можно создать контекст для корутины val scope=rememberCoroutineScope(), а затем из него вызвать корутину из контроллера:

scope.launch {   controller.increment() }

Но такой подход избыточно связывает контроллер и интерфейс, единственным механизмом отложенного обновления является неявная подписка на изменение состояния, которая создается через compose-плагин. И кажется разумным добавить большее разделение архитектурных компонентов по подобию паттерна MVI (Model-View-Intent), про который было обсуждение в предыдущей статье. Но MVI не очень хорошо подходит к Compose, поскольку несколько не очевидно где именно нужно размещать состояние и как классы архитектуры MVI связаны с Composable-функциями. Хорошей альтернативой MVI может быть фреймворк Circuit, который предложил Slack и о котором было хорошее обсуждение в этом видео.

В модели Circuit за отображение интерфейса отвечает Ui-функция, а за хранение и обновление состояния — Presenter-функция. Что важно, что обе функции являются Composable (хотя вторая и не описывает интерфейс, но в действительности подписка на состояние или любой другой наблюдаемым объект, включая Flow, может приводить к перезапуску Composable-функции и не обязательно чтобы это приводило к модификациям пользовательского интерфейса). Само состояние описывается дополнительным data-классом, который используется для типизации Ui- и Presenter-функций. От Ui-функции к Presenter будет поставляться поток событий (например, действий пользователя), а от Presenter к Ui — поток состояний.

Для модификации нашего счетчика сначала добавим зависимости на библиотеку (в блок dependencies в build.gradle):

implementation("com.slack.circuit:circuit-foundation:0.8.0")

Прежде всего необходимо создать абстракцию Screen, которая будет использоваться для выбора необходимых Ui и Presenter-функций:

@Parcelize object CounterScreen : Screen

Объект экрана может принимать аргументы и это можно использовать при навигации (например, передавать идентификатор при отображении карточки с подробностями товара). Затем преобразуем наш Composable и передадим в него объект состояния, для этого также определим возможные действия (события) и data-класс с описанием состояния:

//возможные события от экрана sealed interface CounterEvent : CircuitUiEvent {     object Increment : CounterEvent     object OtherEvent : CounterEvent }  data class CounterState(val counter: Int, val eventSink: (CounterEvent) -> Unit) :     CircuitUiState  @Composable fun Counter(state: CounterState) {     Surface {         Column(             Modifier.fillMaxSize(),             horizontalAlignment = Alignment.CenterHorizontally,             verticalArrangement = Arrangement.Center         ) {             Text("Counter value is ${state.counter}")             Button({                 state.eventSink(CounterEvent.Increment)             }) {                 Text("Increment")             }         }     } }

Чтобы корректно выполнить привязку к экрану нужно добавить Factory-метод (или использовать кодогенерацию, которая интегрируется к существующим DI-библиотекам, например Hilt, и основана на использовании KSP):

class CounterUiFactory : Ui.Factory {     override fun create(screen: Screen, context: CircuitContext): Ui<*>? {         return when (screen) {             is CounterScreen -> ui<CounterState> { state, modifier -> Counter(state = state) }             else -> null         }     } }

Аналогично Ui-функции необходимо выполнить те же действия с контроллером, который в Circuit преобразуется в Presenter-функцию:

@Composable fun CounterPresenter(): CounterState {     var counter by remember { mutableStateOf(0) }      return CounterState(counter) { event ->         when (event) {             CounterEvent.Increment -> counter++             else -> println("Unknown event")         }     } }  class CounterPresenterFactory : Presenter.Factory {     override fun create(         screen: Screen,         navigator: Navigator,         context: CircuitContext     ): Presenter<*>? {         return when (screen) {             is CounterScreen -> presenterOf { CounterPresenter() }             else -> null         }     } }

Все factory-классы должны быть зарегистрированы в CircuitConfig (это обычно выполняется при инициализации приложения) и далее для передачи конфигурации будет использоваться LocalComposition, представленный Composable-функцией CircuitCompositionLocals и, например, CircuitContent (при отображении только одного экрана):

    override fun onCreate(savedInstanceState: Bundle?) {         super.onCreate(savedInstanceState)         val circuitConfig = CircuitConfig.Builder()             .addPresenterFactory(CounterPresenterFactory())             .addUiFactory(CounterUiFactory())             .build()         setContent {             CircuitCompositionLocals(circuitConfig = circuitConfig) {                 CircuitContent(screen = CounterScreen)             }         }     }

Также возможно использовать встроенный навигатор для перемещения между экранами, в этом случае содержание представляется Composable-функцией NavigableCircuitContent.

class MainActivity : ComponentActivity() {     override fun onCreate(savedInstanceState: Bundle?) {         super.onCreate(savedInstanceState)         val circuitConfig = CircuitConfig.Builder()             .addPresenterFactory(CounterPresenterFactory())             .addUiFactory(CounterUiFactory())             .build()         setContent {             val backstack = rememberSaveableBackStack {                 this.push(CounterScreen)             }             val navigator = rememberCircuitNavigator(backstack)             CircuitCompositionLocals(circuitConfig = circuitConfig) {                 NavigableCircuitContent(navigator = navigator, backstack = backstack)             }         }     } }

Например, добавим экран со списком значений, которые будут передаваться в экран счетчика, для этого создадим дополнительный Screen и связанные Ui и Presenter-функции (и также не забудем добавить их в Factory-классы):

@Parcelize object HomeScreen : Screen  //пустое состояние class HomeState : CircuitUiState  //здесь нет состояния и его изменения, поэтому просто возвращаем //состояние по умолчанию @Composable fun HomePresenter() : HomeState = HomeState()  @Composable fun Home() = Column(modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {         Text("Welcome to our Circuit counter")     }  class CounterPresenterFactory : Presenter.Factory {     override fun create(         screen: Screen,         navigator: Navigator,         context: CircuitContext     ): Presenter<*>? {         return when (screen) {             is CounterScreen -> presenterOf { CounterPresenter() }             is HomeScreen -> presenterOf { HomePresenter() }             else -> null         }     } }  class CounterUiFactory : Ui.Factory {     override fun create(screen: Screen, context: CircuitContext): Ui<*>? {         return when (screen) {             is CounterScreen -> ui<CounterState> { state, modifier -> Counter(state = state) }             is HomeScreen -> ui<HomeState> { state, modifier -> Home() }             else -> null         }     } } 

Теперь добавим возможность навигации между экранами, для этого будем принимать в Ui-функцию объект класса Navigator, например так:

@Composable fun Home(navigator: Navigator) = Column(     modifier = Modifier.fillMaxSize(),     horizontalAlignment = Alignment.CenterHorizontally,     verticalArrangement = Arrangement.Center ) {     Text("Welcome to our Circuit counter")     LazyColumn {         items(5) {             Text("Counter $it", modifier = Modifier.clickable {                 navigator.goTo(CounterScreen)             })         }     } }

И поскольку Home создается из Factory, то и в него тоже будем передавать navigator:

class CounterUiFactory(val navigator: Navigator) : Ui.Factory {     override fun create(screen: Screen, context: CircuitContext): Ui<*>? {         return when (screen) {             is CounterScreen -> ui<CounterState> { state, modifier -> Counter(state = state) }             is HomeScreen -> ui<HomeState> { state, modifier -> Home(navigator = navigator) }             else -> null         }     } }  class MainActivity : ComponentActivity() {     override fun onCreate(savedInstanceState: Bundle?) {         super.onCreate(savedInstanceState)         setContent {             val backstack = rememberSaveableBackStack {                 this.push(HomeScreen)             }             val navigator = rememberCircuitNavigator(backstack)             val circuitConfig = CircuitConfig.Builder()                 .addPresenterFactory(CounterPresenterFactory())                 .addUiFactory(CounterUiFactory(navigator = navigator))                 .build()             CircuitCompositionLocals(circuitConfig = circuitConfig) {                 NavigableCircuitContent(navigator = navigator, backstack = backstack)             }         }     } }

Следующим шагом добавим передачу аргумента в CounterScreen и будем использовать значение при формировании изначального состояния, для этого

  • добавим название страницы в State-класс для CounterScreen

  • добавим значение как аргумент конструктора CounterScreen (заменим object -> class)

  • в CounterPresenter будем принимать title и использовать его при инициализации состояния

  • при создании CounterPresenter в CounterPresenterFactory будем извлекать значение title из объекта экрана

data class CounterState(     val title: String,     val counter: Int,     val eventSink: (CounterEvent) -> Unit ) :     CircuitUiState  @Parcelize class CounterScreen(val title: String) : Screen  @Composable fun CounterPresenter(title: String): CounterState {     var counter by remember { mutableStateOf(0) }      return CounterState(title, counter) { event ->         when (event) {             CounterEvent.Increment -> counter++             else -> println("Unknown event")         }     } }  class CounterPresenterFactory : Presenter.Factory {     override fun create(         screen: Screen,         navigator: Navigator,         context: CircuitContext     ): Presenter<*>? {         return when (screen) {             is CounterScreen -> presenterOf { CounterPresenter(screen.title) }             is HomeScreen -> presenterOf { HomePresenter() }             else -> null         }     } }

Теперь название страницы (или идентификатор товара) могут быть извлечены в любой из связанных со Screen функций (например для отображения названия страницы в Ui-функции или для выполнения сетевых запросов в Presenter-функции).

Если в приложении используется Dagger-совместимый DI, можно подключить кодогенерацию и использовать перед Ui и Presenter-функциями совместно с @Composable аннотации @CircuitInject с двумя аргументами (название класса экрана и название Scope в DI). Это автоматизирует генерацию и регистрацию Factory-методов и уменьшит количество boilerplate кода.

Теперь, когда приложение собрано, можно перейти к тестированию. Здесь важно отметить, что в Circuit есть несколько библиотек для тестирования и также представлена интеграция с roborazzi для создания скриншотов отдельных Composable-функций при запуске в Robolectric.

Сначала добавим обычные библиотеки для тестирования (сразу будем использовать Robolectric для быстрого запуска тестов, как следствие будут создаваться unit-тесты вместо инструментальных):

android {   //остальная конфигурация     testOptions {         unitTests {             includeAndroidResources = true         }     } }  dependencies {   //другие зависимости     testImplementation 'org.robolectric:robolectric:4.10'     testImplementation 'androidx.compose.ui:ui-test-junit4'     testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4' }

Для доступа к тестированию presenter-функций будет необходимо получить актуальную конфигурацию и иметь возможность подменить навигатор приложения на экземпляр FakeNavigator, сделаем необходимые изменения:

@Composable fun MainContent(navigator: Navigator, backstack: SaveableBackStack): CircuitConfig {     val circuitConfig = CircuitConfig.Builder()         .addPresenterFactory(CounterPresenterFactory())         .addUiFactory(CounterUiFactory(navigator = navigator))         .build()     CircuitCompositionLocals(circuitConfig = circuitConfig) {         NavigableCircuitContent(navigator = navigator, backstack = backstack)     }     return circuitConfig }  class MainActivity : ComponentActivity() {     override fun onCreate(savedInstanceState: Bundle?) {         super.onCreate(savedInstanceState)         setContent {             val backstack = rememberSaveableBackStack {                 this.push(HomeScreen)             }             val navigator = rememberCircuitNavigator(backstack)             MainContent(navigator = navigator, backstack = backstack)         }     } } 

Начнем с проверки навигации перехода с главного экрана, для этого добавим правило для инициализации Compose и будем использовать методы FakeNavigator (с помощью него можно определить факт перехода между экранами и перейти на любой экран программно):

@RunWith(RobolectricTestRunner::class) class CounterTest {      @get:Rule     val composeTestRule = createComposeRule()      val fakeNavigator = FakeNavigator()      @Test     fun testHome() = runTest {         composeTestRule.setContent {             val backStack = rememberSaveableBackStack {                 push(HomeScreen)             }             MainContent(navigator = fakeNavigator, backstack = backStack)         }         composeTestRule.onNodeWithTag("Welcome Label").assertExists()         //проверяем навигацию         composeTestRule.onNodeWithTag("Counter 0").performClick()         val newScreen = fakeNavigator.awaitNextScreen()         assert(newScreen is CounterScreen)         assert((newScreen as CounterScreen).title == "Counter 0")     }   }

Для проверки взаимодействия с интерфейсом экрана счетчика можно использовать обычный Compose UI Test:

    //проверка интерфейса     @Test     fun testCounter() = runTest {          //создаем начальный экран         val screen = CounterScreen("Counter 0")         //инициализируем compose         composeTestRule.setContent {             val backStack = rememberSaveableBackStack {                 push(screen)             }             MainContent(navigator = fakeNavigator, backstack = backStack)         }         //обычным образом взаимодействуем с узлами на экране         val node = composeTestRule.onNodeWithTag("Counter")         node.assertExists().assertIsDisplayed()         node.assertTextContains("0", substring = true)         //нажимаем на кнопку         composeTestRule.onNodeWithTag("Increment").performClick()         //и проверяем увеличение счетчика         composeTestRule.onNodeWithTag("Counter").assertTextContains("1", substring = true)     } 

Но теперь у нас также есть возможность проверить presenter напрямую, для этого добавим зависимость :

    testImplementation 'com.slack.circuit:circuit-test:0.8.0'

и теперь с помощью функции расширения .test для Presenter мы можем проверить изменение состояния при отправке событий (в действительности в лямбду передается контекст Turbine, что позволяет проверить генерируемые значения состояния и отправить в презентер новые события).

    @Test     fun unitTestCounter() = runTest {          //сохраним конфигурацию (будет нужна для получения презентера)         lateinit var circuitConfig: CircuitConfig         val screen = CounterScreen("Counter 0")          composeTestRule.setContent {             val backStack = rememberSaveableBackStack {                 push(screen)             }             circuitConfig = MainContent(navigator = fakeNavigator, backstack = backStack)         }          val presenter = circuitConfig.presenter(screen, fakeNavigator)         //тестируем презентер         presenter?.test {             //убеждаемся что вначале там 0 и отправляем событие             awaitItem().run {                 assert((this as CounterState).counter==0)                 eventSink(CounterEvent.Increment)             }             //проверяем, что счетчик увеличился             assert((awaitItem() as CounterState).counter==1)         }     } 

Последним действием добавим возможность создания скриншотов полученных Ui-функций. Начиная с версии Robolectric 4.10 стало доступным выполнение захвата изображения View (необходимо добавить аннотацию @GraphicsMode(GraphicsMode.Mode.NATIVE)к тестовому классу. Однако, для корректной работы с Compose нужно также дополнительно подключить плагин roborazzi. В файле build.gradle для проекта добавляем в plugins:

    id "io.github.takahirom.roborazzi" version "1.2.0-alpha-1" apply false

Затем в файле build.gradle модуля активируем плагин и добавляем зависимости:

plugins { //другие плагины     id 'io.github.takahirom.roborazzi' }  dependencies { //другие зависимости     testImplementation("io.github.takahirom.roborazzi:roborazzi:1.2.0-alpha-1")     testImplementation("io.github.takahirom.roborazzi:roborazzi-junit-rule:1.2.0-alpha-1") }

И теперь мы можем использовать функции-расширения для захвата изображения и проверки соответствия снимка ранее сохраненному. Главное достоинство использования Robolectric в этом случае в запуске на локальной файловой системе (а не внутри эмулятора или физического устройства) и, как следствие, возможность просмотра и сохранения скриншотов как части проекта. Добавим скриншотный тест:

    @Test     fun screenShot() {         composeTestRule.setContent {             val backStack = rememberSaveableBackStack {                 push(HomeScreen)             }             MainContent(navigator = fakeNavigator, backstack = backStack)         }         composeTestRule.onNodeWithTag("Welcome Label").captureRoboImage("build/welcome_message.png")     } 

Запустим создание снимков через задачу gradle:

./gradlew recordRoborazziDebug

Снимок можно будет найти в app/build/welcome_message.png:

Снимок Composable

Снимок Composable

В дальнейшем можно будет проводить сравнение актуального изображения со снимком с помощью задачи Gradle:

./gradlew verifyRoborazziDebug

Нужно отметить, что библиотека Circuit находится в стадии активной разработки, сейчас уже появились встроенные интеграции с Roborazzi, а также поддержка инструментальных тестов Android, но это все еще в очень экспериментальной стадии. Но концептуально использовать Circuit можно уже сейчас и основные идеи с реализацией State-Presenter-UI-Screen вероятнее всего останутся неизменными.

Исходный текст проекта можно найти на Github: https://github.com/dzolotov/circuit-sample.


Материал подготовлен в преддверии старта онлайн-курса «Kotlin QA Engineer».


ссылка на оригинал статьи https://habr.com/ru/articles/731150/