Привет!
Меня зовут Антон, и я занимаюсь автоматизацией тестирования Web и мобильных приложений.
Если вы начинаете автоматизировать UI-тесты под iOS, то наверняка заметили, что информации по фреймворку XCUITest в сети не так много, особенно на русском языке.
Эта статья — краткое руководство по основам автоматизации на XCUITest. Здесь я постарался собрать ключевые моменты, которые помогут вам сделать первые шаги в тестировании iOS-приложений.
Первые тесты
Переходим по ссылке на Github проект, на котором будем учиться UI тесты:
Далее нажимаем на Code -> Копируем ссылку с HTTPS
Скрин клонирования проекта

В проекте находим папку с тестами и в ней откроем класс SampleXCUITests. В классе удаляем все лишнее
Скрин пустого проекта

Метод setUp() запускается перед каждым тестом, метод tearDown() работает после каждого теста.
Автоматизируем Главный экран приложения
Проверка видимости Alert после нажатия на него
func testAlertShouldAppearAfterButtonTap() {
let alertButton = app.buttons["Alert"]
XCTAssertTrue(alertButton.waitForExistence(timeout: 3),
"Кнопка 'Alert' не найдена на экране")
alertButton.tap()
let alert = app.alerts.element.staticTexts["Alert"]
XCTAssertTrue(alert.waitForExistence(timeout: 3),
"Alert не появился после нажатия кнопки")
}
Проверка отсутствия видимости Alert после нажатия на кнопку 'Ок'
func testAlertShouldDisappearAfterTappingOK() {
let alertButton = app.buttons["Alert"]
XCTAssertTrue(alertButton.waitForExistence(timeout: 3),
"Кнопка 'Alert' не найдена на экране")
alertButton.tap()
let alert = app.alerts.element.staticTexts["Alert"]
XCTAssertTrue(alert.waitForExistence(timeout: 3),
"Alert не появился после нажатия кнопки")
let alertButtonOK = app.alerts.element.buttons["OK"]
alertButtonOK.tap()
XCTAssertFalse(alert.waitForExistence(timeout: 3),
"Alert должен исчезнуть после нажатия OK")
}
Проверка видимости символов после ввода в 'поле ввода'
func testTextInputShouldDisplayCorrectly() {
let textButton = app.buttons["Text"]
XCTAssertTrue(textButton.waitForExistence(timeout: 3),
"Кнопка 'Text' не найдена на экране")
textButton.tap()
let textField = app.textFields["Enter a text"]
let displayedText = app.staticTexts["VK"]
XCTAssertTrue(textField.waitForExistence(timeout: 3),
"Поле ввода должно отображаться после нажатия кнопки")
textField.tap()
textField.typeText("VK")
app.keyboards.buttons["Return"].tap()
if displayedText.isEnabled {
XCTContext.runActivity(named: "Проверка отображения введённого текста") { _ in
XCTAssertTrue(displayedText.waitForExistence(timeout: 3),
"Текст 'VK' должен появиться на экране")
XCTAssertEqual(displayedText.label, "VK",
"Отображаемый текст должен точно соответствовать введенному")
}
}
}
Проверка видимости символов при переключение экранов после ввода в 'поле ввода'
func testCheckVisibleTextWhileSwitchingBetweenScreens() {
let textButton = app.buttons["Text"]
XCTAssertTrue(textButton.waitForExistence(timeout: 3),
"Кнопка 'Text' не найдена на экране")
textButton.tap()
let textField = app.textFields["Enter a text"]
let displayedText = app.staticTexts["VK"]
XCTAssertTrue(textField.waitForExistence(timeout: 3),
"Поле ввода должно отображаться после нажатия кнопки")
textField.tap()
textField.typeText("VK")
app.keyboards.buttons["Return"].tap()
if displayedText.isEnabled {
XCTContext.runActivity(named: "Проверка отображения введённого текста") { _ in
XCTAssertTrue(displayedText.waitForExistence(timeout: 3),
"Текст 'VK' должен появиться на экране")
XCTAssertEqual(displayedText.label, "VK",
"Отображаемый текст должен точно соответствовать введенному")
}
}
XCTContext.runActivity(named: "Проверка сохранения текста после перехода на WebView") { _ in
app.tabBars.buttons["Web View"].tap()
app.tabBars.buttons["UI Elements"].tap()
XCTAssertTrue(displayedText.waitForExistence(timeout: 3),
"Текст должен сохраняться при возврате на экран")
XCTAssertEqual(displayedText.label, "VK",
"Текст не должен изменяться при переключении экранов")
}
}
Все результаты тестов можно посмотреть в вкладке Show the Test navigator
Скрин результата прогона тестов

Усовершенствуем тесты
Такие тесты уже неплохие, но есть что добавить!
Первое что добавим, это разделим на Page Object и Page Element экраны приложения
В приложении 2 активных экрана: UI Elements(Главный экран) и Web View
Нажимаем правой мышкой на папку SampleXCUITests -> Нажимаем New Group -> Называем директорию
BaseScreen является базовым классом для всех экранов в UI-тестах, который содержит общую логику взаимодействия с пользовательским интерфейсом.
@discardableResult - аннотация используется для подавления предупреждений компилятора в случаях, когда возвращаемое значение метода не используется в коде.
Зачем возвращать Self? - возврат Self позволяет использовать методы класса последовательно (паттерн Chain of Responsibility)
BaseScreen.swift
import Foundation
import XCTest
class BaseScreen {
public let app = XCUIApplication()
private lazy var uiElementsTab = app.tabBars.buttons["UI Elements"]
private lazy var webViewTab = app.tabBars.buttons["Web View"]
private lazy var localTestingTab = app.tabBars.buttons["Local Testing"]
@discardableResult
func goToUIElements() -> Self {
uiElementsTab.tap()
return self
}
@discardableResult
func goToWebView() -> Self {
webViewTab.tap()
return self
}
@discardableResult
func goToLocalTesting() -> Self {
localTestingTab.tap()
return self
}
}
Verifications расширил класс XCUIElement (представляет собой набор вспомогательных методов для работы с элементами пользовательского интерфейса в UI-тестах), чтобы не плодить много кода по проверкам после нажатия, проверку видимости элементов и т.д.
Verifications.swift
import Foundation
import XCTest
extension XCUIElement {
@discardableResult
func verifyExistence(timeout: TimeInterval = 3, message: String = "") -> Self {
let errorMessage = message.isEmpty ? "Элемент '\(self)' должен существовать" : message
XCTAssertTrue(
self.waitForExistence(timeout: timeout),
errorMessage
)
return self
}
@discardableResult
func verifyHittable(message: String = "") -> Self {
let errorMessage = message.isEmpty ? "Элемент '\(self)' должен быть доступен для взаимодействия" : message
XCTAssertTrue(
self.isHittable,
errorMessage
)
return self
}
@discardableResult
func verifyDisappear(timeout: TimeInterval = 3, message: String = "") -> Self {
let errorMessage = message.isEmpty ? "Элемент '\(self)' должен исчезнуть" : message
XCTAssertFalse(
self.waitForExistence(timeout: timeout),
errorMessage
)
return self
}
@discardableResult
func verifyAndTap(timeout: TimeInterval = 3, message: String = "") -> Self {
self.verifyExistence(timeout: timeout, message: message)
.verifyHittable(message: message)
.tap()
return self
}
@discardableResult
func verifyLabel(expected: String, message: String = "") -> Self {
let errorMessage = message.isEmpty ?
"Текст элемента '\(self)' не соответствует. Актуальный: '\(self.label)', Ожидаемый: '\(expected)'" :
message
XCTAssertEqual(self.label, expected, errorMessage)
return self
}
@discardableResult
func typeTextSafely(_ text: String, message: String = "") -> Self {
self.verifyExistence(message: message)
.verifyHittable(message: message)
.tap()
self.typeText(text)
XCUIApplication().keyboards.buttons["Return"].tap()
return self
}
@discardableResult
func scrollToView(maxAttempts: Int = 10) -> Self {
for _ in 1...maxAttempts {
if self.isHittable {
return self
}
sleep(2)
XCUIApplication().webViews.firstMatch.swipeUp()
}
XCTFail("Элемент \(self) отсутствует на экране")
return self
}
}
HomeScreen - класс для взаимодействия с элементами главного экрана.
Нижнее подчеркивание около внешнего имени параметра (_ inputMessage: String) нужно, чтобы при вызове метода не писать около имени параметра внешнее имя.
HomeScreen.swift
import Foundation
import XCTest
final class HomeScreen: BaseScreen {
private lazy var titleLabel = app.navigationBars.staticTexts["UI Elements"]
private lazy var buttonText = app.buttons["Text"]
private lazy var alertButton = app.buttons["Alert"]
private lazy var alertText = app.alerts.element.staticTexts["Дуров верни стену"]
private lazy var alertOKButton = app.alerts.element.buttons["😔"]
private lazy var buttonBack = app.navigationBars.buttons["UI Elements"]
private lazy var textField = app.textFields["Enter a text"]
private lazy var resultLabel = app.staticTexts[HomeScreenValue.textFieldInput]
lazy var baseElement = titleLabel
@discardableResult
func tapText() -> Self {
buttonText
.verifyExistence()
.verifyHittable()
.tap()
textField.verifyExistence()
return self
}
@discardableResult
func tapAlert() -> Self {
alertButton
.verifyExistence()
.verifyHittable()
.tap()
alertText
.verifyExistence()
return self
}
@discardableResult
func closeAlert() -> Self {
alertOKButton
.verifyExistence()
.verifyHittable()
.tap()
alertOKButton.verifyDisappear()
return self
}
@discardableResult
func enterText(_ inputMessage: String) -> Self {
textField.typeTextSafely(inputMessage)
return self
}
@discardableResult
func checkTextAfterPushTextField(_ expectedText: String) -> Self {
resultLabel.verifyLabel(expected: expectedText)
return self
}
}
WebViewScreen - класс для взаимодействия с элементами экрана построенного на WebView.
WebViewScreen.swift
import Foundation
import XCTest
final class WebViewScreen: BaseScreen {
private lazy var titleLabel = app.webViews.staticTexts["App & Browser Testing Made Easy"]
private lazy var benefitsSection = app.webViews.staticTexts["Benefits"]
lazy var baseElement = titleLabel
@discardableResult
func verifyBenefitsSectionVisible() -> Self{
benefitsSection.scrollToView()
return self
}
@discardableResult
func shouldFindTextByPrefix() -> Self{
let beginText = app.webViews.staticTexts.containing(NSPredicate(format: "label BEGINSWITH %@", "Give your")).firstMatch
beginText.verifyExistence()
return self
}
@discardableResult
func shouldFindTextCaseInsensitive() -> Self {
let containsText = app.webViews.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] %@", "give your")).firstMatch
containsText.verifyExistence()
return self
}
@discardableResult
func shouldFindTextByMultipleKeywords(_ keywords: [String]) -> Self{
let keyText = app.webViews.staticTexts.containing(NSPredicate(format: "label CONTAINS %@ AND label CONTAINS %@",
keywords[0], keywords[1])).firstMatch
keyText.verifyExistence()
return self
}
}
Вынесем определенные строки в константы (для главного экрана и для ошибок в случае не загрузки экранов соответственно)
public enum HomeScreenValue {
public static let textFieldHint = "Waiting for text input."
public static let textFieldInput = "XCUI Tests"
}
public enum ErrorMessageValue {
public static let loadMainScreen = "Главный экран не загрузился"
public static let loadWebViewScreen = "Экран WebView не загрузился"
}
BaseTests - базовый класс для UI-тестов, который инициализирует приложение, автоматически запускает его перед каждым тестом и завершает после выполнения
class BaseTests: XCTestCase {
private var baseScreen = BaseScreen()
lazy var app = baseScreen.app
open override func setUp() {
app.launch()
// Тест остановится при первой ошибке
continueAfterFailure = true
}
open override func tearDown() {
app.terminate()
}
}
continueAfterFailure - это свойство, которое определяет поведение теста при возникновении ошибки. По умолчанию оно установлено в true, что означает продолжение выполнения теста даже после обнаружения ошибки
Пример:
continueAfterFailure = false

continueAfterFailure = true

HomeScreenTests - класс для UI-тестов главного экрана. Написал проверку загрузки экрана в setUp() (по типу паттерна LoadableComponent)
HomeScreenTests.swift
import Foundation
import XCTest
final class HomeScreenTests: BaseTests {
override func setUp() {
super.setUp()
HomeScreen().baseElement.verifyExistence(
message: ErrorMessageValue.loadMainScreen
)
}
func testAlertShouldAppearAfterButtonTap() {
HomeScreen()
.tapAlert()
}
func testAlertShouldDisappearAfterTappingOK() {
HomeScreen()
.tapAlert()
.closeAlert()
}
func testTextInputShouldDisplayCorrectly() {
HomeScreen()
.tapText()
.enterText(HomeScreenValue.textFieldInput)
.checkTextAfterPushTextField(HomeScreenValue.textFieldInput)
}
func testCheckVisibleTextWhileSwitchingBetweenScreens() {
HomeScreen()
.tapText()
.enterText(HomeScreenValue.textFieldInput)
BaseScreen()
.goToWebView()
.goToUIElements()
HomeScreen()
.checkTextAfterPushTextField(HomeScreenValue.textFieldInput)
}
}
WebViewScreenTests - класс для UI-тестов WebView экрана
WebViewScreenTests.swift
import Foundation
final class WebViewScreenTests: BaseTests {
override func setUp() {
super.setUp()
BaseScreen()
.goToWebView()
WebViewScreen()
.baseElement
.verifyExistence(message: ErrorMessageValue.loadWebViewScreen)
}
func testScrollToTextBenefits() {
WebViewScreen()
.verifyBenefitsSectionVisible()
}
// Поиск по началу текста
func testShouldFindTextByPrefix() {
WebViewScreen()
.shouldFindTextByPrefix()
}
// Поиск с игнорированием регистра
func testShouldFindTextCaseInsensitive() {
WebViewScreen()
.shouldFindTextCaseInsensitive()
}
// Поиск по ключевым словам
func testShouldFindTextByMultipleKeywords() {
WebViewScreen()
.shouldFindTextByMultipleKeywords(["users", "seamless"])
}
}
Обновлённая структура проекта
Скрин структуры проекта

[Лайфхак] Чтобы выровнять код, то выделяете весь код (Command + A) -> Editor -> Re-Indent
Скрин выравнивания кода

Добавление Allure
Как внедрить Allure в проект — рассказали в статье
Расширил XCTest для поддержки Allure-отчетов, позволяя структурировать тесты по методологиям BDD (Epic, Feature, Story) и добавлять метаданные
Функции и их аннотации:
epic(_ values:String...) - Высокоуровневая бизнес-категория тестов
feature(_ values:String...) - Функциональный модуль
story(_ values: String...) - Юзер-стори или сценарий
displayName(_ name: String) - Имя теста в отчете
severity(_ values: String...) - Критичность теста
owner(_ values: String...)- Ответственный за тест
step(_ name: String, step: () -> Void) - Шаг теста
AllureXCTestExtensions.swift
import Foundation
import XCTest
extension XCTest {
func epic(_ values:String...) {
label(name: "epic", values: values)
}
func feature(_ values:String...) {
label(name: "feature", values: values)
}
func story(_ values: String...) {
label(name: "story", values: values)
}
func displayName(_ name: String) {
addTestCaseName(value: name)
}
func severity(_ values: String...) {
label(name: "severity", values: values)
}
func owner(_ values: String...) {
label(name: "owner", values: values)
}
func step(_ name: String, step: () -> Void) {
XCTContext.runActivity(named: name) { _ in
step()
}
}
private func label(name: String, values: [String]) {
for value in values {
XCTContext.runActivity(named: "allure.label.\(name):\(value)", block: { _ in })
}
}
private func addTestCaseName(value: String) {
XCTContext.runActivity(named: "allure.name:\(value)") { _ in }
}
}
Провели рефакторинг тестовых классов, добавив Allure-аннотации для улучшения структуры и прозрачности тестирования.
Модернизировали класс тестирования Главного экрана
HomeScreenTests.swift
import Foundation
import XCTest
final class HomeScreenTests: BaseTests {
override func setUp() {
super.setUp()
step("Проверяем корректную загрузку главного экрана приложения") {
HomeScreen().baseElement.verifyExistence(
message: ErrorMessageValue.loadMainScreen
)
}
}
// MARK: - Alert
func testAlertShouldAppearAfterButtonTap() {
epic("Главный экран")
feature("Взаимодействие с Alert")
story("Открытие Alert")
displayName("Открытие Alert при нажатии на кнопку")
severity("MINOR")
owner("Anton Moskovsky")
step("Тап на кнопку Alert и проверка появления алерта") {
HomeScreen()
.tapAlert()
}
}
func testAlertShouldDisappearAfterTappingOK() {
epic("Главный экран")
feature("Взаимодействие с Alert")
story("Закрытие Alert")
displayName("Закрытие Alert после нажатия на кнопку 'OK'")
severity("MINOR")
owner("Anton Moskovsky")
step("Тап на кнопку Alert и проверка появления алерта") {
HomeScreen().tapAlert()
}
step("Нажимаем кнопку 'OK' в Alert и проверяем его исчезновение") {
HomeScreen().closeAlert()
}
}
// MARK: - Ввод символов
func testTextInputShouldDisplayCorrectly() {
epic("Главный экран")
feature("Взаимодействие с текстовым полем")
story("Ввод и проверка текста")
displayName("Корректное отображение введенного текста")
severity("MINOR")
owner("Anton Moskovsky")
step("Переходим на экран ввода текста через кнопку 'Text'") {
HomeScreen().tapText()
}
step("Вводим текст '\(HomeScreenValue.textFieldInput)' в текстовое поле") {
HomeScreen()
.enterText(HomeScreenValue.textFieldInput)
}
step("Проверяем, что введенный текст корректно отображается над полем ввода") {
HomeScreen().checkTextAfterPushTextField(HomeScreenValue.textFieldInput)
}
}
func testCheckVisibleTextWhileSwitchingBetweenScreens() {
epic("Главный экран")
feature("Взаимодействие с текстовым полем")
story("Сохранение текста между экранами")
displayName("Сохранение текста при переключении экранов")
severity("NORMAL")
owner("Anton Moskovsky")
step("Переходим на экран ввода текста через кнопку 'Text'") {
HomeScreen().tapText()
}
step("Вводим текст '\(HomeScreenValue.textFieldInput)' в текстовое поле") {
HomeScreen()
.enterText(HomeScreenValue.textFieldInput)
}
step("Переход на WebView и обратно на главный экран") {
BaseScreen()
.goToWebView()
.goToUIElements()
}
step("Проверяем, что введенный текст сохранился после возврата на главный экран") {
HomeScreen()
.checkTextAfterPushTextField(HomeScreenValue.textFieldInput)
}
}
}
И также обновили класс тестирования экрана на WebView
WebViewScreenTests.swift
import Foundation
final class WebViewScreenTests: BaseTests {
override func setUp() {
super.setUp()
step("Переходим на экран WebView из главного меню") {
BaseScreen()
.goToWebView()
}
step("Проверяем корректную загрузку экрана WebView") {
WebViewScreen()
.baseElement
.verifyExistence(message: ErrorMessageValue.loadWebViewScreen)
}
}
func testScrollToTextBenefits() {
epic("Экран WebView")
feature("Поиск текста")
story("Нахождение текста с помощью ScrollView")
displayName("Поиск текста с помощью скролл")
severity("MINOR")
owner("Anton Moskovsky")
step("Выполняем скролл до раздела Benefits") {
WebViewScreen()
.verifyBenefitsSectionVisible()
}
}
// Поиск по началу текста
func testShouldFindTextByPrefix() {
epic("Экран WebView")
feature("Поиск текста")
story("Поиск по началу текста")
displayName("Поиск текста по префиксу")
severity("MINOR")
owner("Anton Moskovsky")
step("Ищем текст по начальным символам") {
WebViewScreen()
.shouldFindTextByPrefix()
}
}
// Поиск с игнорированием регистра
func testShouldFindTextCaseInsensitive() {
epic("Экран WebView")
feature("Поиск текста")
story("Поиск с игнорированием регистра")
displayName("Поиск текста без учета регистра")
severity("MINOR")
owner("Anton Moskovsky")
step("Ищем текст, игнорируя регистр символов") {
WebViewScreen()
.shouldFindTextCaseInsensitive()
}
}
// Поиск по ключевым словам
func testShouldFindTextByMultipleKeywords() {
epic("Экран WebView")
feature("Поиск текста")
story("Поиск по ключевым словам")
displayName("Поиск текста по нескольким ключевым словам")
severity("MINOR")
owner("Anton Moskovsky")
step("Ищем текст по ключевым словам") {
WebViewScreen()
.shouldFindTextByMultipleKeywords(["users", "seamless"])
}
}
}
Выполняем команду в cmd директории проекта:
allure serve allure-results
Результат отчета после прогона тестов
Отчет Allure

Полезные ресурсы
Посмотреть полный код вместе написанных тестов: https://github.com/moskkovsky/ui-tests-xcui
Русскоязычный гайд по XCUITest: https://testengineer.ru/bolshoj-gajd-po-avtomatizacii-xcuitest/
Внедрение Allure (отчётность) в UI-тесты (swift, XCTest): https://habr.com/ru/companies/rtlabs/articles/686448/
Еще про Allure с iOS: https://kolesa.group/media/posts/tech-papers/kak-dzhun-vnedryal-allure-s-xctest-opyt-avtomatizacii-testirovaniya-ios
Про аннотации Allure: https://habr.com/ru/companies/sberbank/articles/359302/