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
:
В дальнейшем можно будет проводить сравнение актуального изображения со снимком с помощью задачи Gradle:
./gradlew verifyRoborazziDebug
Нужно отметить, что библиотека Circuit находится в стадии активной разработки, сейчас уже появились встроенные интеграции с Roborazzi, а также поддержка инструментальных тестов Android, но это все еще в очень экспериментальной стадии. Но концептуально использовать Circuit можно уже сейчас и основные идеи с реализацией State-Presenter-UI-Screen вероятнее всего останутся неизменными.
Исходный текст проекта можно найти на Github: https://github.com/dzolotov/circuit-sample.