
Мы в hh очень любим UI-тесты, ими покрывается практически вся функциональность наших приложений, и даже backend-разработчики прогоняют тесты мобильных платформ перед развертыванием своих фич. Однако наши механизмы UI-тестирования разрабатывались более 8 лет назад и с тех пор почти не изменились.
Кто-то скажет, что старые решения прошли проверку временем и что «работает — не трогай»... Возможно, и так. Но сейчас мы внедряем новую дизайн-систему, а новые компоненты требуют адаптации всех тестов — не лучшее ли время для перемен?
Поэтому мы пересмотрели наши подходы к UI-тестированию и теперь готовы поделиться наработками с сообществом в виде open-source проекта — Rafinad. В этой статье расскажем, что он умеет и как им пользоваться.
Типичная реализация UI-тестов
Для начала сделаем небольшой экскурс и познакомимся с типичной реализацией UI-тестов в iOS. В качестве примера возьмём простой экран, который отображает информацию о пользователе. На данном этапе ограничимся именем и должностью:
import SwiftUI
struct UserView: View {
let user: User
var body: some View {
VStack(spacing: 4) {
Text(user.name)
.font(.largeTitle)
Text(user.position)
.font(.title3)
.foregroundStyle(.secondary)
}
}
}
struct User {
let id: Int
let photoURL: URL?
let name: String
let position: String
}
Допустим, нам надо написать тест, который проверяет корректность имени пользователя на этом экране.
Как написать неправильный тест?
Решением «в лоб» является поиск в иерархии элемента с ожидаемым текстом и проверка его наличия, например:
@MainActor
final class UserScreenTests: XCTestCase {
let application = XCUIApplication()
func testThatUserNameIsCorrect() {
let application = XCUIApplication()
application.launch()
// Поиск текстового элемента с ожидаемым именем
let userName = application
.staticTexts["Steve Jobs"]
.firstMatch
// Проверка, что элемент с ожидаемым именем найден
XCTAssertTrue(userName.exists)
}
}
Но такой тест нельзя назвать корректным, так как он будет зелёным на любом экране, который содержит элемент с ожидаемым текстом. И даже на одном экране тест может найти строку не там, где мы её ожидаем — например, в панели навигации.
Как сделать тест правильным?
Более правильным решением в данном случае является явная идентификация элементов. При этом сами идентификаторы должны содержать уникальный префикс экрана, в нашем примере таким префиксом будет "User"
:
import SwiftUI
struct UserView: View {
let user: User
var body: some View {
VStack(spacing: 4) {
Text(user.name)
.font(.largeTitle)
// Установка идентификатора имени для тестов
.accessibilityIdentifier("UserName")
Text(user.position)
.font(.title3)
.foregroundStyle(.secondary)
// Установка идентификатора должности для тестов
.accessibilityIdentifier("UserPosition")
}
}
}
Тогда наш тест можно привести к следующему виду:
func testThatUserNameIsCorrect() {
let application = XCUIApplication()
application.launch()
// Поиск текстового элемента по его идентификатору
let userName = application
.descendants(matching: .staticText)
.matching(identifier: "UserName")
.firstMatch
// Проверка, что текст элемента равен ожидаемому
XCTAssertEqual(userName.label, "Steve Jobs")
}
Теперь сам тест корректный и будет зелёным только на экране пользователя. К тому же, явная идентификация элементов позволит гарантировать, что имя пользователя находится на своём месте.
Тем не менее, такая реализация не лишена изъянов. Самый очевидный из них в том, что идентификаторы в коде экрана никак не связаны с тестами и дублируются в виде так называемых «магических» строк.
Как можно улучшить?
Для решения проблемы с дублированием идентификаторов можно выделить некоторый их источник, единый и для кодовой базы UI, и для тестов. В простейшем виде это может быть «плоский» enum
со статическими константами:
enum UserAccessibility {
static let name = "UserName"
static let position = "UserPosition"
}
Тогда код самого экрана будет выглядеть так:
import SwiftUI
struct UserView: View {
let user: User
var body: some View {
VStack(spacing: 4) {
Text(user.name)
.font(.largeTitle)
// Установка идентификатора имени из единого источника
.accessibilityIdentifier(UserAccessibility.name)
Text(user.position)
.font(.title3)
.foregroundStyle(.secondary)
// Установка идентификатора должности из единого источника
.accessibilityIdentifier(UserAccessibility.position)
}
}
}
Чтобы источник идентификаторов стал доступен в тестах, достаточно подключить его файл к соответствующему таргету в Xcode. После этого можно заменить идентификатор имени пользователя в нашем тесте:
func testThatUserNameIsCorrect() {
let application = XCUIApplication()
application.launch()
// Поиск текстового элемента по его идентификатору из единого источника
let userName = application
.descendants(matching: .staticText)
.matching(identifier: UserAccessibility.name)
.firstMatch
XCTAssertEqual(userName.label, "Steve Jobs")
}
Теперь проблема устранена, изменение идентификатора на стороне UI не потребует дополнительных действий на стороне тестов. Но давайте усложним задачу.
Что может быть сложнее?
Сложности начинаются с переиспользуемыми составными компонентами, так как их невозможно идентифицировать в контексте только одного экрана.
В качестве примера добавим компонент Avatar
, который отображает фото по заданному URL или плейсхолдер, если фото нет:
import SwiftUI
struct Avatar: View {
let url: URL?
var body: some View {
AsyncImage(url: url) { image in
image.resizable()
} placeholder: {
Image(systemName: "person.crop.circle")
.resizable()
.foregroundStyle(.secondary)
}
.clipShape(.capsule)
}
}
Допустим, мы хотим написать тест, который проверяет, что для пользователя с фото отображается именно его изображение, а не плейсхолдер. Для этого нам нужно идентифицировать изображение и плейсхолдер разными идентификаторами, но в качестве их источника нельзя использовать UserAccessibility
, так как компонент Avatar
может отображаться на разных экранах.
Что же делать, что же делать...
Красивое решение тут одно: если компонент переиспользуется, то у него должен быть собственный источник идентификаторов. Добавим его для нашего примера с Avatar
:
enum AvatarAccessibility {
static let image = "AvatarImage"
static let placeholder = "AvatarPlaceholder"
}
Теперь следует установить эти идентификаторы для внутренних элементов компонента:
import SwiftUI
struct Avatar: View {
let url: URL?
var body: some View {
AsyncImage(url: url) { image in
image
.resizable()
// Установка идентификатора для изображения по URL
.accessibilityIdentifier(AvatarAccessibility.image)
} placeholder: {
Image(systemName: "person.crop.circle")
.resizable()
.foregroundStyle(.secondary)
// Установка идентификатора для плейсхолдера
.accessibilityIdentifier(AvatarAccessibility.placeholder)
}
.clipShape(.capsule)
// Создание accessibility-контейнера, чтобы находить элемент в тестах
.accessibilityElement(children: .contain)
}
}
Но так мы возвращаемся к изначальной проблеме — если искать элементы в иерархии по идентификаторам из AvatarAccessibility
независимо от самого экрана, тест найдет этот компонент и будет зелёным не только на экране пользователя.
Поэтому в тестах сначала нужно найти сам компонент Avatar
по специфичному для экрана идентификатору, а уже внутри него искать изображение по идентификатору из AvatarAccessibility
. Следовательно, источник идентификаторов экрана пользователя должен содержать константу и для компонента Avatar
:
enum UserAccessibility {
// Идентификатор для компонента Avatar
static let avatar = "UserAvatar"
static let name = "UserName"
static let position = "UserPosition"
}
Осталось добавить компонент Avatar
на экран пользователя и установить для него новый идентификатор:
import SwiftUI
struct UserView: View {
let user: User
var body: some View {
VStack(spacing: .zero) {
Avatar(url: user.photoURL)
.frame(width: 120, height: 120)
// Установка идентификатора компонента Avatar
.accessibilityIdentifier(UserAccessibility.avatar)
Text(user.name)
.font(.largeTitle)
.accessibilityIdentifier(UserAccessibility.name)
.padding(.top, 24)
Text(user.position)
.font(.title3)
.foregroundStyle(.secondary)
.accessibilityIdentifier(UserAccessibility.position)
.padding(.top, 4)
}
}
}
Теперь тесты смогут найти компонент Avatar
, гарантируя его принадлежность к экрану пользователя. Осталось только добавить сами тесты.
Как найти переиспользуемые компоненты в тестах?
Напомню, нам нужно проверять, что на экране пользователя с фото отображается само изображение по URL, а не плейсхолдер. Учитывая все тонкости с идентификаторами, такой тест может выглядеть так:
func testThatUserWithPhotoHasImageFromURL() {
let application = XCUIApplication()
application.launch()
// Поиск компонента Avatar на экране пользователя
let userAvatar = application
.descendants(matching: .other)
.matching(identifier: UserAccessibility.avatar)
.firstMatch
// Поиск изображения внутри Avatar
let avatarImage = userAvatar
.descendants(matching: .image)
.matching(identifier: AvatarAccessibility.image)
.firstMatch
// Ожидание завершения загрузки и появления изображения пользователя
if !avatarImage.waitForExistence(timeout: 4) {
XCTFail("User photo not found")
}
// Поиск плейсхолдера внутри Avatar
let avatarPlaceholder = userAvatar
.descendants(matching: .image)
.matching(identifier: AvatarAccessibility.placeholder)
.firstMatch
// Проверка, что плейсхолдера нет
XCTAssertFalse(avatarPlaceholder.exists)
}
Такую реализацию можно назвать типичной, многие проекты используют транзитивный поиск элементов в своих UI-тестах, чтобы те были честными, но у него все же есть ряд недостатков...
Недостатки типичного подхода
Уверен, что код последнего теста мало кому понравился, его трудно назвать приятным и лаконичным. Да, его можно улучшить, например, добавив более удобные методы в расширение XCUIElement
, но это будет полумерой. Чтобы выкрутить синтаксис тестов на полную, нужно разобраться с самим механизмом идентификации элементов на стороне UI.
Давайте взглянем на наши идентификаторы ещё раз:
enum AvatarAccessibility {
static let image = "AvatarImage"
static let placeholder = "AvatarPlaceholder"
}
enum UserAccessibility {
static let avatar = "UserAvatar"
static let name = "UserName"
static let position = "UserPosition"
}
Очевидных проблем здесь несколько:
Нет гарантий уникальности идентификаторов: ничего не помешает сделать их значения одинаковыми
Нет валидации префиксов: они критичны для тестов, но про них легко забыть
Дублирование префиксов: они повторяются в каждой строке, даже в самом названии типа
Сложность отладки: название константы может сильно различаться с его значением, что затрудняет разбор логов
Но есть и менее очевидная проблема: идентификаторы не связаны с типом компонента. Если наш экран пользователя начнёт отображать несколько компонентов Avatar
, например, внутри карусели, то тесты могут даже не падать, так как будут находить и проверять первый элемент в этой карусели.
Получается, такой подход к идентификации элементов сильно зависит от человеческого фактора. Ошибки легко пропустить, а тесты при этом продолжают быть зелёными, но перестают гарантировать корректность интерфейса.
Теперь, когда мы разобрались, как устроены UI-тесты и какие у них есть проблемы, пора посмотреть и на решение этих проблем.
Рафинированная реализация UI-тестов
Решение всех проблем на самом деле простое — строго типизировать источник идентификаторов и максимально переиспользовать информацию о типах в тестах. Это позволит отлавливать большинство ошибок ещё на этапе компиляции и полностью исключить псевдозеленые тесты. Но как это сделать?
Для этого Rafinad вводит новый термин — accessibility-схема. Это и есть строго типизированная замена наших идентификаторов, без статических констант и самих строк. Вместо них — класс и обычные поля с типом схемы внутренних элементов.
На примере нашего переиспользуемого компонента Avatar
его accessibility-схема выглядит так:
import Rafinad
class AvatarAccessibility: ViewAccessibility {
let image = ImageAccessibility()
let placeholder = ImageAccessibility()
}
Давайте знакомиться с новыми типами — всего встроенных accessibility-схем совсем немного:
ViewAccessibility
: базовая схема для всех компонентовImageAccessibility
: примитивная схема изображенийTextAccessibility
: примитивная схема текстовых компонентовAnyAccessibility
: схема компонентов с неизвестным типомScreenAccessibility
: базовая схема экрана
Этот набор позволяет описывать структуру любого компонента и экрана в целом, а дополнительные примитивы могут быть легко добавлены самостоятельно при необходимости.
Как теперь установить идентификатор компонента?
UI-тесты в iOS работают с системой Accessibility, которая позволяет идентифицировать элементы только строкой, которых у нас больше нет.
Поэтому Rafinad вводит второй новый термин — accessibility-ключ. Это замена стандартного идентификатора, который позволяет идентифицировать компоненты полями из accessibility-схемы, используя их Key-Path.
Чтобы установить accessibility-ключ для компонента в SwiftUI, достаточно использовать модификатор accessibilityKey(_:)
. В случае нашего компонента Avatar
это выглядит так:
import SwiftUI
struct Avatar: View {
let url: URL?
var body: some View {
AsyncImage(url: url) { image in
image
.resizable()
// Установка accessibility-ключа для изображения по URL
.accessibilityKey(\AvatarAccessibility.image)
} placeholder: {
Image(systemName: "person.crop.circle")
.resizable()
.foregroundStyle(.secondary)
// Установка accessibility-ключа для плейсхолдера
.accessibilityKey(\AvatarAccessibility.placeholder)
}
.clipShape(.capsule)
// Создание accessibility-контейнера, чтобы находить элемент в тестах
.accessibilityElement(children: .contain)
}
}
Модификатор accessibility-ключа устанавливает стандартный идентификатор компонента именно таким, каким является сама запись key-path с полным названием типа, но без символа \
. То есть фактическими идентификаторами элементов Avatar
будут строки "AvatarAccessibility.image"
и "AvatarAccessibility.placeholder"
. Это может быть полезно для разбора логов и отладки тестов в целом.
Также такой подход решает проблему уникальности идентификаторов, так как компилятор не даст добавить два одинаковых поля в accessibility-схему. А наличие названия типа в идентификаторе исключает проблемы с префиксами, которые мы рассматривали ранее.
Как добавить схему для экрана?
Accessibility-схема экрана аналогична переиспользуемым компонентам, отличается только базовый класс — ScreenAccessibility
. Пример схемы для нашего экрана пользователя выглядит так:
import Rafinad
class UserAccessibility: ScreenAccessibility {
let avatar = AvatarAccessibility()
let name = TextAccessibility()
let position = TextAccessibility()
}
Останется заменить идентификаторы на accessibility-ключи в самом коде экрана:
import SwiftUI
struct UserView: View {
let user: User
var body: some View {
VStack(spacing: .zero) {
Avatar(url: user.photoURL)
.frame(width: 120, height: 120)
// Установка ключа для компонента Avatar
.accessibilityKey(\UserAccessibility.avatar)
Text(user.name)
.font(.largeTitle)
// Установка ключа для имени
.accessibilityKey(\UserAccessibility.name)
.padding(.top, 24)
Text(user.position)
.font(.title3)
.foregroundStyle(.secondary)
// Установка ключа для должности
.accessibilityKey(\UserAccessibility.position)
.padding(.top, 4)
}
}
}
Как видно из нашего примера, всё довольно просто, сам алгоритм на стороне UI мало чем отличается от типичного подхода, который мы рассматривали ранее. Но вот в тестах всё меняется кардинально...
Как выглядят тесты?
Наконец-то добрались до самой вкусной части — до синтаксиса тестов. Тут предлагаю передохнуть и сначала насладиться чистотой «рафинированного» кода, потом пойдём в нём разбираться:
import XCTest
import RafinadTesting
@MainActor
final class UserScreenTests: XCTestCase {
let application = XCUIApplication()
func testThatUserNameIsCorrect() {
application.launch()
application
.screen(of: UserAccessibility.self) // получение экрана пользователя
.name // получение элемента имени
.assert(text: "Steve Jobs") // проверка, что имя равно ожидаемому
}
func testThatUserWithPhotoHasImageFromURL() {
application.launch()
let avatar = application
.screen(of: UserAccessibility.self) // получение экрана пользователя
.avatar // получение компонента Avatar
avatar
.image // получение элемента изображения внутри Avatar
.waitForExistence(timeout: 4) // ожидание появления изображения
avatar
.placeholder // получение элемента плейсхолдера внутри Avatar
.assert(isExist: false) // проверка, что плейсхолдера нет
}
}
Весь «сахар» начинается с вызова метода screen(of:)
, он есть у каждого экземпляра XCUIElement
, включая его наследника XCUIApplication
. Этот метод принимает тип accessibility-схемы и создает специальную обертку — TestingElement
. Она выполняет действия и осуществляет поиск внутри иерархии обернутого элемента — в нашем примере это корневой элемент всего приложения.
TestingElement
использует accessibility-схему в качестве источника данных о структуре обёрнутого элемента и за счёт неё позволяет так изящно получать вложенные компоненты. А знания о типах этих компонентов помогают защитить тесты от изменения иерархии ещё на этапе их компиляции.
Кроме получения вложенных компонентов, TestingElement
позволяет применять цепочки действий и проверок над обёрнутым элементом. Состав этих действий при этом зависит от базового класса accessibility-схемы и протоколов дополнительных возможностей.
Что за протоколы дополнительных возможностей?
Дело в том, что стандартная реализация XCUIElement
никак не ограничена типом компонента и слишком много себе позволяет, например, вводить текст в изображение. Для тестов это может стать проблемой при изменениях на стороне UI, например, когда элемент оборачивается в переиспользуемый компонент. В этом случае тест может продолжать совершать действия с самой обёрткой и сообщит о проблеме только на этапе выполнения.
Поэтому Rafinad добавляет ещё один уровень защиты тестов от подобного безобразия и позволяет декларировать дополнительные возможности компонента в его accessibility-схеме с помощью небольшого набора протоколов:
DisableableAccessibility
: возможность обрабатывать отключенное состояниеSelectableAccessibility
: возможность обрабатывать выбранное состояниеEditableAccessibility
: возможность редактирования текстаSwipeableAccessibility
: возможность выполнять жесты свайпаRotatableAccessibility
: возможность выполнять жесты поворотаPinchableAccessibility
: возможность выполнять жесты масштабирования
На примере стандартного компонента TextField
его accessibility-схема с дополнительными возможностями выглядела бы так:
import Rafinad
// Accessibility-схема стандартного компонента TextField
// с возможностью вводить текст и быть отключенным
class TextFieldAccessibility:
ViewAccessibility,
EditableAccessibility,
DisableableAccessibility { }
Благодаря этим протоколам компонент TextField
получит набор дополнительных действий в тестах, например:
func testThatSearchFieldCanTypeText() {
application.launch()
application
.screen(of: UserListAccessibility.self) // экран списка пользователей
.searchField // получение поля ввода
.tap() // тап на поле ввода
.typeText("Steve Jobs") // ввод текста
.submitByKeyboard() // сабмит текста
.assert(text: "Steve Jobs") // проверка введенного текста
}
В случае каких-то перемен в UI мы узнаем о проблемах ещё на этапе компиляции без необходимости прогонять тесты и выяснять, что же в них сломалось.
Подводя итог
Таким образом, мы разобрали типичную реализацию UI-тестов, познакомились с потенциальными проблемами и с их красивым решением в виде Rafinad. С ним код становится современным и удобным, а тесты — честными и максимально защищенными компилятором.
Увы, в одной статье невозможно рассмотреть все сценарии использования, и за бортом осталось несколько важных тем:
С ними предлагаю ознакомиться в документации — она подробно покрывает все сценарии с многочисленными примерами.
На что обратить внимание?
Как и с обычным подходом, так и с Rafinad, тесты могут не находить элементы в двух случаях:
Пропущена установка accessibility-ключа
Пропущено создание accessibility-контейнера в SwiftUI
Дело в том, что SwiftUI исключает контейнеры (HStack
, VStack
, Group
и т.д.) из иерархии accessibility-элементов, при этом установленный снаружи идентификатор заменяет идентификаторы внутренних элементов. Поэтому, если компонент отображает несколько элементов через стек или другой контейнер, то для доступа к нему в тестах следует создать accessibility-контейнер самостоятельно, используя модификатор .accessibilityElement(children: .contain)
.
Почему так мало стандартных примитивов?
Да, стандартных примитивов куда больше, чем поддерживает встроенный набор accessibility-схем. Мы умышленно не стали добавлять схемы для всех компонентов SwiftUI и UIKit.
Во-первых, стандартные компоненты редко используются в «сыром» виде, чаще оборачиваются под кастомный компонент с таким же названием, но с другой структурой и accessibility-схемой. Поэтому мы хотели максимально освободить пространство имён от лишних типов и, соответственно, конфликтов.
Во-вторых, тип большинства стандартных компонентов не интересен в тестах и может быть заменен на ViewAccessibility
, а при необходимости явной идентификации они легко могут быть реализованы самостоятельно:
import Rafinad
// Accessibility-схема стандартного компонента Divider
class DividerAccessibility: ViewAccessibility { }
Как поиграть?
В репозитории Rafinad находится простой демо-проект с готовыми экранами и тестами, его можно использовать как песочницу. Для этого достаточно выполнить команды в терминале:
git clone https://github.com/hhru/Rafinad.git
open Rafinad/Example/RafinadExample.xcodeproj
Что ещё?
Rafinad поддерживает также компоненты на UIKit, для них отличается только способ установки accessibility-ключей, который подробно описан в документации.
Также стоит отметить, что Rafinad никак не ограничивает использование шаблонов проектирования. Например, в наших тестах используется шаблон PageObject.
Увы, тесты невозможно защитить от деградации самой среды тестирования, например, от изменений на тестовом сервере или дестабилизации инфраструктуры. От подобных проблем нас спасает карантин, его реализацию мы подробно разбирали в прошлой статье — Карантин UI-тестов в iOS.
На этом всё
Буду рад обратной связи в комментариях. Пока!