Я занимаюсь автоматизацией мобильного тестирования, в основном на 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 приветствуются.