В 2021 году iOS-приложения должны строиться с расчетом на современный дизайн, обрабатывать множество одновременных запросов, работать с REST API и большими объемами статических данных. Это выдвигает высокие требования к качеству кода.
Чтобы достичь его, кажется не обойтись без автоматизированных тестов UI-интерфейса. Таким образом быстро проверяется поведение приложения при любых действиях пользователя. Фреймворк XCTest от Apple дает возможность писать UI-тесты с довольно широкой функциональностью.
Содержание
- Добавление UI-тестов в проект в Xcode
- Класс UI-теста
- Работаем с элементами интерфейса
- Утверждения (Assertions)
- Выполнение тестов
- Тестирование по методу черного ящика
Добавление UI-тестов в имеющийся проект Xcode
При добавлении UI-тестов в уже созданный проект в Xcode они вставляются в отдельную группу и получают отдельный build target; так происходит потому что UI-тесты компилируются и деплоятся в отдельном приложении — тест-раннере XCUIApplication. Код созданных тестов запускается в этом раннере, а НЕ в целевом (окончательном, target) приложении. Раннер работает как прокси, принимает логику теста, и преобразует ее в некие действия (iOS Accessibility Actions), и уже эти действия выполняются в target-приложении. Таким образом имитируется взаимодействие пользователя с приложением. Это значит, с точки зрения программиста, что тестировщик не работает непосредственно с UKit-элементами приложения (типа UILabel или UIButton), а только через прокси-элементы (XCUIElement).
Добавление в новый проект
При создании нового проекта XCode отмечаем опцию “Include Tests” (Включить тесты в проект).
После успешного создания нового проекта увидим в Навигаторе проектов (Project Navigator):
- Группу UITests, уже с созданными и включенными в проект UI-тестами
- И правее — билд нового проекта, с указанным target-приложением.
Добавление в существующий проект
Если проект уже есть и в него надо просто добавить тесты, нажимаем в главном меню XCode: File > New > Target… . Появится диалог выбора шаблона, в нем надо пролистать до раздела Test и выбрать там “UI Testing Bundle”.
После нажатия “Next”, вводим название набора тестов (группы), или XCode сгенерит нам имена по умолчанию.
Класс UI Test
Это Swift-класс, наследуемый из XCTestCase. Пройдемся по структуре этого класса.
Настройка
Класс теста может содержать лишь один метод с настройкой. Объявление метода выглядит так:
override func setUpWithError() throws
Перед запуском каждого теста XCTest вызывает метод setUpWithError(), чтобы можно было настроить тест. Мы оверрайдим метод, поскольку он наследуется от суперкласса XCTestCase.
Если непонятно, что throws делает в данном контексте — оно позволяет методу передавать ошибки, произошедшие во время выполнения, в коллер фреймворка XCTest.
XCUIApplication
Теперь вспоминаем, что написанный тест вызывается и запускается из раннера, а не из основного приложения. XCUIApplication, еще раз, является прокси-объектом, как бы отображающим основное приложение. Обычно объявляется переменная XCUIApplication в тестовом классе, и вызывается метод:
class ExampleUITests: XCTestCase { var app: XCUIApplication! override func setUpWithError() throws { try super.setUpWithError() app = XCUIApplication() app.launch() } }
Метод запуска является синхронным, он возвращает прокси-объект, отображающий основное приложение.
Или же, можно задать настройки XCUIApplication чтобы оно приняло как target предустановленное приложение на реальном iOS-устройстве. Для этого пишем: Bundle Identifier. Например чтобы запустить Safari на смартфоне:
app = XCUIApplication(bundleIdentifier: "com.apple.mobilesafari")
XCUIDevice
Фреймворк позволяет управлять ориентацией экрана, эмулировать нажатие кнопок, и даже управление через Siri, посредством прокси-объекта XCUIDevice. Он представляет собой синглтон, к которому получают доступ через его свойство .shared :
let device = XCUIDevice.shared
В этом классе часто вызывается изменение ориентации экрана. За это отвечает свойство opientation. Для задания ориентации вводится enum-значение UIDeviceOrientation. Могут быть такие ориентации:
- UIDeviceOrientation.portrait
- UIDeviceOrientation.landscapeLeft
- UIDeviceOrientation.landscapeRight
- UIDeviceOrientation.portraitUpsideDown
К примеру, задаем портретную ориентацию в методе, чтобы смартфон сохранял портретную ориентацию при выполнении UI-тестов:
class ExampleUITests: XCTestCase { var app: XCUIApplication! let device = XCUIDevice.shared override func setUpWithError() throws { try super.setUpWithError() app = XCUIApplication(bundleIdentifier: "com.apple.mobilesafari") app.launch() device.orientation = UIDeviceOrientation.portrait }
Teardown
Здесь так же: тестовый класс может содержать в себе лишь один teardown-метод. Объявляется так:
override func tearDownWithError() throws
В методе выполняются любые cleanup-задачи, или выбрасываются ошибки после завершения теста. Оверрайдим этот метод, потому что он наследуется из суперкласса XCTestCase. Обычная teardown-задача заключается в закрытии приложения во время тестирования:
override func tearDownWithError() throws { try super.tearDownWithError() app.terminate() }
Методы в тесте
В нашем классе можно создать новые тест-кейсы, путем создания новых методов, и каждый из них это отдельный UI-тест. Принято присваивать названия методам, добавляя в их начало test:
func testTapOnRefreshButton() throws { // Test logic here... }
Чтобы написать логику тест-кейса, надо немного исследовать интерфейс элементов приложения. В следующем разделе расскажем, как это.
Взаимодействие с UI-элементами
Мы уже применили концепцию XCUIApplication как прокси-объекта, отображающего основное приложение. Класс XCUIApplication наследуется из класса XCUIElement, в рамках протокола XCUIElementTypeQueryProvider. Важность этого, с точки зрения разработчика, заключается в том, что имея экземпляр XCUIApplication, мы можем отправлять ему запросы, и получать доступ к видимым элементам любого View (экрана) в приложении.
Сначала пройдемся по свойствах XCUIElementTypeQueryProvider по части идентификации элементов. Есть часто применяемые методы и свойства.
Получение элементов экрана
XCUIElementTypeQueryProvider это протокол, который синхронизируется с XCUIApplication, передающем элементы для идентификации. Каждое свойство возвращает объект XCUIElementQuery, по которому находим элемент. Ниже приведены некоторые свойства:
- var alerts: XCUIElementQuery
Доступ к любому предупреждению, если оно видимое на экране.
- var buttons: XCUIElementQuery
Доступ ко всем видимым кнопкам на экране (UIButton)
- var staticTexts: XCUIElementQuery
Доступ ко всем видимым текстовым надписям (UILabel) на экране.
- var textFields: XCUIElementQuery
Доступ ко всем полям ввода текста (UITextField)
- var tables: XCUIElementQuery
Доступ ко всем таблицам (UITableView)
- var otherElements: XCUIElementQuery
Последнее название может озадачить, а это свойство очень полезное. Оно возвращает строку, дающую доступ к другим под-экранам (UIView) в открытом экране. Иногда дизайнеры применяют UIView для создания кастомных кнопок. Такое часто делают в iOS-приложениях, написанных на React Native.
Надо учесть, что эти свойства возвращают XCUIElementQuery для всех элементов указанного типа. Можно уточнить запрос, чтобы идентифицировать отдельный элемент:
let companyNameTextElement = app.staticTexts["Tesla, Inc."]
Если строка “Tesla, Inc.” есть на экране, то companyNameTextElement будет содержать результат XCUIElement. Чуть позже рассмотрим свойства и методы XCUIElement. Однако, такой подход не очень-то гибкий. А что если название компании в другом формате? Есть два подхода для выполнения такого запроса: через строку-предикат; или через идентификатор доступности. Хотя это разные способы, оба они позволяют корректно конструировать XCUIElementQuery. Теперь выясним некоторые свойства и методы XCUIElementQuery.
- var count: Int
Обрабатывает запрос и возвращает количество элементов, отвечающих условию.
- var element: XCUIElement
Возвращает первый подходящий элемент в запросе.
- func element(matching: XCUIElement.ElementType, identifier: String?) -> XCUIElement
Возвращает элемент, соответствующий указанному типу, и строке идентификатора доступности.
Строка-предикат
Так называется некое логическое условие, созданное по соответствующей спецификации Apple. Это достаточно специфическая тема, если интересно, можно ознакомиться с предметом по ссылке. Ниже — пример, как строка, построенная по “предикативному принципу” корректирует запрос:
let predicate = NSPredicate(format: "label CONTAINS[c] 'tesla'") let textQuery = app.staticTexts.containing(predicate) // Check to see if the query contains results if textQuery.count > 0 { let companyNameTextElement = textQuery.element }
Строка-предикат делает запрос гибче: проводится проверка (без учета регистра) в тексте на наличие слова “tesla”.
Примечание: независимость от регистра — это символ [c]
Вместе с тем, метод строки-предиката должно нечасто применяться, по идее. Хотя она и дает больше гибкости, но также и больше сложности в логическом плане. Делает код автоматизации сложнее в чтении. Лучше все-таки в основном применять идентификаторы доступности для обращения к элементам.
Идентификатор доступности
Это строка, прибавляемая к UIKit-элементу, уникально идентифицирующая его в тесте. Нечто вроде как прикреплять ярлычок на странице книжки, чтобы потом быстрее открыть в нужном месте.
Сториборды
Если в приложении есть сториборды, то можно визуально выделить элемент интерфейса в сториборде, и ввести в Инспекторе (Identity Inspector) строку, содержащую Идентификатор доступности:
Swift UI
Если дизайн приложения делается при помощи SwiftUI, можно добавить в код соответствующий идентификатор, например:
Button(action: {}, label: { Text("Submit") }).accessibility(identifier: "stockTickerSymbolSubmitBtn")
React Native
Если приложение пишется на React Native, то тоже можно добавлять в код такие идентификаторы.
Далее пример, как можно найти элемент по его идентификатору:
let companyNameTextElement = app.staticTexts.element(matching: XCUIElement.ElementType.staticText, identifier: «stockNameText»)
В методе элемента мы вводим 2 параметра:
matching: Тип элемента XCUIElement
identifier: Строка с идентификатором доступности
Свойства элементов
Выше мы исследовали способы запроса элементов XCUIElement. После получения элемента, видим что есть некоторые его свойства, с которыми можно поработать:
- var exists: Bool
Существует ли такой элемент
- var isHittable: Bool
Можно ли нажать этот элемент в этом его положении
- var label: String
Атрибут подписи элемента
- var placeholderValue: String?
Значение, отображаемое в случае, если значение не задавалось
- var value: Any?
Raw-value-атрибут элемента
- var isEnabled: Bool
Можно ли взаимодействовать с элементом нажатием на него
Например. Если хотим передать строку из XCUIElement со значением UILabel на одном из экранов приложения:
let companyNameText = app.staticTexts.element(matching: XCUIElement.ElementType.staticText, identifier: "stockNameText") print("Company name text: \(companyNameText.label)")
Действия с элементами
Предусмотрено много действий с XCUIElement.
- func tap()
имитирует нажатие элемента
- func doubleTap()
имитирует двойное нажатие
- func press(forDuration: TimeInterval)
имитирует длительное нажатие (с указанием длительности)
- func typeText(String)
Вводит строку в элементе
- func swipeLeft()
Имитирует свайп влево
- func swipeRight()
Свайп вправо
- func swipeUp()
Свайп вверх
- func swipeDown()
- Свайп вниз
Пробуем: чтобы передать текст в некий XCUIElement содержащий поле UITextField и имитировать нажатие другого XCUI-элемента с UIView работающего как кастомная кнопка:
let textInputField = app.textFields.element(matching: .textField, identifier: "stockTickerSymbolSearchInput") let submitBtn = app.otherElements.element(matching: .other, identifier: "stockTickerSymbolSearchBtn") if (textInputField.exists && submitBtn.exists) { textInputField.tap() textInputField.typeText("TWTR") submitBtn.tap() }
Assertions
Концепция Assertions (Утверждения) позволяют сравнивать результат с ожидаемым значением. Применяются для проверки выполнения действий в автоматизированном тесте — отвечают ли ожидаемому поведению. В Apple предусмотрели специальный набор макросов XCTAssert, некоторые приведены ниже:
- XCTAssert(expression)
Простая проверка, правильно ли выражение. Возвращает сбой, если выражение == “false”.
- XCTAssertEqual(expression1, expression2)
Проверка на совпадение значений двух выражений. Сбой, если значения совпали. (expression1 != expression2).
- XCTAssertNotEqual(expression1, expression2)
“Утверждение”, что два выражения не имеют одинаковое значение. Сбой, если expression1 == expression2.
- XCTAssertNil(expression)
Проверка на равность 0. Сбой, если выражение != 0.
Пример:
func testExample() throws { let textInputField = app.textFields.element(matching: .textField, identifier: "stockTickerSymbolSearchInput") let submitBtn = app.otherElements.element(matching: .other, identifier: "stockTickerSymbolSearchBtn") if (textInputField.exists && submitBtn.exists) { textInputField.tap() textInputField.typeText("TWTR") submitBtn.tap() } let companyNameText = app.staticTexts.element(matching: .staticText, identifier: "stockNameText") XCTAssertEqual(companyNameText.label, "Twitter, Inc.") }
Запуск тестов
Из Навигатора тестов в XCode
Простой интуитивный запуск из IDE. В Навигаторе Тестов справа будут ромбовидные кнопки для каждого тестового класса, добавленного в группу. Доступны опции запуска:
- Всех тестов в группе (Test Group)
- Запуска одного тест-сьюта (Test Suite)
- Запуска отдельно функции (Test Case function) в тест-сьюте
Также можно запустить тестирование непосредственно в коде.
Новая Схема тестов в XCode
Обычно при тестировании создается новая билд-схема для запуска автоматических UI-тестов. Главная причина в том, что если запускать тесты из меню в XCode: Product > Test (или клавиатурной комбинацией), то например юнит-тесты (если таковые есть в проекте) будут запускаться первыми, перед UI-тестами. То есть, надо будет ждать завершения юнит-тестов, чтобы запустились UI-тесты.
Чтобы избежать этого, создаем билд-схему “только UI-тесты”. В главном меню XCode выбираем Product > Scheme > Manage Schemes… . Теперь увидим список имеющихся билд-схем проекта.
Кликаем на текущей схеме, переходим к значку настроек в левом нижнем углу списка схем, и выбираем Duplicate.
Вводим название новой схемы, кликаем на разделе Test, и снимаем галочку с первой группы, где и будут юнит-тесты.
Теперь можно запустить схему из меню Product > Test в главном меню XCode (или шоткатом).
Применение CLI-интерфейса xcodebuild
При установке XCode и его сопутствующих CLI-тулзов, ставится и xcodebuild, путь к которому прописывается в системных PATH-переменных. Можно запускать UI-тесты с xcodebuild прямо из командной строки, не открывая XCode.
$ xcodebuild \ -project "Example.xcodeproj" \ -scheme "Stock Price App UI Tests" \ -destination "platform=iOS Simulator,name=iPhone 12 Pro Max,OS=14.2" \ test
- —project
Это путь к проекту, .xcodeproj
- -scheme
Название схемы.
- -destination
Путь к устройству, выражаемый в виде строки. Чтобы уточнить, какие девайсы зарегистрированы в системе, набираем:
$ xcrun xctrace list devices
Тестирование черного ящика
Как мы уже знаем, можно запускать XCUIApplication чтобы протестировать свое приложение на физическом устройстве подключенном к компьютеру, через идентификатор бандла, то есть мы можем писать тесты XCUITests для приложений, кода которых у нас нет. Однако, нам все же надо посмотреть иерархию вьюшек в приложении. Для этого можем воспользоваться такой вещью как Appium Desktop.
Можно инспектировать элементы каждого View, чтобы видеть их XCUIElement.ElementType и их атрибуты и идентификаторы если они доступны — надписи, строки и т.п.
Теперь мы знаем как тестируется интерфейс в XCode-приложениях, и умеем писать простые UI-тесты.