Я занимаюсь автоматизацией мобильного тестирования, в основном на Appium. Когда решил наконец нормально разобраться в нативных UI-тестах под iOS, выбор был очевиден — XCUITest. Это фреймворк от самой Apple, ему не нужен сервер автоматизации, и он работает напрямую с деревом доступности. А потом простой логин превратился вот в это:

let email = app.textFields["login.email"]
XCTAssertTrue(email.waitForExistence(timeout: 10), "Email field did not appear")
email.tap()
email.typeText("test@example.com")

let loginButton = app.buttons["login.submit"]
XCTAssertTrue(loginButton.waitForExistence(timeout: 10), "Login button did not appear")
loginButton.tap()

XCUITest даёт примитивы, а ожидания, ретраи, screen object’ы и отчёты оставляет на вас. Во многих проектах эту дыру закрывают одинаково: сначала появляется waitAndTap(), потом ещё пара расширений, и со временем в проекте живёт маленький самописный фреймворк, который никто не тестирует. Я решил написать такой фреймворк сознательно и посмотреть, что получится. Получился KassiOS.

Идеи я взял у Kaspresso, который мне нравился на Android: тест описывает действия пользователя, ожидания — забота фреймворка, а каждый шаг сам попадает в отчёт. API свой, а внутри обычный XCUITest: пакет подключается к UI-тест-таргету и не тянет за собой зависимостей.

Как выглядит тест

onScreen(LoginScreen.self) { login in
    login.email.typeText("test@example.com")
    login.password.typeText("secret")
    login.loginButton.tap()
}
onScreen(HomeScreen.self) { $0.welcome.assertVisible() }
final class LoginScreen: KassScreen {
    lazy var email = textField("login.email")
    lazy var password = secureTextField("login.password")
    lazy var loginButton = button("login.submit")

    override var onLoad: [KassElement] { [email, loginButton] }
}

Перед тем как выполнить блок, KassiOS проверяет элементы из onLoad. Если приложение открыло не тот экран, тест падает сразу, на шаге «On LoginScreen», а не на первом поле, до которого успел дотянуться. Каждый onScreen — отдельная activity в отчёте Xcode, так что упавший прогон читается как список шагов.

Тест стал короче, но цель была не в этом. Мне хотелось, чтобы ожидания и ретраи жили в одном месте, во фреймворке, а не копировались в каждый тест.

Чтобы одному тапу можно было верить

Первая версия tap() просто дожидалась элемента и тапала. Почти вся работа с тех пор ушла на случаи, когда этого мало. Сейчас один вызов login.loginButton.tap() укладывается в общий бюджет времени и за это время:

  • ждёт, пока элемент появится и на него можно будет нажать. Внутренние ретраи делят один бюджет: три попытки с таймаутом 10 секунд — это 10 секунд, а не 30;

  • прокручивает к элементу, если он за краем экрана, и убирает клавиатуру, если та его закрывает. Убирает кнопкой Done или Hide, но не Return: Return может отправить форму;

  • тапает один раз. Повторяются только предусловия. Если фреймворк тапает повторно «на всякий случай», он может сам устроить двойную отправку или замаскировать настоящую;

  • на каждой попытке ищет элемент заново. KassElement хранит не сам элемент, а способ его найти (() -> XCUIElement), поэтому если SwiftUI пересоздал view, следующая попытка найдёт новый.

Некоторые действия ещё и проверяют, что получилось. setSwitch(on:), toggle(), replaceText и adjustPicker после действия читают элемент и падают, если состояние не поменялось. Без этого тап по подписи рядом с переключателем возвращается как успешный, и тест может пройти, хотя переключатель так и остался выключенным.

Если тест всё-таки падает, в отчёт попадают скриншот экрана в момент падения и полное дерево доступности, а сообщение старается объяснить причину:

KassiOS: text 'welcom' — assertVisible failed: does not exist
  ↳ element not found in the current hierarchy
  ↳ did you mean: 'welcome', 'Welcome!'?

У ожиданий есть и обратная сторона. Слишком терпеливый ретрай превращает сломанное приложение в зелёный тест. Поэтому в KassiOS ожидание только даёт интерфейсу время дойти до нужного состояния, а когда бюджет кончился, тест падает с последней причиной, которую фреймворк успел увидеть. Всё это можно отключить в KassConfig.

Проверка на сломанном банке

В какой-то момент у меня были аккуратные абстракции, куча юнит-тестов и никакого понимания, ловит ли всё это баги. Зелёный UI-тест говорит только о том, что приложение сделало то, что тест проверил. Покраснеет ли он, если появится тот самый баг, ради которого его писали, — неизвестно.

Чтобы это выяснить, я сделал ChaosBank — банковское приложение, в котором баги включаются при запуске (я писал о нём на Хабре), — и эталонный набор тестов, который гоняет его через KassiOS. У каждого теста в наборе один и тот же контракт:

чистое приложение  →  тест проходит
включили дефект    →  тот же тест падает

Первый полный прогон, на 34 тестах (Xcode 27.1, симулятор iPhone 17, M1 Max), меня изрядно отрезвил:

  • test_portfolio_showsTotalAndPnl оставался зелёным при любом дефекте, включая тот, где убыток показывается как прибыль. Тест проверял, что числа есть на экране, а не что они правильные.

  • Группы дефектов localization и flaky не покрасили ни одного теста. Проверок на локаль и гонки в наборе просто не было.

  • Красные прогоны шли заметно дольше зелёных. Львиную долю времени съедала моя же подсказка «did you mean?»: она опрашивала около 150 элементов по одному. Когда я перевёл её на один снимок дерева, упавший тест перевода стал занимать 23 секунды вместо 85.

Заодно набор нашёл баг в самом KassiOS. Когда я добавил проверку после ввода, один тест упал вот так:

KassiOS: textField 'order.limitPriceField' — replaceText('179.00') failed:
  typed '179.00' but the field reads '179.009.07'

Поле цены было выровнено по правому краю, и в нём уже стояло 189.07. Тап по центру поставил курсор в середину текста, удаление стёрло только 18, и новое значение встало перед 9.07. Предыдущая версия очищала поле точно так же, просто не проверяла результат. Тест упал бы строкой ниже, на неверной сумме заказа, и ничто не указывало бы на поле. Теперь перед удалением KassiOS тапает по правому краю.

Что добавилось благодаря дефектам

Несколько проверок появились из-за дефектов, которые проскакивали мимо тестов. Например, successToastMissing: перевод проходит, шторка закрывается, а тост об успехе так и не показывается. Тест, который ждёт закрытия шторки, остаётся зелёным. Чтобы это поймать, понадобилось ждать иначе:

/// Catches `successToastMissing`.
func test_transfer_happyPath_completes() {
    launchUnlocked()
    onScreen(HomeScreen.self) { $0.transferAction.tap() }
    onScreen(TransferScreen.self) { transfer in
        transfer.recipientField.typeText("Alex")
        transfer.amountField.typeText("100")
        transfer.continueButton.tap()
        transfer.confirmButton.tap()
        transfer.successToast.assertAppears(within: 2)
        transfer.confirmButton.within(timeout: 20).waitUntilGone()
    }
}

Тост висит на экране около 1,4 секунды. Обычный бюджет рассчитан на «дождаться, пока будет готово», а assertAppears опрашивает короткое окно — «успеть заметить, пока видно». Вот ещё несколько проверок, которые мне постоянно требовались:

// Двойная отправка: два тапа в одну точку без проверок между ними.
order.placeButton.rapidTap(times: 2)

// Значение не должно меняться, пока ничего не происходит.
home.totalBalance.assertUnchanged(for: 3)

// Котировки тикают, поэтому связанные значения читаем из одного снимка.
let t = home.readTexts(["home.total", "home.account.EUR", "home.account.USD"])

// Строк столько, сколько нужно, но строки могут быть не те.
history.cells().assertNoDuplicates()
history.cells().assertEach("is money-in") { row in
    guard row.readLabel().contains("+") else { throw KassError("expected a money-in row") }
}

verifyAll выполняет все проверки в блоке и показывает все расхождения на экране, а не только первое. Для условий, которые хочется собрать самому, есть eventually, continuously(during:), anyOf и retry(times:) — как в Kaspresso.

Для валидации обычно нужно прогнать одну и ту же форму с кучей разных значений, поэтому есть и параметризованные тесты:

func test_login_validatesTheEmail() {
    parameterized([("a@b.c", true), ("bad", false), ("", false)], name: { $0.0 }) { email, valid in
        relaunch()   // для каждого случая — чистое приложение
        onScreen(LoginScreen.self) { login in
            login.email.replaceText(email)
            login.loginButton.tap()
            if valid {
                onScreen(HomeScreen.self) { $0.welcome.assertVisible() }
            } else {
                login.error.assertVisible()
            }
        }
    }
}

Каждый случай попадает в отчёт отдельной activity с именем из name, так что сразу видно, что упал именно «bad». Упавший случай не останавливает остальные. В Swift Testing есть @Test(arguments:), но в Xcode 27.1 у него есть только шаблон для юнит-тестов, а шаблоны UI-тестов по-прежнему на XCTest. Я пользуюсь Swift Testing для логики моделей и view model, а KassiOS — для пользовательских сценариев, в разных таргетах.

По одному дефекту за раз

Если гонять набор против целой группы дефектов, видно только, что что-то покраснело. Какой дефект ловит каждый тест и какой не ловит никто, так не узнать. Для этого в KassiOS есть консольная утилита kassios mutants. Вы перечисляете дефекты и тесты, которые каждый из них должен покрасить, а она включает дефекты по одному:

mutant                 verdict
zeroAmountAccepted     SURVIVED  (still green: …/TransferTests/test_transfer_zeroAmount_disablesContinue)
balanceAfterAdds       killed
...
58 of 59 killed

С флагом --clean она сначала прогоняет все тесты без дефектов: тест, который и так красный, «ловит» что угодно. В эталонном наборе список собирается из комментариев /// Catches `id` над тестами, как у теста с тостом выше, и CI падает, если сгенерированный файл устарел.

С тех пор набор вырос с 34 тестов до 71, и все 59 дефектов из списка ловятся. Прогон по одному дефекту занимает на моём Mac около 70 минут. С --parallel 3 три дефекта проверяются одновременно на клонах симулятора, и выходит 28 минут. О том, какие тесты дольше всех оставались зелёными и что понадобилось, чтобы они начали падать, напишу в следующей статье.

Что ещё есть

  • kassios lint до запуска тестов проходит по screen object’ам и отмечает те, где нет onLoad или identifier собирается на лету. Аудит доступности от Apple можно запустить прямо из теста.

  • kassios agent start запускает небольшой процесс на Mac, который слушает только loopback и делает то, чего тест не может изнутри симулятора: выдать разрешения, зафиксировать статус-бар, подставить геолокацию. Токен и порт агент выбирает сам и оставляет тесту, где их найти. Если агент не запущен, такие вызовы пропускаются с сообщением, а не зависают.

  • KassHTTP готовит состояние через API приложения, чтобы тесту про перевод не приходилось сначала заводить счёт через интерфейс.

  • webView(), link и links() достают элементы из WKWebView. У HTML нет accessibility identifier’ов, поэтому веб-элементы ищутся по тексту.

  • Если включить recordVideoOnFailure и запустить агент, к упавшему тесту прикладывается ещё и видео экрана симулятора. Репортеры для Allure и JUnit встроены.

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

Кому подойдёт, а кому нет

Мне кажется, KassiOS имеет смысл попробовать, если:

  • тесты на XCUITest переросли свои хелперы. Уже есть waitAndTap(), базовый класс и page object’ы, а каждый новый нестабильный тест обзаводится своим sleep;

  • тихий сбой обходится дорого. Формы, платежи, настройки — это места, где тап мимо или поле с остатком старого значения позволяют тесту пройти на сломанном приложении;

  • команда знает Kaspresso по Android и хочет такую же структуру на iOS: экраны, шаги, сценарии, Allure;

  • хочется знать, что на самом деле ловят тесты. Для kassios mutants достаточно, чтобы приложение умело включать дефект при запуске, через launch-аргумент или переменную окружения.

Скорее не подойдёт, если:

  • тестов мало. Десяток тестов на несколько экранов прекрасно живут на чистом XCUITest с парой расширений, которые поймёт любой iOS-разработчик;

  • нужен один набор тестов на iOS и Android. Это умеют Appium и Maestro, у каждого свой сервер или рантайм. KassiOS — только iOS, Swift внутри Xcode-проекта;

  • нужно знать, когда приложение простаивает. EarlGrey работает внутри приложения и это знает. KassiOS работает снаружи и опрашивает интерфейс. Для синхронизации есть хук, а в репозитории лежит пример адаптера к EarlGrey;

  • не хочется ничего лишнего между падением и строкой кода. Действия KassiOS вызывают XCTFail внутри и возвращают элемент, поэтому при continueAfterFailure = true цепочка вызовов продолжится и после падения;

  • хочется оставаться на инструментах Apple, а не завязываться на сторонний слой, который поддерживает один человек.

От нестабильности, которая идёт от самого приложения, тестовых данных или окружения, KassiOS не спасает. И когда что-то ведёт себя странно, всё равно приходится разбираться, что XCUITest делает внутри. Пока я писал KassiOS, я узнал об этом больше, чем узнал бы, просто написав несколько тестов.

Сделано с ИИ, проверено как в QA

KassiOS я писал с серьёзной помощью ИИ-инструментов. Я QA-инженер, а не разработчик Swift-фреймворков, и без этой помощи не зашёл бы так далеко. Но это же значило, что проверять приходилось всё: 172 юнит-теста, 59 UI-тестов на небольшом демо-приложении на SwiftUI в репозитории KassiOS, эталонный набор на ChaosBank и SwiftLint. Всё это гоняется в CI на каждый pull request. Баг с курсором нашёл набор на ChaosBank, а не я, читая код.

Как попробовать

.package(url: "https://github.com/VadimToptunov/KassiOS.git", from: "1.10.0")

Подключите пакет к UI-тест-таргету, унаследуйте тестовый класс от KassTestCase вместо XCTestCase и напишите один screen object — например, для экрана, с которого начинается ваш самый нестабильный тест. Обычные вызовы XCUITest продолжат работать рядом. Публичный API следует семантическому версионированию с версии 1.0.

Консольные инструменты (агент, линтер и kassios mutants) ставятся одной формулой Homebrew, уже собранными, так что собирать ничего не нужно:

brew install vadimtoptunov/tap/kassios
kassios agent start

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

Если попробуете, расскажите, что показалось лишним, чего не хватило и где KassiOS мешал. Issues и PR приветствуются.

Ссылки

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