Site icon AppTractor

Прекратите использовать тестовые теги в Jetpack Compose

Тестирование UI с помощью Jetpack Compose в основном основано на поиске узлов в дереве семантики, которое состоит из composable элементов.

Я заметил, что некоторые люди используют тестовые теги (test tags) в качестве модификаторов для своих составных элементов, чтобы сделать их легкодоступными.

Вот небольшой пример:

@Composable
fun PrimaryButton(
    modifier: Modifier = Modifier,
    @StringRes text: Int, onClick: OnClickFunction) {
    Button(
        // tag in the primary button
        modifier = modifier.testTag("Primary button"),
        onClick = onClick,
    ) {
        Text(
            stringResource(id = text),
        )
    }
}

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

Пока все хорошо. Однако у этого есть огромный минус.

Он загрязняет ваш продакшн код тегами, которые предназначены для тестирования. Производственный код не должен содержать ничего, относящегося к тестированию, по крайней мере, максимально стремиться к этому.

Другие минусы:

Есть лучший способ — дополнительная семантика

Тестовые теги не предоставляют ничего, кроме способа поиска элементов пользовательского интерфейса во время тестирования пользовательского интерфейса. Семантика используется в Android для того, чтобы предоставить пользователям с особыми потребностями различные способы взаимодействия с вашим приложением. Например, с помощью TalkBack, чтения с экрана или Switch Access, который итеративно просматривает ваш пользовательский интерфейс, а пользователь выбирает элемент с кликабельным аксессуаром.

Вам следует придерживаться общепринятых способов тестирования. Следующие варианты показывают дополнительные способы поиска узлов в семантическом дереве.

Описание содержимого

Это общеизвестный способ, так как все будут его использовать. Каждое изображение или иконка в Jetpack Compose просят вас заполнить параметр contentDescription. Этот параметр можно заполнить каким-то осмысленным описанием и в то же время использовать его в тесте. Если изображение не несет никакой смысловой нагрузки и является просто косметическим, вы можете передать null, и оно будет проигнорировано в рамках семантики. Реализация:

Image(
    painter = painterResource(R.drawable.important_image),
    // here you pass your description or null, if it is not important
    contentDescription = stringResource(id = R.string.important_image_description),
)

В тесте:

composeRule.onNode(hasContentDescription("Description of the image")).assertExists()

Описание действия для клика

В большинстве случаев вы сможете найти пользовательскую кнопку по описанию или тексту. Но есть один дополнительный слой, который может быть полезен при тестировании и одновременном обеспечении доступности вашего приложения. Вы можете использовать clickable или semantics поле onClick с дополнительными clickLabel и clickAction. Метка информирует пользователя о действии, которое произойдет после нажатия на компонент.

// clickable modifier
Column(
    Modifier.clickable(
        onClickLabel = stringResource(R.string.on_button_click_label),
        onClick = {}
    )
) {}

// semantics modifier
Column(
    Modifier.semantics(
        onClick(
            label = stringResource(R.string.on_button_click_label), 
            action = {return@onClick true}
        )
    )
) {}

Семантика версии ожидает возврата булевого значения. Она должна знать, обрабатывается ли действие.

Для кликабельного матчера не существует готового семантического матчера, но мы можем его создать.

// matcher based on the click label
fun hasClickLabel(label: String) = SemanticsMatcher("Clickable action with label: $label") {
    it.config.getOrNull(
        SemanticsActions.OnClick
    )?.label == label
}

В тесте:

composeRule.onNode(hasClickLabel("Moves to next screen")).assertExists()

Я хочу, чтобы вы ознакомились с другими действиями класса SemanticsActions, которые тоже можно использовать.

Описание состояния

Большинство приложений имеют определенное состояние, чтобы показать пользователю конкретный контент. Вы можете использовать stateDescription для описания текущего состояния экрана, кнопки или любого другого представления. Реализация довольно проста. Основываясь на механизме обработки состояния, вы можете изменить stateDescription в соответствии с вашими требованиями. Затем состояние интерпретируется для пользователя.

@Composable
fun MainScreen(state: State, onClick: () -> Unit) {

    val turnedOffDescription = stringResource(id = R.string.turned_off_state_description)
    val turnedOnDescription = stringResource(id = R.string.turned_on_state_description)
    val waitingDescription = stringResource(id = R.string.waiting_state_description)

    Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxSize()) {
        CustomComposable(
            modifier = Modifier
                .semantics {
                    // definistion of state description based on state
                    stateDescription = when (state) {
                        State.TurnedOff -> turnedOffDescription
                        State.TurnedOn -> turnedOnDescription
                        State.WaitingForAction -> waitingDescription
                    }
                },
            state = state
        ) 
        /**
          * other composables
          */
    }
}

В тесте:

composeRule.onNode(hasStateDescription("Description of state")).assertExists()

Роль composable

Сервисы доступности используют для описания role элемента composable. Все распространенные составные элементы, такие как кнопки, переключатели, флажки и другие, имеют эту роль изначально. Однако все композитные элементы входят в состав других композитных элементов, таких как строки с текстом, например. Желательно сделать весь элемент кликабельным, чтобы он содержал текущее значение. Вот пример с кнопкой переключения:

var currentValue by remember { mutableStateOf(false) }
Row(
    Modifier
        .toggleable(
            value = currentValue,
            role = Role.Switch,
            onValueChange = { currentValue = !currentValue }
        )
        .padding(8.dp)
        .fillMaxWidth()
) {
    Text("Setting description", Modifier.weight(1f))
    Switch(checked = currentValue, onCheckedChange = null)
}

Передача null в переключатель делает его отключенным. Состояние контролируется модификатором toggleable в строке.

В тесте мы можем использовать роль следующим образом:

fun hasRole(role: Role) = SemanticsMatcher("Searches for role: $role") {
    it.config.getOrNull(SemanticsProperties.Role) == role
}

// in test
composeRule.onNode(
    hasRole(Role.Switch).and(
        hasText("Setting description")
    )).assertExists()

Последовательная семантика

Если у вас есть сложная composable, которая ведет себя как единое целое, она может быть составной для размещения многокатегорийных данных, таких как события, заголовки и т.д. Вы можете опустить семантику всех дочерних композитов и объявить ее в родительском композите один раз.

Не создавайте семантику в нескольких составных элементах, если она не нужна:

val actionDescription = stringResource(id = R.string.actionDescription)
val contentDescription = stringResource(id = R.string.imageDescription)
Row {
    Image(
        painter = painterResource(R.drawable.important_image),
        contentDescription = contentDescription,
    ),
    PrimaryButton(
        modifier = Modifier.clickable(
            onClickLabel = clickActionLabel,
            onClick = {}
        )
    )
}

Создайте единую семантическую декларацию поверх всего независимого элемента:

val actionDescription = stringResource(id = R.string.actionDescription)
val contentDescription = stringResource(id = R.string.imageDescription)
Row(
    // row is parent of the composable - it owns the semantics
    modifier = Modifier.semantics {
        customActions = listOf(
            CustomAccessibilityAction(
                label = actionDescription, 
                // custom function
                { return@CustomAccessibilityAction true} 
            ),
        )
        contentDescription = contentDescription
    }
    
) {
    Image(
        painter = painterResource(R.drawable.important_image),
        contentDescription = null
    ),
    PrimaryButton(
        // to make sure, no semantics are addeed
        modifier = Modifier.clearAndSetSemantics { }
    )
}

Это позволит сохранить семантику явной и чистой в коде. Описание содержимого и метка действия могут быть найдены с помощью SemanticMatcher, как указано выше.

Чтобы сделать ваши тесты еще лучше, более подробными и чистыми, вы можете ознакомиться с моей другой статьей о шаблоне Робот в Jetpack Compose.

Заключение

Использование семантики делает ваше приложение доступным для людей с особыми потребностями и в то же время сохраняет его тестируемость. К сожалению, бывают ситуации, когда тестовый тег неизбежен или просто не стоит тратить время на семантику. С другой стороны, я надеюсь, что это сделает внедрение семантик менее сложным для вас и упростит чью-то жизнь с помощью вашего приложения.

Подробнее о семантике и доступности вы можете узнать на официальном сайте.

Источник

Exit mobile version