KassiOS: слой в стиле Kaspresso поверх XCUITest, проверенный на сломанном банке
Я занимаюсь автоматизацией мобильного тестирования, в основном на 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()
sourcecraft.dev
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() }
sourcecraft.dev
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] }
}
sourcecraft.dev
Перед тем как выполнить блок, 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!'?
sourcecraft.dev
У ожиданий есть и обратная сторона. Слишком терпеливый ретрай превращает сломанное приложение в зелёный тест. Поэтому в KassiOS ожидание только даёт интерфейсу время дойти до нужного состояния, а когда бюджет кончился, тест падает с последней причиной, которую фреймворк успел увидеть. Всё это можно отключить в KassConfig.
Проверка на сломанном банке
В какой-то момент у меня были аккуратные абстракции, куча юнит-тестов и никакого понимания, ловит ли всё это баги. Зелёный UI-тест говорит только о том, что приложение сделало то, что тест проверил. Покраснеет ли он, если появится тот самый баг, ради которого его писали, — неизвестно.
Чтобы это выяснить, я сделал ChaosBank — банковское приложение, в котором баги включаются при запуске (я писал о нём на Хабре), — и эталонный набор тестов, который гоняет его через KassiOS. У каждого теста в наборе один и тот же контракт:
чистое приложение → тест проходит
включили дефект → тот же тест падает
sourcecraft.dev
Первый полный прогон, на 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'
sourcecraft.dev
Поле цены было выровнено по правому краю, и в нём уже стояло 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()
}
}
sourcecraft.dev
Тост висит на экране около 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") }
}
sourcecraft.dev
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()
}
}
}
}
sourcecraft.dev
Каждый случай попадает в отчёт отдельной 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
sourcecraft.dev
С флагом --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: "VadimToptunov/KassiOS", from: "1.10.0")
sourcecraft.dev
Подключите пакет к UI-тест-таргету, унаследуйте тестовый класс от KassTestCase вместо XCTestCase и напишите один screen object — например, для экрана, с которого начинается ваш самый нестабильный тест. Обычные вызовы XCUITest продолжат работать рядом. Публичный API следует семантическому версионированию с версии 1.0.
Консольные инструменты (агент, линтер и kassios mutants) ставятся одной формулой Homebrew, уже собранными, так что собирать ничего не нужно:
brew install vadimtoptunov/tap/kassios
kassios agent start
sourcecraft.dev
Самой библиотеке они не нужны: ставьте их, когда понадобится что-то из этого.
Если попробуете, расскажите, что показалось лишним, чего не хватило и где KassiOS мешал. Issues и PR приветствуются.
Ссылки
KassiOS
ChaosBank для iOS и статья о нём на Хабре
Эталонный набор ChaosBank × KassiOS
Документация XCTest
Kaspresso
EarlGrey
Allure Report
KassiOS: a Kaspresso-style layer for XCUITest, tested against a broken bank
My background is QA automation, mostly mobile and mostly Appium. When I decided to learn native iOS UI testing properly, XCUITest looked like the obvious choice: it's Apple's own framework, it needs no automation server, and it works with the accessibility hierarchy directly. Then a simple login turned into this:
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 gives you primitives and leaves waiting, retries, screen objects and reporting to you. In most projects I've seen, that gap gets filled by a waitAndTap() extension, then a few more, until there's a small framework that nobody tests. I decided to write that framework on purpose and see where it went. That became KassiOS.
The ideas come from Kaspresso, which I liked on Android: a test describes what the user does, the framework handles the waiting, and every step shows up in the report. The API is my own, and underneath it's still plain XCUITest in your UI-test target, with no dependencies.
What a test looks like
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] }
}
Before the block runs, KassiOS checks the elements in onLoad. If the app ended up on a different screen, the test fails right there, in the "On LoginScreen" step, instead of on whichever field it happened to touch first. Every onScreen block is an activity in Xcode's report, so a red run reads as a list of steps.
The test is shorter, but that isn't what I was after. I wanted the waiting and retrying to live in one place, in the framework, so that each test doesn't carry its own copy.
Making one tap trustworthy
The first version of tap() waited for the element and tapped it. Most of the work since then went into the cases where that isn't enough. Today a single login.loginButton.tap(), within one time budget:
- waits for the element to exist and be hittable. Internal retries share the budget, so three attempts at a 10-second action take 10 seconds, not 30;
- scrolls to the element if it's off screen, and dismisses the keyboard if it covers the target. It uses Done or Hide for that, never Return, because Return can submit a form;
- taps once. Only the preconditions are retried. A framework that taps again "just in case" can cause a double submit, or hide a real one;
- finds the element again on every attempt. A KassElement stores how to find the element (() -> XCUIElement), not the element itself, so if SwiftUI rebuilds the view, the next attempt gets the new one.
Some actions also check what they did. setSwitch(on:), toggle(), replaceText and adjustPicker read the element back and fail if its state didn't change. Without it, a switch whose tap lands on its label, next to the control, returns as if it had worked, and the test can pass with the switch still off.
When something does fail, the report gets a screenshot of the screen at that moment and the full accessibility tree, and the message tries to say why:
KassiOS: text 'welcom' — assertVisible failed: does not exist
↳ element not found in the current hierarchy
↳ did you mean: 'welcome', 'Welcome!'?
Waiting has a limit too. A retry that forgives too much turns a broken app into a green test, so in KassiOS waiting only gives the UI time to reach a state. When the budget runs out, the test fails with the last reason the framework saw. Each of these behaviors can be turned off in KassConfig.
Checking it against a broken bank
At some point I had tidy abstractions, a lot of unit tests, and no idea whether any of it caught bugs. A green UI test tells you the app did what the test checked. It doesn't tell you the test would turn red if the bug it was written for showed up.
To find out, I built ChaosBank, a banking app with bugs you switch on at launch (previous post), and a reference suite that tests it through KassiOS. Every test in the suite has the same contract:
clean app → test passes
enable a defect → the same test fails
The first full run had 34 tests (Xcode 27.1, iPhone 17 simulator, M1 Max), and it was humbling:
- test_portfolio_showsTotalAndPnl stayed green with every defect on, including one that shows a loss as a gain. It checked that the numbers were on screen, not that they were right.
- The localization and flaky defect groups didn't turn a single test red. The suite had no locale or race checks at all.
- Red runs were much slower than green ones. Most of the extra time went into my own "did you mean?" hint, which queried about 150 elements one by one. Reading a single hierarchy snapshot instead took the failing transfer test from 85 seconds to 23.
The suite also found a bug in KassiOS. After I added the read-back, one test failed like this:
KassiOS: textField 'order.limitPriceField' — replaceText('179.00') failed:
typed '179.00' but the field reads '179.009.07'
The price field was right-aligned and pre-filled with 189.07. The tap at its centre put the cursor in the middle of the text, the deletes removed only 18, and the new value went in front of 9.07. The previous version cleared fields the same way. It just didn't look, so the test would have failed a line later on a wrong order total, with nothing pointing at the field. Now KassiOS taps the trailing edge before deleting.
What the defects added
Several checks came from defects that slipped through. Take successToastMissing: the transfer goes through and the sheet closes, but the success toast never appears. A test that waits for the sheet to close stays green. Catching it needed a different kind of wait:
/// 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()
}
}
The toast is on screen for about 1.4 seconds. The normal budget is built for "wait until it's ready", and assertAppears polls a short window instead, for "catch it while it's there". A few more that I kept needing:
// A double submit: two taps on the same spot, no re-check in between.
order.placeButton.rapidTap(times: 2)
// A value that must not move while nothing happens.
home.totalBalance.assertUnchanged(for: 3)
// Prices that tick: read related values from one hierarchy snapshot.
let t = home.readTexts(["home.total", "home.account.EUR", "home.account.USD"])
// The right number of rows can still be the wrong rows.
history.cells().assertNoDuplicates()
history.cells().assertEach("is money-in") { row in
guard row.readLabel().contains("+") else { throw KassError("expected a money-in row") }
}
verifyAll runs every assertion in its block and reports all the mismatches on a screen, not only the first. For conditions you compose yourself there's eventually, continuously(during:), anyOf and retry(times:), as in Kaspresso.
Validation defects tend to need the same form filled with many inputs, so there's a parameterized runner as well:
func test_login_validatesTheEmail() {
parameterized([("a@b.c", true), ("bad", false), ("", false)], name: { $0.0 }) { email, valid in
relaunch() // a clean app for every case
onScreen(LoginScreen.self) { login in
login.email.replaceText(email)
login.loginButton.tap()
if valid {
onScreen(HomeScreen.self) { $0.welcome.assertVisible() }
} else {
login.error.assertVisible()
}
}
}
}
Each case is its own activity in the report, named by the name closure, so a red run says the "bad" case failed. A failing case doesn't stop the rest. Swift Testing has @Test(arguments:), but in Xcode 27.1 it only has a unit-test template, and the UI-test templates are still XCTest. I use Swift Testing for model and view-model logic and KassiOS for the flows, in separate targets.
One defect at a time
Running the suite against groups of defects tells you that something went red. It doesn't tell you which defect each test catches, or which defect nobody catches. For that, KassiOS has a command-line tool, kassios mutants. You list the defects and the tests each one must turn red, and it runs them one defect at a time:
mutant verdict
zeroAmountAccepted SURVIVED (still green: …/TransferTests/test_transfer_zeroAmount_disablesContinue)
balanceAfterAdds killed
...
58 of 59 killed
--clean first runs every listed test with no defect on, because a test that's already red "catches" everything. In the reference suite the list is generated from a `/// Catches id ` comment above each test, like the one on the toast test above, and CI fails if the generated file is out of date.
The suite has since grown from 34 tests to 71, and all 59 defects on the list are caught. Running them one by one takes about 70 minutes on my Mac. With --parallel 3, which runs three defects at once on clones of the simulator, it takes 28. Which tests stayed green the longest, and what it took to make them fail, is the next post.
A few other parts
- kassios lint reads screen objects before anything runs and flags those without onLoad or with computed identifiers. Apple's accessibility audit can run from a test.
- kassios agent start starts a small loopback-only Mac process for things a test can't do from inside the simulator: grant permissions, freeze the status bar, set a location. It picks its own token and port and tells the test where to find them. Without it, those calls skip with a message instead of hanging.
- KassHTTP arranges state through the app's API, so a transfer test doesn't have to create an account through the UI first.
- webView(), link and links() reach into a WKWebView. HTML has no accessibility identifiers, so web elements are found by label.
- With recordVideoOnFailure and the agent running, a failed test also gets a video of the simulator screen. Allure and JUnit reporters are built in.
Scenarios, network stubs, snapshot tests and the rest are in the README and the Guide.
Who it's for, and who it isn't
I think KassiOS fits if:
- your XCUITest suite has outgrown its helpers. There's a waitAndTap(), a base test class and page objects, and every new flaky test gets its own sleep;
- a silent failure is expensive in what you test. Forms, payments and settings are where a tap that missed or a field that kept part of its old value lets a test pass on a broken app;
- your team knows Kaspresso from Android and wants the same structure on iOS: screens, steps, scenarios, Allure;
- you want to know what your suite actually catches. kassios mutants only needs the app to switch a defect on at launch, through a launch argument or an environment variable.
It's probably not for you if:
- the suite is small. A dozen tests over a few screens are fine on raw XCUITest with a couple of extensions that every iOS developer can read;
- you need one suite for iOS and Android. Appium and Maestro do that, through a server or a runtime of their own. KassiOS is iOS only, Swift inside the app's Xcode project;
- you need the app's own idle state. EarlGrey runs inside the app and knows when it's idle. KassiOS stays out of process and polls. It has a synchronizer hook, with an example EarlGrey adapter in the repo;
- you want nothing between a failure and its line. KassiOS actions call XCTFail internally and return the element, so with continueAfterFailure = true a chain keeps going after a failure;
- you'd rather stay with Apple's tools than depend on a third-party layer with one maintainer.
None of this fixes flakiness that comes from the app, the test data or the environment. When something behaves oddly, you still need to know what XCUITest does underneath. Building KassiOS taught me more about that than writing tests would have.
Built with AI, checked like QA
I built KassiOS with a lot of help from AI coding tools. I'm a QA engineer, not a Swift framework developer, and that help let me go much further than I would have alone. It also meant checking everything: 172 unit tests, 59 UI tests against a small SwiftUI demo app in the KassiOS repo, the ChaosBank reference suite, and SwiftLint, all in CI on every pull request. The cursor bug above was found by the ChaosBank suite, not by me reading the code.
Trying it
.package(url: "https://github.com/VadimToptunov/KassiOS.git", from: "1.10.0")
Add it to your UI-test target, make the test class a KassTestCase instead of XCTestCase, and write one screen object, for example for the screen your flakiest test starts on. Plain XCUITest calls keep working next to it. The public API has followed semantic versioning since 1.0.
The command-line tools (the agent, the linter and the mutation runner) are one Homebrew formula, prebuilt, so there's nothing to build:
brew install vadimtoptunov/tap/kassios
kassios agent start
The library doesn't need them; install them when you want one of those features.
If you try it, I'd like to hear what feels unnecessary, what's missing, and where it gets in your way. Issues and PRs are welcome.
Links
- KassiOS
- ChaosBank for iOS and the post about it
- ChaosBank × KassiOS reference suite
- XCTest documentation
- Kaspresso
- EarlGrey
- Allure Report
Mentions
KassiOS: слой в стиле Kaspresso поверх XCUITest, проверенный на сломанном банке Я занимаюсь автоматизацией мобильного тестирования, в основном на 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() sourcecraft.dev 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() } sourcecraft.dev 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] } } sourcecraft.dev Перед тем как выполнить блок, 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!'? sourcecraft.dev У ожиданий есть и обратная сторона. Слишком терпеливый ретрай превращает сломанное приложение в зелёный тест. Поэтому в KassiOS ожидание только даёт интерфейсу время дойти до нужного состояния, а когда бюджет кончился, тест падает с последней причиной, которую фреймворк успел увидеть. Всё это можно отключить в KassConfig. Проверка на сломанном банке В какой-то момент у меня были аккуратные абстракции, куча юнит-тестов и никакого понимания, ловит ли всё это баги. Зелёный UI-тест говорит только о том, что приложение сделало то, что тест проверил. Покраснеет ли он, если появится тот самый баг, ради которого его писали, — неизвестно. Чтобы это выяснить, я сделал ChaosBank — банковское приложение, в котором баги включаются при запуске (я писал о нём на Хабре), — и эталонный набор тестов, который гоняет его через KassiOS. У каждого теста в наборе один и тот же контракт: чистое приложение → тест проходит включили дефект → тот же тест падает sourcecraft.dev Первый полный прогон, на 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' sourcecraft.dev Поле цены было выровнено по правому краю, и в нём уже стояло 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() } } sourcecraft.dev Тост висит на экране около 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") } } sourcecraft.dev 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() } } } } sourcecraft.dev Каждый случай попадает в отчёт отдельной 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 sourcecraft.dev С флагом --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: "VadimToptunov/KassiOS", from: "1.10.0") sourcecraft.dev Подключите пакет к UI-тест-таргету, унаследуйте тестовый класс от KassTestCase вместо XCTestCase и напишите один screen object — например, для экрана, с которого начинается ваш самый нестабильный тест. Обычные вызовы XCUITest продолжат работать рядом. Публичный API следует семантическому версионированию с версии 1.0. Консольные инструменты (агент, линтер и kassios mutants) ставятся одной формулой Homebrew, уже собранными, так что собирать ничего не нужно: brew install vadimtoptunov/tap/kassios kassios agent start sourcecraft.dev Самой библиотеке они не нужны: ставьте их, когда понадобится что-то из этого. Если попробуете, расскажите, что показалось лишним, чего не хватило и где KassiOS мешал. Issues и PR приветствуются. Ссылки KassiOS ChaosBank для iOS и статья о нём на Хабре Эталонный набор ChaosBank × KassiOS Документация XCTest Kaspresso EarlGrey Allure Report
KassiOS: a Kaspresso-style layer for XCUITest, tested against a broken bank My background is QA automation, mostly mobile and mostly Appium. When I decided to learn native iOS UI testing properly, XCUITest looked like the obvious choice: it's Apple's own framework, it needs no automation server, and it works with the accessibility hierarchy directly. Then a simple login turned into this: 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 gives you primitives and leaves waiting, retries, screen objects and reporting to you. In most projects I've seen, that gap gets filled by a waitAndTap() extension, then a few more, until there's a small framework that nobody tests. I decided to write that framework on purpose and see where it went. That became KassiOS. The ideas come from Kaspresso, which I liked on Android: a test describes what the user does, the framework handles the waiting, and every step shows up in the report. The API is my own, and underneath it's still plain XCUITest in your UI-test target, with no dependencies. What a test looks like 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] } } Before the block runs, KassiOS checks the elements in onLoad. If the app ended up on a different screen, the test fails right there, in the "On LoginScreen" step, instead of on whichever field it happened to touch first. Every onScreen block is an activity in Xcode's report, so a red run reads as a list of steps. The test is shorter, but that isn't what I was after. I wanted the waiting and retrying to live in one place, in the framework, so that each test doesn't carry its own copy. Making one tap trustworthy The first version of tap() waited for the element and tapped it. Most of the work since then went into the cases where that isn't enough. Today a single login.loginButton.tap(), within one time budget: - waits for the element to exist and be hittable. Internal retries share the budget, so three attempts at a 10-second action take 10 seconds, not 30; - scrolls to the element if it's off screen, and dismisses the keyboard if it covers the target. It uses Done or Hide for that, never Return, because Return can submit a form; - taps once. Only the preconditions are retried. A framework that taps again "just in case" can cause a double submit, or hide a real one; - finds the element again on every attempt. A KassElement stores how to find the element (() -> XCUIElement), not the element itself, so if SwiftUI rebuilds the view, the next attempt gets the new one. Some actions also check what they did. setSwitch(on:), toggle(), replaceText and adjustPicker read the element back and fail if its state didn't change. Without it, a switch whose tap lands on its label, next to the control, returns as if it had worked, and the test can pass with the switch still off. When something does fail, the report gets a screenshot of the screen at that moment and the full accessibility tree, and the message tries to say why: KassiOS: text 'welcom' — assertVisible failed: does not exist ↳ element not found in the current hierarchy ↳ did you mean: 'welcome', 'Welcome!'? Waiting has a limit too. A retry that forgives too much turns a broken app into a green test, so in KassiOS waiting only gives the UI time to reach a state. When the budget runs out, the test fails with the last reason the framework saw. Each of these behaviors can be turned off in KassConfig. Checking it against a broken bank At some point I had tidy abstractions, a lot of unit tests, and no idea whether any of it caught bugs. A green UI test tells you the app did what the test checked. It doesn't tell you the test would turn red if the bug it was written for showed up. To find out, I built ChaosBank, a banking app with bugs you switch on at launch (previous post), and a reference suite that tests it through KassiOS. Every test in the suite has the same contract: clean app → test passes enable a defect → the same test fails The first full run had 34 tests (Xcode 27.1, iPhone 17 simulator, M1 Max), and it was humbling: - test_portfolio_showsTotalAndPnl stayed green with every defect on, including one that shows a loss as a gain. It checked that the numbers were on screen, not that they were right. - The localization and flaky defect groups didn't turn a single test red. The suite had no locale or race checks at all. - Red runs were much slower than green ones. Most of the extra time went into my own "did you mean?" hint, which queried about 150 elements one by one. Reading a single hierarchy snapshot instead took the failing transfer test from 85 seconds to 23. The suite also found a bug in KassiOS. After I added the read-back, one test failed like this: KassiOS: textField 'order.limitPriceField' — replaceText('179.00') failed: typed '179.00' but the field reads '179.009.07' The price field was right-aligned and pre-filled with 189.07. The tap at its centre put the cursor in the middle of the text, the deletes removed only 18, and the new value went in front of 9.07. The previous version cleared fields the same way. It just didn't look, so the test would have failed a line later on a wrong order total, with nothing pointing at the field. Now KassiOS taps the trailing edge before deleting. What the defects added Several checks came from defects that slipped through. Take successToastMissing: the transfer goes through and the sheet closes, but the success toast never appears. A test that waits for the sheet to close stays green. Catching it needed a different kind of wait: /// 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() } } The toast is on screen for about 1.4 seconds. The normal budget is built for "wait until it's ready", and assertAppears polls a short window instead, for "catch it while it's there". A few more that I kept needing: // A double submit: two taps on the same spot, no re-check in between. order.placeButton.rapidTap(times: 2) // A value that must not move while nothing happens. home.totalBalance.assertUnchanged(for: 3) // Prices that tick: read related values from one hierarchy snapshot. let t = home.readTexts(["home.total", "home.account.EUR", "home.account.USD"]) // The right number of rows can still be the wrong rows. history.cells().assertNoDuplicates() history.cells().assertEach("is money-in") { row in guard row.readLabel().contains("+") else { throw KassError("expected a money-in row") } } verifyAll runs every assertion in its block and reports all the mismatches on a screen, not only the first. For conditions you compose yourself there's eventually, continuously(during:), anyOf and retry(times:), as in Kaspresso. Validation defects tend to need the same form filled with many inputs, so there's a parameterized runner as well: func test_login_validatesTheEmail() { parameterized([("a@b.c", true), ("bad", false), ("", false)], name: { $0.0 }) { email, valid in relaunch() // a clean app for every case onScreen(LoginScreen.self) { login in login.email.replaceText(email) login.loginButton.tap() if valid { onScreen(HomeScreen.self) { $0.welcome.assertVisible() } } else { login.error.assertVisible() } } } } Each case is its own activity in the report, named by the name closure, so a red run says the "bad" case failed. A failing case doesn't stop the rest. Swift Testing has @Test(arguments:), but in Xcode 27.1 it only has a unit-test template, and the UI-test templates are still XCTest. I use Swift Testing for model and view-model logic and KassiOS for the flows, in separate targets. One defect at a time Running the suite against groups of defects tells you that something went red. It doesn't tell you which defect each test catches, or which defect nobody catches. For that, KassiOS has a command-line tool, kassios mutants. You list the defects and the tests each one must turn red, and it runs them one defect at a time: mutant verdict zeroAmountAccepted SURVIVED (still green: …/TransferTests/test_transfer_zeroAmount_disablesContinue) balanceAfterAdds killed ... 58 of 59 killed --clean first runs every listed test with no defect on, because a test that's already red "catches" everything. In the reference suite the list is generated from a `/// Catches id ` comment above each test, like the one on the toast test above, and CI fails if the generated file is out of date. The suite has since grown from 34 tests to 71, and all 59 defects on the list are caught. Running them one by one takes about 70 minutes on my Mac. With --parallel 3, which runs three defects at once on clones of the simulator, it takes 28. Which tests stayed green the longest, and what it took to make them fail, is the next post. A few other parts - kassios lint reads screen objects before anything runs and flags those without onLoad or with computed identifiers. Apple's accessibility audit can run from a test. - kassios agent start starts a small loopback-only Mac process for things a test can't do from inside the simulator: grant permissions, freeze the status bar, set a location. It picks its own token and port and tells the test where to find them. Without it, those calls skip with a message instead of hanging. - KassHTTP arranges state through the app's API, so a transfer test doesn't have to create an account through the UI first. - webView(), link and links() reach into a WKWebView. HTML has no accessibility identifiers, so web elements are found by label. - With recordVideoOnFailure and the agent running, a failed test also gets a video of the simulator screen. Allure and JUnit reporters are built in. Scenarios, network stubs, snapshot tests and the rest are in the README and the Guide. Who it's for, and who it isn't I think KassiOS fits if: - your XCUITest suite has outgrown its helpers. There's a waitAndTap(), a base test class and page objects, and every new flaky test gets its own sleep; - a silent failure is expensive in what you test. Forms, payments and settings are where a tap that missed or a field that kept part of its old value lets a test pass on a broken app; - your team knows Kaspresso from Android and wants the same structure on iOS: screens, steps, scenarios, Allure; - you want to know what your suite actually catches. kassios mutants only needs the app to switch a defect on at launch, through a launch argument or an environment variable. It's probably not for you if: - the suite is small. A dozen tests over a few screens are fine on raw XCUITest with a couple of extensions that every iOS developer can read; - you need one suite for iOS and Android. Appium and Maestro do that, through a server or a runtime of their own. KassiOS is iOS only, Swift inside the app's Xcode project; - you need the app's own idle state. EarlGrey runs inside the app and knows when it's idle. KassiOS stays out of process and polls. It has a synchronizer hook, with an example EarlGrey adapter in the repo; - you want nothing between a failure and its line. KassiOS actions call XCTFail internally and return the element, so with continueAfterFailure = true a chain keeps going after a failure; - you'd rather stay with Apple's tools than depend on a third-party layer with one maintainer. None of this fixes flakiness that comes from the app, the test data or the environment. When something behaves oddly, you still need to know what XCUITest does underneath. Building KassiOS taught me more about that than writing tests would have. Built with AI, checked like QA I built KassiOS with a lot of help from AI coding tools. I'm a QA engineer, not a Swift framework developer, and that help let me go much further than I would have alone. It also meant checking everything: 172 unit tests, 59 UI tests against a small SwiftUI demo app in the KassiOS repo, the ChaosBank reference suite, and SwiftLint, all in CI on every pull request. The cursor bug above was found by the ChaosBank suite, not by me reading the code. Trying it .package(url: "https://github.com/VadimToptunov/KassiOS.git", from: "1.10.0") Add it to your UI-test target, make the test class a KassTestCase instead of XCTestCase, and write one screen object, for example for the screen your flakiest test starts on. Plain XCUITest calls keep working next to it. The public API has followed semantic versioning since 1.0. The command-line tools (the agent, the linter and the mutation runner) are one Homebrew formula, prebuilt, so there's nothing to build: brew install vadimtoptunov/tap/kassios kassios agent start The library doesn't need them; install them when you want one of those features. If you try it, I'd like to hear what feels unnecessary, what's missing, and where it gets in your way. Issues and PRs are welcome. Links - KassiOS - ChaosBank for iOS and the post about it - ChaosBank × KassiOS reference suite - XCTest documentation - Kaspresso - EarlGrey - Allure Report