Всем привет!

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

Сегодня мы перейдем от теории к практике и детальнее погрузимся в мир UI-тестов, потренируемся в их написании, рассмотрим, с какими проблемами разработчик может столкнуться в процессе покрытия приложения тестами, и предложим вам наши решения. Попробуем вместе с вами написать хороший UI-тест! )

Я, Дмитрий Жердев, iOS-разработчик компании Циан, хочу поделиться с вами нашими идеями.

Поехали!

Назначение

Зачем же нужны UI-тесты, почему нельзя обойтись модульными и Unit-тестами?

UI-тесты – тесты, которые взаимодействуют с приложением так же, как пользователь – через пользовательский интерфейс. Так что, если нужно проверить не просто бизнес-логику в отрыве от контекста, а целиком фичу вместе с ее интеграцией в приложение, стоит ими воспользоваться.

Кроме того, эти тесты максимально подходят для проверки работоспособности переходов между экранами и всего важного UI-интерактива.

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

Помимо прочего, UI-тесты не столь требовательны к реализации и позволяют обеспечить legacy-функционал покрытием без глобального рефакторинга.

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

Сквозные UI-тесты (End-to-End, в которых проходят реальные запросы на сервер, без моков) – это единственные тесты на стороне фронтенда, которые способны поймать поломку API. Несколько раз нас это выручало, ведь на стороне бэкенда тоже есть legacy, которое не полностью покрыто тестами.

Хоть UI-тесты прогоняются дольше других, но, как видно, они решают другие задачи и потому способны дать то, чего не могут обеспечить модульные и Unit-тесты. Так что они прекрасно друг друга дополняют. 

Практика в Циан

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

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

Чтобы изменить это, было принято решение писать нативные UI-тесты силами iOS-разработчиков. Теперь каждая продуктовая задача, чтобы пройти ревью, должна быть покрыта UI-тестами, написанными разработчиком.

Пишем тесты

Теперь, когда контекст есть, можно переходить к самому интересному – к написанию тестов )

В любых UI-тестах на iOS не обойтись без двух объектов.

Прежде всего, это XCUIElement, который представляет все элементы UI. Этот класс поддерживает протокол XCUIElementAttributes, то есть предоставляет атрибуты, определенные этим протоколом, такие как identifier, elementType, label.

XCUIElementQuery – класс поискового запроса XCUIElement. Чаще всего поиск осуществляется по атрибутам, определенным в XCUIElementAttributes.

Рассмотрим простой пример. Есть экран с двумя кнопками.

Первая кнопка – с заголовком “Button”, вторая – с картинкой-многоточием.

Код экрана выглядит следующим образом:

SimpleViewController.swift
import UIKit

final class SimpleViewController: UIViewController {
    @IBOutlet private weak var stackView: UIStackView!

    override func viewDidLoad() {
        super.viewDidLoad()

        setupSubviews()
    }

    private func setupSubviews() {
        view.accessibilityIdentifier = "SimpleView"
        stackView.accessibilityIdentifier = "SuccessView"

        let button1 = createButton()
        button1.setTitle("Button",
                         for: .normal)
        stackView.addArrangedSubview(button1)

        let button2 = createButton()
        button2.setImage(UIImage(named: "more"),
                         for: .normal)
        stackView.addArrangedSubview(button2)
    }
}

createButton() – создание и стилизацию кнопок оставим за скобками.

Проверим интерактив второй кнопки:

import XCTest
 
final class UITests: XCTestCase {
    func testSimple() throws {
        XCUIApplication().launch()

        let button2 = XCUIApplication().buttons["more"]
        XCTAssert(button2.exists)
        button2.tap()
    }
}

Этот тест проходит успешно, но чем он плох: мы осуществляем поиск кнопки по кодовому названию asset’а картинки – прямо скажем, так себе решение. Думаю, объяснять излишне, что это очень неустойчивый способ, картинка и текст могут поменяться в любой момент, и без правок тест упадет. Попробуем совершить поиск с помощью идентификатора. Добавим в SimpleViewController строчку:

button2.accessibilityIdentifier = "Button"
Стало | Было

В самом тесте заменим

let button2 = XCUIApplication().buttons["more"]

на

let button2 = XCUIApplication().buttons["Button"]
Стало | Было

Запускаем, и тест падает на последней строчке с ошибкой:

Failed to get matching snapshot: Multiple matching elements found for <XCUIElementQuery: 0x60000215ae40>

При этом проверка XCTAssert(button2.exists) проходит успешно.

Давайте разбираться. Ошибка говорит нам о том, что найдено больше одного элемента по такому запросу. Что из себя представляет значение button2, если вывести его в консоль?

Output: {
  Button, {{8.0, 107.0}, {412.0, 30.0}}, label: 'Button'
  Button, {{8.0, 147.0}, {412.0, 18.0}}, identifier: 'Button', label: 'more'
}

Мы видим, что обе наши кнопки попали в результат поиска.

Документация Apple говорит нам, что subscript(_:) XCUIElementQuery возвращает дочерний элемент, соответствующий идентификатору, указанному в ключе (дословно: “Returns a descendant element matching the identifier specified by key”). Странно! У первой кнопки нет идентификатора. Установим ей идентификатор, отличный от идентификатора второй кнопки:

button1.accessibilityIdentifier = "Button1"
Стало | Было

Тест падает на той же строчке с той же ошибкой. Значение button2 при этом такое:

Output: {
  Button, {{8.0, 107.0}, {412.0, 30.0}}, identifier: 'Button1', label: 'Button'
  Button, {{8.0, 147.0}, {412.0, 18.0}}, identifier: 'Button', label: 'more'
}

Перепишем тест более явно:

func testSimple() throws {
    XCUIApplication().launch()

    let button2 = XCUIApplication()
        .descendants(matching: .button)
        .matching(identifier: "Button")
        .element
    XCTAssert(button2.exists)
    button2.tap()
}
Стало | Было

Результат тот же (на самом деле, эти две записи эквивалентны). Значит, заявлено как поиск по идентификатору? На деле не по идентификатору, а по чему придется )

Разумеется, самый простой способ заставить наш тест работать – это сделать идентификатор второй кнопки не просто "Button", а "Button2". Но глобально хотелось бы разобраться, найти такое решение, при котором не пришлось бы постоянно следить не только за тем, чтобы все идентификаторы UI-элементов на экране были уникальными, но и за тем, чтобы они не совпадали с другими их атрибутами.

Какой же может быть способ явно осуществить поиск идентификатору? Такое решение – NSPredicate. Перепишем тест снова:

func testSimple() throws {
    XCUIApplication().launch()

    let button2ID = "Button"
    let predicate = NSPredicate(format: "identifier == '\(button2ID)'")
    let button2 = XCUIApplication()
        .descendants(matching: .button)
        .matching(predicate)
        .element
    XCTAssert(button2.exists)
    button2.tap()
}
Стало | Было

Значение button2:

Output: {
  Button, {{8.0, 147.0}, {412.0, 18.0}}, identifier: 'Button', label: 'more'
}

Наконец, победа!

Но какой ценой? Конечно, нам нравится то, что теперь поиск кнопки осуществляется явно по идентификатору, но как-то не очень хочется писать такую конструкцию при поиске каждого элемента в каждом UI-тесте.

И это не единственное неудобство, как могло бы показаться. Для такого простого экрана вопрос уникальности идентификатора UI-элементов не является проблемой. Но как быть с проверками экранов-коллекций, реализованных с помощью UITableView или UICollectionView, где есть множество повторяющихся ячеек?

Допустим, у нас ячейка с лейблом и кнопкой:

Код ячейки
import UIKit

final class ListItemCell: UITableViewCell {
    private var itemView: ListItemView!

    func update(viewModel: ListItemViewModel) {
        itemView.update(viewModel: viewModel)
    }
}

, где ListItemView:

import UIKit

final class ListItemView: UIView {
    private var title: UILabel!
    private var infoButton: UIButton!

    func update(viewModel: ListItemViewModel) {
        title.text = viewModel.title
    }
}

и ListItemViewModel:

import Foundation

struct ListItemViewModel {
    var id: String
    var title: String
}

Код viewController’а:

ListViewController.swift
import UIKit

final class ListViewController: UITableViewController {
    let items: [ListItemViewModel] = {
        return [
            .init(id: "1", title: "First"),
            .init(id: "2", title: "Second"),
            .init(id: "3", title: "Third"),
            .init(id: "4", title: "Fourth"),
            .init(id: "5", title: "Fifth"),
            .init(id: "6", title: "Sixth"),
            .init(id: "7", title: "Seventh"),
            .init(id: "8", title: "Eighth"),
            .init(id: "9", title: "Ninth")
        ]
    }()

    override func viewDidLoad() {
        super.viewDidLoad()

        title = "List"
        setupNavigationItemBackButton()

        view.accessibilityIdentifier = "ListView"

        tableView.rowHeight = 44
        tableView.register(ListItemCell.self,
                           forCellReuseIdentifier: "Cell")
    }

    override func tableView(_ tableView: UITableView,
                            numberOfRowsInSection section: Int) -> Int {
        return items.count
    }

    override func tableView(_ tableView: UITableView,
                            cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "Cell",
                                                 for: indexPath)
        if let itemCell = cell as? ListItemCell {
            itemCell.update(viewModel: items[indexPath.row])
        }
        return cell
    }

    override func tableView(_ tableView: UITableView,
                            didSelectRowAt indexPath: IndexPath) {
        let vc = SimpleViewController()
        navigationController?.pushViewController(vc, animated: true)
    }
}

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

Чтобы проверить UI-элементы ячейки, лейбл и кнопку, нам, разумеется, нужно однозначно найти эти элементы.

Что для этого мы могли бы сделать? Первое, что приходит в голову, – это сделать уникальными идентификаторы этих элементов. Перепишем метод update(viewModel:):

func update(viewModel: ListItemViewModel) {
    title.text = viewModel.title

    accessibilityIdentifier = viewModel.id
    title.accessibilityIdentifier = "Title\(viewModel.id)"
    infoButton.accessibilityIdentifier = "InfoButton\(viewModel.id)"
}
Стало | Было

Проверка UI-элементов экрана могла бы выглядеть следующим образом:

import XCTest

final class UITests: XCTestCase {
    func testList() throws {
        // Запускаем приложение
        XCUIApplication().launch()

        // Проверяем первую ячейку
        let item1TitleID = "Title1"
        let item1TitlePredicate = NSPredicate(format: "identifier == '\(item1TitleID)'")
        let item1Title = XCUIApplication()
            .descendants(matching: .staticText)
            .matching(item1TitlePredicate)
            .element
        XCTAssert(item1Title.exists)
        XCTAssertEqual(item1Title.label, "First")
    }
}

Тогда UI-тест всего флоу с переходом мог бы быть таким:

import XCTest

final class UITests: XCTestCase {
    func testFlow() throws {
        // Запускаем приложение
        XCUIApplication().launch()

        // Проверяем первую ячейку
        let item1TitleID = "Title1"
        let item1TitlePredicate = NSPredicate(format: "identifier == '\(item1TitleID)'")
        let item1Title = XCUIApplication()
            .descendants(matching: .staticText)
            .matching(item1TitlePredicate)
            .element
        XCTAssert(item1Title.exists)
        XCTAssertEqual(item1Title.label, "First")

        // Нажимаем на первую ячейку
        let item1ID = "1"
        let itemPredicate = NSPredicate(format: "identifier == '\(item1ID)'")
        let item1 = XCUIApplication()
            .descendants(matching: .other)
            .matching(itemPredicate)
            .element
        XCTAssert(item1.exists)
        item1.tap()

        // Проверяем кнопку
        let button2ID = "Button"
        let predicate = NSPredicate(format: "identifier == '\(button2ID)'")
        let button2 = XCUIApplication()
            .descendants(matching: .button)
            .matching(predicate)
            .element
        XCTAssert(button2.exists)
        button2.tap()
    }
}

Хорошо ли у нас получилось? На мой взгляд, просто безобразно!

Какие проблемы?

  1. Крайне неудобно обеспечивать полную уникальность идентификаторов проверяемых элементов

  2. Если нам потребуется покрыть UI-тестами другой кейс, который связан с этими экранами, то это приведет к дублированию кода

  3. Сам тест выглядит как полотно, он не нагляден, больше напоминает процедурное программирование, чем объектно-ориентированное

  4. Очень громоздкий поиск каждого элемента

Обо всем по порядку.

Уникальность идентификации

let button2 = XCUIApplication()
    .descendants(matching: .button)
    .matching(predicate)
    .element

Давайте посмотрим внимательнее, как здесь осуществляется поиск:

  • descendants(matching: .button) – поиск всех кнопок

  • matching(predicate) – выборка на основе предиката, в котором прописано правило соответствия идентификатору

  • element – элемент, соответствующий запросу

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

Неужели нам действительно требуется обеспечить уникальность идентификаторов не только внутри одного экрана приложения, но и внутри всех экранов?

К счастью, нет, и решение кроется как раз в используемом нами методе XCUIElement descendants(matching:). Этот метод осуществляет поиск элементов только внутри иерархии вызываемого элемента.

Перепишем сначала ячейку. В методе update(viewModel:) нам больше не требуется менять идентификаторы subviews. Вместо этого нам достаточно установить их один раз:

private func setupAccessibility() {
    title.accessibilityIdentifier = "Title"
    infoButton.accessibilityIdentifier = "InfoButton"
}

func update(viewModel: ListItemViewModel) {
    title.text = viewModel.title

    accessibilityIdentifier = viewModel.id
}
Стало | Было

Мы оставили за скобками вызов setupAccessibility() в инициализаторах.

Теперь у абсолютно всех экземпляров ячеек идентификаторы лейблов title принимают значение "Title".

Теперь перепишем тест:

func testFlow() throws {
    // Запускаем приложение
    XCUIApplication().launch()

    // Получаем вью экрана-списка
    let listViewID = "ListView"
    let listViewPredicate = NSPredicate(format: "identifier == '\(listViewID)'")
    let listView = XCUIApplication()
        .descendants(matching: .table)
        .matching(listViewPredicate)
        .element

    // Находим первую ячейку
    let item1ID = "1"
    let itemPredicate = NSPredicate(format: "identifier == '\(item1ID)'")
    let item1 = listView
        .descendants(matching: .other)
        .matching(itemPredicate)
        .element

    // Проверяем первую ячейку
    XCTAssert(item1.exists)

    let item1TitleID = "Title"
    let item1TitlePredicate = NSPredicate(format: "identifier == '\(item1TitleID)'")
    let item1Title = item1
        .descendants(matching: .staticText)
        .matching(item1TitlePredicate)
        .element
    XCTAssert(item1Title.exists)
    XCTAssertEqual(item1Title.label, "First")

    // Нажимаем на первую ячейку
    item1.tap()

    // Переходим на другой экран
    let simpleViewID = "SimpleView"
    let simpleViewPredicate = NSPredicate(format: "identifier == '\(simpleViewID)'")
    let simpleView = XCUIApplication()
        .descendants(matching: .other)
        .matching(simpleViewPredicate)
        .element

    // Проверяем кнопку
    let button2ID = "Button"
    let predicate = NSPredicate(format: "identifier == '\(button2ID)'")
    let button2 = simpleView
        .descendants(matching: .button)
        .matching(predicate)
        .element
    XCTAssert(button2.exists)
    button2.tap()
}
Стало | Было

Прогоняем – работает!

Первая проблема решена: с идентификаторами работать стало удобнее. Осталось еще три, да еще и кода стало намного больше )

Дублирование и читабельность кода

Решением следующих двух проблем является шаблон, общепринятый при написании UI-тестов: PageObject. Это класс, представляющий экран приложения. Он содержит в себе всю информацию и логику, связанную с поиском элементов на экране и действиями с ними. Тестовый код взаимодействует с приложением через такие объекты.

Сейчас не будем подробно разбирать PageObject, в интернете достаточно много ресурсов, описывающих его.

Рассмотрим, как могли бы выглядеть PageObject’ы наших экранов. 

SimplePage – PageObject для SimpleViewController’а:

import XCTest

struct SimplePage {
    private let application = XCUIApplication()

    var view: XCUIElement {
        let id = "SimpleView"
        let predicate = NSPredicate(format: "identifier == '\(id)'")
        return XCUIApplication().descendants(matching: .other)
            .matching(predicate).element
    }

    var successView: XCUIElement {
        let id = "SuccessView"
        let predicate = NSPredicate(format: "identifier == '\(id)'")
        return XCUIApplication().descendants(matching: .other)
            .matching(predicate).element
    }

    var button1: XCUIElement {
        let id = "Button1"
        let predicate = NSPredicate(format: "identifier == '\(id)'")
        return XCUIApplication().descendants(matching: .button)
            .matching(predicate).element
    }

    var button2: XCUIElement {
        let id = "Button"
        let predicate = NSPredicate(format: "identifier == '\(id)'")
        return XCUIApplication().descendants(matching: .button)
            .matching(predicate).element
    }
}

ListPage – PageObject для ListViewController’а:

import XCTest

struct ListPage {
    private let application = XCUIApplication()

    var view: XCUIElement {
        let id = "ListView"
        let predicate = NSPredicate(format: "identifier == '\(id)'")
        return XCUIApplication().descendants(matching: .table)
            .matching(predicate).element
    }

    private func item(id: String) -> XCUIElement {
        let predicate = NSPredicate(format: "identifier == '\(id)'")
        return view.descendants(matching: .other)
            .matching(predicate).element
    }

    func itemTitle(itemID: String) -> XCUIElement {
        let id = "Title"
        let predicate = NSPredicate(format: "identifier == '\(id)'")
        return item(id: itemID).descendants(matching: .staticText)
            .matching(predicate).element
    }

    func itemInfoButton(itemID: String) -> XCUIElement {
        let id = "InfoButton"
        let predicate = NSPredicate(format: "identifier == '\(id)'")
        return item(id: itemID).descendants(matching: .button)
            .matching(predicate).element
    }
}

extension ListPage {
    func openSimplePage(byItemID itemID: String) -> SimplePage {
        let itemElement = item(id: itemID)
        itemElement.tap()
        return SimplePage()
    }
}

Перепишем теперь наш тест, используя эти объекты:

func testFlow() throws {
    // Запускаем приложение
    XCUIApplication().launch()

    // Получаем вью экрана-списка
    let listPage = ListPage()

    // Проверяем первую ячейку списка
    let item1Title = listPage.itemTitle(itemID: "1")
    XCTAssert(item1Title.exists)
    XCTAssertEqual(item1Title.label, "First")

    // Переходим на другой экран
    let simplePage = listPage.openSimplePage(byItemID: "1")

    // Проверяем кнопку
    let button2 = simplePage.button2
    XCTAssert(button2.exists)
    button2.tap()
}
Стало | Было

Уже намного лучше. Мы избавились от проблемы дублирования кода для других тестов, связанных с этими экранами, а также структурировали все с помощью PageObject’ов. Сам тест стал куда читабельнее и лаконичнее.

Но можно еще лучше.

Громоздкий поиск

Вернемся к PageObject для экрана ListViewController:

ListPage.swift
import XCTest

struct ListPage {
    private let application = XCUIApplication()

    var view: XCUIElement {
        let id = "ListView"
        let predicate = NSPredicate(format: "identifier == '\(id)'")
        return XCUIApplication().descendants(matching: .table)
            .matching(predicate).element
    }

    private func item(id: String) -> XCUIElement {
        let predicate = NSPredicate(format: "identifier == '\(id)'")
        return view.descendants(matching: .other)
            .matching(predicate).element
    }

    func itemTitle(itemID: String) -> XCUIElement {
        let id = "Title"
        let predicate = NSPredicate(format: "identifier == '\(id)'")
        return item(id: itemID).descendants(matching: .staticText)
            .matching(predicate).element
    }

    func itemInfoButton(itemID: String) -> XCUIElement {
        let id = "InfoButton"
        let predicate = NSPredicate(format: "identifier == '\(id)'")
        return item(id: itemID).descendants(matching: .button)
            .matching(predicate).element
    }
}

extension ListPage {
    func openSimplePage(byItemID itemID: String) -> SimplePage {
        let itemElement = item(id: itemID)
        itemElement.tap()
        return SimplePage()
    }
}

Не многовато ли копипаста при поисках элементов? Вынесем общую логику поиска в extension:

import XCTest

extension XCUIElement {
    func descendantElement(matching elementType: XCUIElement.ElementType,
                           identifier: String? = nil) -> XCUIElement {
        var query = descendants(matching: elementType)
        if let identifier = identifier {
            let predicate = NSPredicate(format: "identifier == '\(identifier)'")
            query = query.matching(predicate)
        }
        return query.element
    }
}

И все равно есть к чему стремиться.

Сейчас доступ к каждому атомарному UI-элементу в ячейке – это новый метод в ListPage. Сейчас таких элементов 2, а что будет, когда станет больше?.. А если эти ячейки будут использоваться не на одном экране?.. Напрашивается вынос ячейки в отдельный объект, чтобы избежать дублирования кода.

Но как бы сделать это элегантно, чтобы сохранить преимущества поиска элемента с использованием иерархии? При таком подходе мы бы сильно уменьшили головную боль с уникальностью идентификаторов UI-элементов.

Так мы пришли к нашему решению, которое является развитием концепции PageObject. Мы назвали его CianUIElementsBrowser.

CianUIElementsBrowser

Этот механизм базируется на трех основных сущностях, в которых легко прослеживается параллель с Web-браузером:

  • SearchRequest – информационный объект параметров поиска элемента (аналог URL);

  • ElementBrowser – протокол с абстрактной реализацией рекурсивной логики поиска (аналог поисковика);

  • И самое главное: реализации ElementBrowser, которые предназначены для поиска и мониторинга конкретных элементов, а также реализации логики тестирования интерактива с ними (собственно, сам браузер)

Рассмотрим подробнее.

SearchRequest:

import XCTest

struct SearchRequest {
    var id: String?
    var type: XCUIElement.ElementType
    var label: String?
    var value: String?

    init(id: String? = nil,
         type: XCUIElement.ElementType = .any,
         label: String? = nil,
         value: String? = nil) {
        self.id = id
        self.type = type
        self.label = label
        self.value = value
    }
}

Как видно, SearchRequest – это структура, содержащая параметры поиска, в качестве которых выступают атрибуты искомого UI-элемента.

ElementBrowser:

import XCTest

protocol ElementBrowser {
    // MARK: - Initial Properties

    var request: SearchRequest { get }
    /// Запрос с информацией о родителе искомого элемента (предпочтительнее)
    var parentBrowser: ElementBrowser? { get }
    /// Конкретный XCUIElement родителя искомого элемента
    var parentElement: XCUIElement? { get }

    // MARK: - Search Results

    /// Найденный XCUIElement
    var view: XCUIElement { get }
    /// Найденный XCUIElement родителя
    var parentView: XCUIElement? { get }
}

ElementBrowser – это протокол, основа наших будущих PageObject’ов. Предполагается инициализировать объекты с SearchRequest. Также для поиска с иерархией целесообразно сообщить информацию о родителе UI-элемента.

Перейдем к реализации.

Прежде всего рассмотрим, каким образом атрибуты SearchRequest будут использоваться при поиске XCUIElement’а:

import XCTest

private extension SearchRequest {
    var predicate: NSPredicate? {
        var parts = [String]()
        if let id = id, !id.isEmpty {
            parts.append("(identifier == '\(id)')")
        }
        if let label = label {
            parts.append("(label == '\(label)')")
        }
        if let value = value {
            parts.append("(value == '\(value)')")
        }

        if !parts.isEmpty {
            let format = parts.joined(separator: " AND ")
            return NSPredicate(format: format)
        } else {
            return nil
        }
    }
}

Но самое интересное – это рекурсивный поиск view для ElementBrowser с использованием информации о родителе:

extension ElementBrowser {
    // MARK: - Initial Properties

    var parentElement: XCUIElement? {
        return nil
    }
    var parentBrowser: ElementBrowser? {
        return nil
    }

    // MARK: - Search Results

    var view: XCUIElement {
        return recursiveSearchElement()
    }
    var parentView: XCUIElement? {
        if let parentElement = parentElement {
            return parentElement
        } else {
            return parentBrowser?.recursiveSearchElement()
        }
    }

    // MARK: - Internal

    private func recursiveSearchElement() -> XCUIElement {
        let query: XCUIElementQuery
        if let parentView = parentView {
            query = parentView.descendants(matching: request.type)
        } else {
            query = XCUIApplication().descendants(matching: request.type)
        }

        if let predicate = request.predicate {
            return query.element(matching: predicate)
        } else {
            return query.element
        }
    }
}

С такими extension’ами простейшая реализация может выглядеть так:

import XCTest

final class SimpleElementBrowser: ElementBrowser {
    var request: SearchRequest
 
    var parentElement: XCUIElement?
    var parentBrowser: ElementBrowser?

    init(request: SearchRequest = SearchRequest(),
         parentElement: XCUIElement? = nil,
         parentBrowser: ElementBrowser? = nil) {
        self.request = request
        self.parentElement = parentElement
        self.parentBrowser = parentBrowser
    }
}

Теперь, когда мы рассмотрели реализацию CianUIElementsBrowser, воспользуемся этим инструментом и перепишем тесты.

Начнем с самого простого:

import XCTest

struct SimplePage: ElementBrowser {
    let request: SearchRequest

    var successView: ElementBrowser {
        let request = SearchRequest(id: "SuccessView",
                                    type: .other)
        return SimpleElementBrowser(request: request,
                                    parentBrowser: self)
    }

    var button1: XCUIElement {
        successView.view.descendantElement(matching: .button,
                                           identifier: "Button1")
    }

    var button2: XCUIElement {
        successView.view.descendantElement(matching: .button,
                                           identifier: "Button")
    }

    init(
        request: SearchRequest = SearchRequest(
            id: "SimpleView",
            type: .other
        )
    ) {
        self.request = request
    }
}

Теперь вынесем ячейку из ListPage:

import XCTest

struct ListItem: ElementBrowser {
    let request: SearchRequest
    let parentBrowser: ElementBrowser?

    var title: XCUIElement {
        view.descendantElement(matching: .staticText,
                               identifier: "Title")
    }

    var infoButton: XCUIElement {
        view.descendantElement(matching: .button,
                               identifier: "InfoButton")
    }

    init(id: String,
         parentBrowser: ElementBrowser? = nil) {
        self.request = SearchRequest(id: id)
        self.parentBrowser = parentBrowser
    }
}

extension ListItem {
    func check(exists: Bool = true,
               withTitle text: String) {
        XCTAssertEqual(view.exists, exists)
        if exists {
            XCTAssert(title.exists)
            XCTAssertEqual(title.label, text)
            XCTAssert(infoButton.exists)
        }
    }
}

В extension’е мы попутно реализовали метод для проверки содержимого ячейки.

ListPage теперь выглядит так:

import XCTest

struct ListPage: ElementBrowser {
    let request: SearchRequest

    func item(id: String) -> ListItem {
        ListItem(id: id,
                 parentBrowser: self)
    }

    init(
        request: SearchRequest = SearchRequest(
            id: "ListView",
            type: .table
        )
    ) {
        self.request = request
    }
}

extension ListPage {
    func openSimplePage(byItemID itemID: String) -> SimplePage {
        return openSimplePage(byItem: item(id: itemID))
    }

    func openSimplePage(byItem item: ListItem) -> SimplePage {
        item.view.tap()
        return SimplePage()
    }
}

И, собственно, сам UI-тест теперь выглядит так:

func testFlow() throws {
    // Запускаем приложение
    XCUIApplication().launch()

    // Получаем вью экрана-списка
    let listPage = ListPage()

    // Проверяем первую ячейку списка
    let item1 = listPage.item(id: "1")
    item1.check(withTitle: "First")

    // Переходим на другой экран
    let simplePage = listPage.openSimplePage(byItem: item1)

    // Проверяем кнопку
    let button2 = simplePage.button2
    XCTAssert(button2.exists)
    button2.tap()
}
Стало | Было

И он проходит!

Вот мы и решили последнюю обозначенную проблему. Кажется, недурно получилось )

Мы разобрали только основные принципы работы CianUIElementsBrowser. Этот механизм не ограничен структурой, протоколом и его дефолтной реализацией. По задумке именно конкретные реализации протокола, содержащие в себе уникальную логику проверки состояний, и реализации утилит для тестирования интерактива, превращают поисковой движок в полноценный браузер для мониторинга элементов. Неслучайно механизм назван CianUIElementsBrowser, а не CianUIElementsSearchEngine )

Именно полный комплекс инструментов позволяет ускорить разработку и упростить поддержку UI-тестов.

Работа с асинхронностью

Одним из самых распространенных кейсов при написании UI-тестов у нас является проверка UI-экрана, данные для которого загружаются не сразу, а после загрузки данных по сети. Следовательно, элементы UI появляются с задержкой.

Что я имею ввиду?

Смоделируем такое поведение у нашего SimpleViewController, когда экран загружается не сразу. Добавим лоадер, который будет отображаться при переходе, а кнопки отобразим с задержкой 2 секунды, как будто бы они отобразились после загрузки данных.

Выглядеть это будет так:

SimpleViewController.swift
import UIKit

final class SimpleViewController: UIViewController {
    @IBOutlet private weak var activityIndicator: UIActivityIndicatorView!
    @IBOutlet private weak var stackView: UIStackView!

    override func viewDidLoad() {
        super.viewDidLoad()

        setupSubviews()

        activityIndicator.startAnimating()
        stackView.isHidden = true

        DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 2) { [weak self] in
            self?.activityIndicator.stopAnimating()
            self?.stackView.isHidden = false
        }
    }

    private func setupSubviews() {
        view.accessibilityIdentifier = "SimpleView"

        activityIndicator.hidesWhenStopped = true
        activityIndicator.accessibilityIdentifier = "LoadingView"

        stackView.accessibilityIdentifier = "SuccessView"

        let button1 = createButton()
        button1.setTitle("Button",
                         for: .normal)
        button1.accessibilityIdentifier = "Button1"
        stackView.addArrangedSubview(button1)

        let button2 = createButton()
        button2.setImage(UIImage(named: "more"),
                         for: .normal)
        button2.accessibilityIdentifier = "Button"
        stackView.addArrangedSubview(button2)
    }
}
Стало | Было
Стало | Было

Запускаем наш тест, он падает.

UITests.swift

Логично, потому что на момент проверки кнопки еще нет на экране. Нам необходимо дождаться появления view, которая отображает успешно загруженные данные (в нашем случае две кнопки), и только потом проверять ее.

Для такого частого кейса мы реализовали несколько полезных утилит:

import XCTest

enum TimeConstants {
    static let defaultTimeout = TimeInterval(20)
}

extension XCTestCase {
    @discardableResult
    func waitForElementAppearance(_ element: XCUIElement,
                                  timeout: TimeInterval = TimeConstants.defaultTimeout) -> XCUIElement {
        return waitForElementPredicateExpectation(element,
                                                  predicate: NSPredicate(format: "exists == 1"),
                                                  timeout: timeout)
    }

    @discardableResult
    func waitForElementDisappearance(_ element: XCUIElement,
                                     timeout: TimeInterval = TimeConstants.defaultTimeout) -> XCUIElement {
        return waitForElementPredicateExpectation(element,
                                                  predicate: NSPredicate(format: "exists == 0"),
                                                  timeout: timeout)
    }

    @discardableResult
    func waitForElementPredicateExpectation(_ element: XCUIElement,
                                            predicate: NSPredicate,
                                            timeout: TimeInterval = TimeConstants.defaultTimeout) -> XCUIElement {
        expectation(for: predicate,
                    evaluatedWith: element,
                    handler: nil)
        waitForExpectations(timeout: timeout,
                            handler: nil)
        return element
    }
}

Названия первых двух функций говорят сами за себя )

Функция waitForElementPredicateExpectation довольно универсальная, она предназначена не только для ожидания появления или скрытия элементов. Это ожидание соответствия элемента любому предикату.

Во всех этих функциях определен таймаут, по истечении которого, если не будут выполнены необходимые условия, тест упадет.

Аналогично реализованы еще два extenison’а:

Первый – для нативного XCUIElement:

XCUIElement+Wait.swift
import XCTest

extension XCUIElement {
    @discardableResult
    func waitForAppearance(timeout: TimeInterval = TimeConstants.defaultTimeout) -> XCTWaiter.Result {
        return waitForPredicateExpectation(predicate: NSPredicate(format: "exists == 1"),
                                           timeout: timeout)
    }

    @discardableResult
    func waitForDisappearance(timeout: TimeInterval = TimeConstants.defaultTimeout) -> XCTWaiter.Result {
        return waitForPredicateExpectation(predicate: NSPredicate(format: "exists == 0"),
                                           timeout: timeout)
    }

    @discardableResult
    func waitForPredicateExpectation(predicate: NSPredicate,
                                     timeout: TimeInterval = TimeConstants.defaultTimeout) -> XCTWaiter.Result {
        let expectation = XCTNSPredicateExpectation(predicate: predicate,
                                                    object: self)
        return XCTWaiter().wait(for: [expectation],
                                timeout: timeout)
    }
}

И второй – для нашего ElementBrowser:

ElementBrowser+Wait.swift
import XCTest

extension ElementBrowser {
    @discardableResult
    func waitForAppearance(timeout: TimeInterval = TimeConstants.defaultTimeout) -> XCTWaiter.Result {
        return waitForPredicateExpectation(predicate: NSPredicate(format: "exists == 1"),
                                           timeout: timeout)
    }

    @discardableResult
    func waitForDisappearance(timeout: TimeInterval = TimeConstants.defaultTimeout) -> XCTWaiter.Result {
        return waitForPredicateExpectation(predicate: NSPredicate(format: "exists == 0"),
                                           timeout: timeout)
    }

    @discardableResult
    func waitForPredicateExpectation(predicate: NSPredicate,
                                     timeout: TimeInterval = TimeConstants.defaultTimeout) -> XCTWaiter.Result {
        let expectation = XCTNSPredicateExpectation(predicate: predicate,
                                                    object: view)
        return XCTWaiter().wait(for: [expectation],
                                timeout: timeout)
    }
}

Использовать первый или второй – решает разработчик в зависимости от конкретной ситуации.

Основное отличие в работе функций последних двух extension’ов – то, что в случае неудачи тест не падает, функции возвращают результат ожидания, который разработчик теста может использовать по своему усмотрению.

Теперь мы можем поправить наш тест, а точнее функцию openSimplePage(byItem:) у ListPage:

func openSimplePage(byItem item: ListItem) -> SimplePage {
    item.view.tap()
    let simplePage = SimplePage()
    let result = simplePage.successView.waitForAppearance()
    if result != .completed {
        XCTFail("Успешные данные не отобразились")
    }
    return simplePage
}
Стало | Было

Запускаем – работает!

На практике может потребоваться не только дождаться появления элемента на экране (или его скрытия), но и его определенного состояния. Для этого как раз помогут функции waitForElementPredicateExpectation / waitForPredicateExpectation.

Коротко о других инструментах

Асинхронность – это только одна из утилит, которые содержит в себе наш CianUIElementsBrowser. У нас реализованы такие вещи, как:

  • утилиты навигации;

  • работа с текстовыми полями;

  • помощь в скролле к искомой ячейке коллекции, а там есть нюансы за счет механизма реюза;

  • для всех компонентов (вьюхи и контролы) нашей дизайн-системы реализованы соответствующие ElementBrowser’ы с массой полезных утилит;

  • и т. д.

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

Можно добавить, что при разработке этих инструментов очень важно учитывать нюансы поведения компонентов UIKit, что является трудной задачей даже для опытного автоматизатора. Самое печальное, что в некоторых случаях тесты без заложенной в них проработки асинхронности могут падать не в 100 % случаев. Очевидно, что без детальной проработки инструментария и выработки best practices стабильность тестов резко упадет. Согласитесь, это еще один довольно весомый аргумент в пользу написания тестов разработчиками.

Бонус

End-to-End и моки

Самые важные пользовательские сценарии мы покрываем End-to-End тестами (сквозными, с реальными запросами на бэкенд). В этих тестах проверяются критичный функционал со всеми переходами и основные элементы UI тестируемого флоу.

Для более детального тестирования UI мы пишем тесты на моках, так как состав экрана во многом зависит от ответа с бэкенда, а в нашем продукте контент меняется очень быстро (то квартиру продали, то цену поменяли и т. д.), поэтому детальные проверки без моков сделали бы UI-тесты очень нестабильными. А вот в тестах на моках мы уже можем проверять UI экранов вдоль и поперек.

Для моков сетевых запросов мы используем фреймворк SBTUITestTunnel.

Однако это не единственные возможности данного фреймворка. Он позволяет отправить команду основному проекту на выполнение зарегистрированного блока кода. На основе этого механизма мы реализовали открытие универсальных ссылок.

Так что мы пользуемся не только своими решениями )

UI-тесты и VoiceOver

При написании тестов мы упоминали про XCUIElementAttributes, по которым происходит поиск XCUIElement’ов.

Для ясности разберем, откуда свойства XCUIElementAttributes берут свои значения.

Атрибут identifier соответствует свойству accessibilityIdentifier NSObject, наследником которого является UIView.

Что касается таких атрибутов, как elementType, label, value, то они отражают то, как элементы будут проговариваться пользователю при включенном режиме VoiceOver. Системные элементы UI по умолчанию содержат в себе логику предоставления этой информации. Например, для UIButton elementType == .button, а значение label приравнивается к заголовку, а если он пуст, то к кодовому названию картинки в image assets. Понятное дело, что это не лучшим образом донесет до пользователя назначение данной кнопки. Повлиять на дефолтное значение можно с помощью протокола UIAccessibility, который поддерживает UIView и его сабклассы. Для этого нужно просто выставить значения свойствам:

  • isAccessibilityElement – может ли пользователь взаимодействовать с элементом в режиме VoiceOver;

  • accessibilityTraits – тип элемента, сообщающий пользователю ожидания от его поведения (кнопка/текст/переключатель и т. п.);

  • accessibilityLabel – поясняющая метка;

  • accessibilityValue – значение изменяемого элемента (интерактивного), которое не может быть описано с помощью accessibilityLabel;

  • accessibilityHint – дополнительная подсказка

  • и т. д.

Так что выставить значение accessibilityLabel для кнопки с картинкой без текста более чем оправдано. В большинстве же случаев при работе с системными вьюхами разработчику не требуется выставлять значения вручную, они уже имеют корректное значение.

Подытожим: протокол UIAccessibility не предназначен для разметки с целью поиска элементов в UI-тестах. Это можно использовать и проверять значения в UI-тестах, но все же следует помнить про пользователей.

В одном из методов примера мы проверяем значение атрибута label:

extension ListItem
extension ListItem {
    func check(exists: Bool = true,
               withTitle text: String) {
        XCTAssertEqual(view.exists, exists)
        if exists {
            XCTAssert(title.exists)
            XCTAssertEqual(title.label, text)
            XCTAssert(infoButton.exists)
        }
    }
}

Порой для оптимальной работы в режиме VoiceOver полезно создавать экземпляр UIAccessibilityElement. Это полезно знать, хоть и не имеет прямого отношения к UI-тестам, так что не будем утомлять этим, статья и так получилась немаленькой. Если интересно, об этом неплохо написано в документации Apple.

Нюансы про видимость элементов в UI-тестах

По нашим наблюдениям, начиная разработку UI-тестов, разработчики часто сталкиваются с проблемами, когда они пытаются в UI-тестах проверить вьюху, но не могут ее найти.

В этом разделе мы постарались собрать наиболее типовые случаи. Некоторые из них могут показаться очевидными, но все же их лучше проговорить.

Прежде всего, если у UIView isHidden == true, то она не будет найдена ни в VoiceOver, ни в UI-тестах со всеми своими потомками.

Элемент в UI-тесте не будет найден, если одна из его superview имеет нулевую высоту или ширину. Даже если у superview clipToBounds == false и искомая вьюха отображается корректно, она не будет найдена, так как в целях оптимизации в UI-тестах поиск по вьюхам с нулевыми размерами не производится.

Если у вьюхи значение свойства isAccessibilityElement == true, то ни одна из ее сабвьюх в UI-тесте не будет найдена. Экземпляры UIView с isAccessibilityElement == false недоступны для функции VoiceOver и не мешают доступу к потомкам. Однако они могут быть не видны в UI-тестах при попытке найти именно их. Такая проблема решается переопределением свойств протокола UIAccessibilityContainer

Хотя мы и не акцентировали на это внимание в примере, но именно для этой цели у ListItemView мы переопределили метод accessibilityElements

override var accessibilityElements: [Any]?
override var accessibilityElements: [Any]? {
    get { [title!, infoButton!] }
    set { _ = newValue }
}

Без этого ListItemView был бы “невидим” в UI-тестах.

Но стоит помнить, что основным назначением UIAccessibilityContainer является именно VoiceOver. Про него тоже неплохо написано в документации Apple.

Хоть VoiceOver и не связан напрямую с UI-тестами, но взаимосвязь все же прослеживается, так как он является частью интерфейса. Об этом стоит помнить, чтобы написание UI-тестов не мешало слабовидящим пользоваться приложением.

Заключение

На этом, пожалуй, пора закругляться.

На своем опыте мы убедились в том, что UI-тесты могут быть очень мощным инструментом для повышения качества продукта для компаний с частым релизным циклом, как у нас. Они способны значительно снизить нагрузку на QA по регресс-тестированию и помогать при рефакторинге.

Но достичь этого не так просто, как могло бы показаться вначале. UI-тесты, как и любые другие тесты, приносят пользу, только когда они стабильны, поддерживаемы и на их написание уходит приемлемое время. Для этого мы на старте приложили немало сил, чтобы разработать инструменты, проработать нюансы, собрать хорошие практики и сформировать руководство.

Ранее мы писали про пирамиду тестирования. В силу своих особенностей и назначения, UI-тесты не способны заменить другие тесты из нее (модульные и Unit-тесты), но верно и обратное утверждение: модульные и Unit-тесты не способны заменить UI-тесты. Лучше всего тесты работают в комплексе и прекрасно дополняют друг друга.

Для себя мы пришли к выводу, что вложение в UI-тесты оказалось для нас оправданным, и будем продолжать развивать эту идею. Мы рекомендуем и вам подумать в сторону UI-тестов, особенно если у вас частый релизный цикл и/или большая нагрузка на QA.

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


  1. spiceginger
    10.08.2021 16:57
    -2

    Хорошо бы упомянуть такой паттерн для UI тестов как Робот


    1. Jedr Автор
      10.08.2021 17:21
      +4

      Спасибо за комментарий!

      В данный момент мы не используем паттерн Робот. Мы не выносим действия в отдельные объекты, по сути у нас эту функцию выполняют extension'ы PageObject'ов.

      Паттерн хороший, но в данной статье мы хотели поделиться частью того, что используем сами. Статья и так получилась довольно длинная )


  1. MSMashuxa
    15.08.2021 13:11
    +1

    Здравствуйте! Очень интересная, а главное полезная статья. Спасибо!

    Кажется в тексте закралась небольшая ошибка: в выпадающей вставке "ElementBrowser+Wait.swift" продублирован код из "XCUIElement+Wait.swift".


    1. Jedr Автор
      15.08.2021 13:15
      +1

      Здравствуйте!

      Большое вам спасибо за комментарий и за то, что указали на ошибку! Вы были абсолютно правы, я ее исправил.

      Очень рад, что статья вам понравилась )