- PVSM.RU - https://www.pvsm.ru -
Привет!
Меня зовут Антон, и я занимаюсь автоматизацией тестирования Web и мобильных приложений.
Если вы начинаете автоматизировать UI-тесты под iOS, то наверняка заметили, что информации по фреймворку XCUITest в сети не так много, особенно на русском языке.
Эта статья — краткое руководство по основам автоматизации на XCUITest. Здесь я постарался собрать ключевые моменты, которые помогут вам сделать первые шаги в тестировании iOS-приложений.
Переходим по ссылке на Github проект [1], на котором будем учиться 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 [2] - аннотация используется для подавления предупреждений компилятора в случаях, когда возвращаемое значение метода не используется в коде.
Зачем возвращать Self? - возврат Self позволяет использовать методы класса последовательно (паттерн Chain of Responsibility)
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-тестах), чтобы не плодить много кода по проверкам после нажатия, проверку видимости элементов и т.д.
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) нужно, чтобы при вызове метода не писать около имени параметра внешнее имя.
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.
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)
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 экрана
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 в проект — рассказали в статье [3]
Расширил 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) - Шаг теста
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-аннотации для улучшения структуры и прозрачности тестирования.
Модернизировали класс тестирования Главного экрана
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
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
Результат отчета после прогона тестов
Посмотреть полный код вместе написанных тестов: https://github.com/moskkovsky/ui-tests-xcui [4]
Русскоязычный гайд по XCUITest: https://testengineer.ru/bolshoj-gajd-po-avtomatizacii-xcuitest/ [5]
Внедрение Allure (отчётность) в UI-тесты (swift, XCTest): https://habr.com/ru/companies/rtlabs/articles/686448/ [6]
Еще про Allure с iOS: https://kolesa.group/media/posts/tech-papers/kak-dzhun-vnedryal-allure-s-xctest-opyt-avtomatizacii-testirovaniya-ios [7]
Про аннотации Allure: https://habr.com/ru/companies/sberbank/articles/359302/ [8]
Автор: moskkovsky
Источник [9]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/ui-testing/419768
Ссылки в тексте:
[1] Github проект: https://github.com/browserstack/xcuitest-sample-browserstack
[2] @discardableResult: https://www.pvsm.ru/users/discardableResult
[3] статье: https://habr.com/ru/amp/publications/686448/#chapter2
[4] https://github.com/moskkovsky/ui-tests-xcui: https://github.com/moskkovsky/ui-tests-xcui
[5] https://testengineer.ru/bolshoj-gajd-po-avtomatizacii-xcuitest/: https://testengineer.ru/bolshoj-gajd-po-avtomatizacii-xcuitest/
[6] https://habr.com/ru/companies/rtlabs/articles/686448/: https://habr.com/ru/companies/rtlabs/articles/686448/
[7] https://kolesa.group/media/posts/tech-papers/kak-dzhun-vnedryal-allure-s-xctest-opyt-avtomatizacii-testirovaniya-ios: https://kolesa.group/media/posts/tech-papers/kak-dzhun-vnedryal-allure-s-xctest-opyt-avtomatizacii-testirovaniya-ios
[8] https://habr.com/ru/companies/sberbank/articles/359302/: https://habr.com/ru/companies/sberbank/articles/359302/
[9] Источник: https://habr.com/ru/articles/909558/?utm_source=habrahabr&utm_medium=rss&utm_campaign=909558
Нажмите здесь для печати.