Тестирование приложений 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.
Материал подготовлен в преддверии старта онлайн-курса "Kotlin QA Engineer".