Тестирование приложений 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".

Комментарии (0)