Как стать автором
Обновить

XCUITest для начинающих: как сделать тестирование iOS красивым с Allure

Уровень сложностиПростой
Время на прочтение12 мин
Количество просмотров475

Привет!

Меня зовут Антон, и я занимаюсь автоматизацией тестирования Web и мобильных приложений.

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

Эта статья — краткое руководство по основам автоматизации на XCUITest. Здесь я постарался собрать ключевые моменты, которые помогут вам сделать первые шаги в тестировании iOS-приложений.

Первые тесты

Переходим по ссылке на Github проект, на котором будем учиться UI тесты:

Далее нажимаем на Code -> Копируем ссылку с HTTPS

Скрин клонирования проекта
Клонирование проекта
Клонирование проекта

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

Скрин пустого проекта
Холст iOS тестирования
Холст iOS тестирования

Метод 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 с флагом false
Пример работы continueAfterFailure с флагом false

continueAfterFailure = true

Пример работы continueAfterFailure с флагом true
Пример работы 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

Скрин выравнивания кода
Выравнивание кода в Xcode
Выравнивание кода в Xcode

Добавление 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
Результат отчета
Результат отчета

Полезные ресурсы

Теги:
Хабы:
+6
Комментарии2

Публикации

Работа

Ближайшие события