Swift Testing doesn't do UI testing. Apple's new framework replaces XCTest for unit tests — but driving a real app's UI still means XCUITest, and XCUITest still makes you hand-manage timing, retries, and stale element references.
KassiOS is a UI-testing suite on top of XCUITest — not a thin helper, but the full stack Kaspresso gives Android: readable screen objects, implicit waits under one shared flaky-safety budget, structured reports (Allure + JUnit), strict accessibility-identifier enforcement, parameterized runs, and zero-dependency snapshot regression. Zero external dependencies, one-line SPM install, Swift 6 ready.
Raw XCUITest makes you manage timing by hand. KassiOS bakes waiting and retries into every interaction, so tests read like a script.
Before — raw XCUITest:
let email = app.textFields["login.email"]
XCTAssertTrue(email.waitForExistence(timeout: 10))
email.tap(); email.typeText("test@example.com")
let button = app.buttons["login.submit"]
XCTAssertTrue(button.waitForExistence(timeout: 10))
button.tap()
XCTAssertTrue(app.staticTexts["home.welcome"].waitForExistence(timeout: 10))After — KassiOS:
onScreen(LoginScreen.self) { login in
step("Enter valid credentials") {
login.email.typeText("test@example.com")
login.loginButton.tap() // waits + retries internally
}
}
onScreen(HomeScreen.self) { home in
home.welcome.assertVisible() // waits until it appears
}No waitForExistence. No sleep. No stale references.
Swift Testing and KassiOS are complementary, not competitors:
| Swift Testing | KassiOS | |
|---|---|---|
| Layer | unit / logic | end-to-end UI |
| Drives the app UI | no | yes (XCUITest) |
| Runs in-process with your types | yes | no (separate UI-test target) |
| Parameterized cases | @Test(arguments:) |
parameterized(_:) |
Use Swift Testing for your model and view-model logic; use KassiOS for the flows a user actually taps through. They live in different targets and never collide.
Types use a short
Kassprefix (KassScreen,KassElement,KassTestCase,KassConfig). Change with one find-replace if you prefer another.
📖 Full documentation guide — every feature, plus an honest when-to-use / when-not-to. Guides in the DocC reference: Coming from Kaspresso, Why your XCUITest suite flakes, Parameterized UI tests, and a CI recipe. Migrating an existing suite? Migration guide. Release notes: CHANGELOG.md.
🧪 See a real suite: ChaosBank ⨯ KassiOS is a complete, hand-written KassiOS suite driving ChaosBank — a deliberately-buggy fintech app — end to end (WKWebView login, live market feed, money flows). It passes on the clean build and turns red on each injected defect, so you can read real screen objects and tests instead of toy snippets.
Swift Package Manager. Add the package and link it to your UI Test target:
.package(url: "https://github.com/VadimToptunov/KassiOS.git", from: "1.7.0")- Describe screens:
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] }
}- Write tests against a
KassTestCase:
final class LoginFlowUITests: KassTestCase {
func test_login() {
launch()
onScreen(LoginScreen.self) { login in
login.email.typeText("test@example.com")
login.password.typeText("secret")
login.loginButton.tap()
}
}
}- Implicit waits + flaky-safety. Every interaction is wrapped in a retry
loop (
Waiter) that shares one time budget, so retries never compound into runaway timeouts. Toggle withconfig.flakySafetyEnabled. - Elements re-resolve on each attempt.
KassElementstores a() -> XCUIElementclosure, not a cached element — the fix for stale references after the hierarchy reloads. - Readable reports.
onScreenandstepwrapXCTContext.runActivity, so Xcode's test report and.xcresultgroup actions the way you wrote them.
| Raw XCUITest | EarlGrey | KassiOS | |
|---|---|---|---|
| Implicit waits / flaky-safety | manual waitForExistence |
idling resources | built-in, one shared budget |
| Stale-safe (re-resolve each attempt) | no | n/a | yes |
| Screen-object DSL | roll your own | none | KassScreen |
Flow primitives (anyOf/continuously) |
no | partial | yes |
| Parameterized (data-driven) tests | no | no | yes |
| Reporting | .xcresult only |
none | Allure + JUnit |
| Accessibility-id enforcement + audit | no | no | yes |
| Snapshot regression | external lib | no | built-in, zero-dep |
| Idle synchronization | — | core strength (in-process) | pluggable (EarlGrey adapter) |
| Dependencies | — | heavy | zero |
| Setup | none | non-trivial | one-line SPM |
EarlGrey's edge is in-process idle synchronization; KassiOS stays out-of-process (pure XCUITest) but exposes a synchronizer seam to plug EarlGrey in. See the honest when-to-use / when-not-to.
Every method below is chainable, self-waiting and flaky-safe:
// Locators — id-first: matches the real accessibility identifier when one
// exists, only falling back to a label match when it doesn't
element("checkout.confirm") // any element type, not just one
element(id: "sheet.title", label: "Confirm") // id AND label — for SwiftUI's
button(id: "sheet.title", label: "Confirm") // container-id propagation
staticText(containing: "Welcome") // label substring (intentional
button(containing: "Sign In") // label matching — no id warning)
element(labelContains: "Welcome") // — any type, same intent
// Interactions
element.tap()
element.typeText("hello")
element.clearText()
// Gestures
element.doubleTap()
element.longPress(forDuration: 1.5)
element.swipeUp() // .swipeDown() / .swipeLeft() / .swipeRight()
// Scroll a container until the element is on screen
row.scrollTo(in: list, direction: .up)
// Assertions
element.assertVisible() // strict: exists + hittable (on screen)
element.assertPresent() // softer: exists + non-empty frame
element.assertExists()
element.assertNotExists() // or .waitUntilGone()
element.assertEnabled() // .assertDisabled()
element.assertSelected(true)
element.assertTextContains("partial") // substring of value-or-label (was `assertHasText`, now deprecated)
element.assertHasValue("exact") // exact match on .value
element.assertLabel("exact")
element.assertLabelContains("part")
element.assertValueMatches("^\\d{4}$") // regex on value-or-label
element.assertHittable() // .assertNotHittable()
element.waitUntil("is selected") { $0.isSelected }
// Deep assertions — beyond presence-only checks
element.assertValue(closeTo: 92.5, tolerance: 0.01) // numeric, locale-aware ("$92.50")
element.assertAppears(within: 2) // catches a transient (toast) before it dismisses
// Scoped children — resolve within an element
row.staticText("title").assertTextContains("Inbox")
row.button("delete").tap()
// Controls
element.setSwitch(on: true) // toggles only if needed
element.toggle() // flips unconditionally, regardless of current state
element.adjustSlider(toNormalizedPosition: 0.75) // iOS
element.adjustPicker(toValue: "March") // iOS
// Editing
element.replaceText("new") // clears, then types
// Multitouch (iOS)
element.pinch(scale: 2, velocity: 1)
element.rotate(.pi / 4, velocity: 1)
element.twoFingerTap()A not-found failure appends a one-line snapshot of the element (exists,
hittable, id, label, type, frame) plus, when the locator was built
from an accessibility identifier that genuinely doesn't exist, the closest
matching identifiers/labels actually on screen — so a wrong-id guess (common
from an AI author) self-corrects instead of a second round trip:
KassiOS: text 'welcom' — assertVisible failed: does not exist
↳ element not found in the current hierarchy
↳ did you mean: 'welcome', 'Welcome!'?
Ranked by case-insensitive Levenshtein distance
(KassIdentifierSuggestions.nearest(to:among:)), bounded to a 400-element walk
of the current screen, and gated behind
KassConfig.suggestSimilarIdentifiersOnFailure (on by default — the walk only
runs once an assertion has already failed).
A test defaults to fail-fast: the first failed assertion stops the method, so
a red run only ever tells you about one mismatch. verifyAll is the
standard QA "soft assertion" / "verify-all" pattern for a screen-state check —
it runs every assertion in its scope to completion, so one run reports all
the mismatches at once:
verifyAll("account summary") {
onScreen(AccountScreen.self) { screen in
screen.balance.assertHasValue("$100.00")
screen.name.assertLabel("Jane Doe")
screen.avatar.assertVisible()
}
}If two of those three fail, the test still reports both — not just the first.
verifyAll restores the prior fail-fast setting once its scope ends, so
everything outside (or after) it is fail-fast again. It's the whole-screen
counterpart to assertEach below, which is the same soft-aggregate idea
scoped to a collection's rows.
Compose custom conditions out of throwing checks (requireExists,
requireVisible, requireHittable) with the same primitives Kaspresso offers:
// Retry a multi-step condition until it holds (or the budget elapses).
eventually { try banner.requireVisible(); try dismiss.requireHittable() }
// Assert something stays true for a duration (the inverse of flaky-safety).
continuously(during: 1.0) { try spinner.requireExists() }
// Pass if the UI is in any one of several valid states.
anyOf(
KassBranch("logged in") { try home.requireVisible() },
KassBranch("needs 2FA") { try otpField.requireVisible() }
)
// Attempts-bounded retry.
retry(times: 3) { try list.requireExists() }
device.pressBack() // taps the leading navigation-bar buttonKassElementCollection is the query-level counterpart of KassElement:
screen.cells().assertNotEmpty()
screen.cells().assertCount(24)
screen.cells().element(at: 0).tap()
screen.cells().containing(.staticText, "Inbox").first.tap()
screen.staticTexts().matching(label: "Error").assertNotEmpty()
screen.images().forEach { $0.assertExists() }
// Deep assertions — catch pagination/leakage bugs a count alone misses
screen.cells().assertNoDuplicates() // by label, or pass `by:` for a custom key
screen.cells().assertEach("is money-in") { row in
guard row.readLabel().contains("+") else { throw KassError("expected a money-in row") }
}
screen.cells().within(timeout: 5).assertNotEmpty() // per-call timeout, like KassElement.withinBuilders: all(_:), all(_:type:), buttons(), staticTexts(), cells(),
images(), customCollection(_:_:).
Run one body across many cases — each an isolated, reported activity (the
XCUITest analogue of Swift Testing's @Test(arguments:)):
parameterized([("a@b.c", true), ("bad", false), ("", false)], name: { $0.0 }) { email, valid in
relaunch() // clean slate between cases
onScreen(LoginScreen.self) { login in
login.email.replaceText(email)
login.submit.tap()
valid ? onScreen(HomeScreen.self) { $0.welcome.assertVisible() }
: login.error.assertVisible()
}
}device on a KassTestCase reaches outside the app's view tree:
device.autoAllowSystemDialogs(test: self) // dismiss permission alerts
app.tap() // nudge XCUITest to deliver it
device.hideKeyboard()
device.screenshot("after login") // attached to the .xcresult
device.allowSystemDialogNow() // tap an on-screen permission alert
device.sendToBackground(for: 2) // then reactivates
device.pressHome() // iOS only
device.rotate(to: .landscapeLeft) // iOS only
device.open(url: "https://example.com") // deep link via Safari (iOS)
device.waitForIdle() // via the configured synchronizer
let springboard = device.springboard // home screen / system alerts hostAn agent-facing inventory of the current screen — every element's identifier,
type, label and hittability — for scaffolding KassScreen objects or a quick
discovery pass without eyeballing debugDescription:
let inventory = device.dumpIdentifiers() // only real accessibility ids
device.dumpIdentifiers(includingUnidentified: true) // + everything else, for discovery
device.attachIdentifierInventory() // same, + pretty JSON attached to the reportEach entry is a Codable KassIdentifierInfo { identifier, type, label, isHittable }, read from one accessibility snapshot of the app (so isHittable
here means "on screen", not occlusion-aware); the walk is bounded (400 elements),
same as the "did you mean?" suggestions above.
Some device settings can't change live; KassiOS models them honestly as a relaunch (launch arguments — works on the simulator and real devices):
device.relaunch { $0.locale("de_DE").language("de") }
device.relaunch { $0.dynamicType("UICTContentSizeCategoryAccessibilityXL") }Some things a test can't reach from inside the simulator — granting permissions
without a dialog, freezing the status bar, setting location, pushing a
notification, flipping appearance, recording video on failure — because
xcrun simctl is a host-side command the XCUITest process can't exec.
kassios-agent bridges that gap: a tiny, loopback-only, token-authenticated Mac
process the in-simulator test talks to over localhost.
try device.statusBar.freeze(time: "9:41", battery: 100, cellularBars: 4)
try device.location.set(latitude: 34.7071, longitude: 33.0226)
try device.appearance(.dark)
config = KassConfig(recordVideoOnFailure: true) // .mp4 attached only on failureEach call XCTSkips with an actionable message — never hangs — when no agent
is running or on a real device, so a suite without the agent still goes green.
The full API, the security posture (loopback-only, per-run token auth,
allowlisted simctl mapping — never arbitrary argv), video-on-failure, and
the CI setup live in the
Guide.
Extract common journeys into a KassScenario and replay them anywhere:
struct LoginScenario: KassScenario {
let email: String, password: String
func run(in test: KassTestCase) {
test.onScreen(LoginScreen.self) { login in
login.email.typeText(email)
login.password.typeText(password)
login.loginButton.tap()
}
}
}
func test_home() {
launch()
scenario(LoginScenario(email: "a@b.c", password: "secret"))
onScreen(HomeScreen.self) { $0.welcome.assertVisible() }
}A failing test automatically attaches a screenshot of its final state to the report.
Attach an AllureReporter and KassiOS writes Allure 2
result files — nested steps, interactions and screenshots included:
override func setUp() {
super.setUp()
config = KassConfig(reporter: AllureReporter())
}Results go to $ALLURE_RESULTS_PATH (set it in the UI test target's scheme) or
<temp>/allure-results; the absolute path is logged at test start. Then:
allure serve <results-dir>Every step, interaction and device.screenshot becomes a step/attachment in
the report. Steps left open by a hard failure are attributed the test's
terminal status, so the tree always closes cleanly.
By default KassiOS is poll-based: Waiter re-tries until the UI is ready. For
stronger flaky-safety you can plug in a KassSynchronizer that blocks each
attempt until the app is idle (animations, network, main-queue work settled):
config = KassConfig(synchronizer: EarlGreySynchronizer())The core ships NoOpSynchronizer and stays dependency-free; an EarlGrey-backed
adapter is provided as an opt-in reference in
Examples/EarlGreySynchronizer.swift.
For built-in idle waiting without EarlGrey, use StabilizingSynchronizer,
which settles once the accessibility tree stops changing — see
Documentation/Guide.md.
Force the app to carry real accessibility identifiers. .warn surfaces an Xcode
message; .enforce fails the test (with a screenshot) when an element is matched
by label instead of an explicit id:
config = KassConfig(accessibilityIdentifierPolicy: .enforce) // or .warn / .ignore
// 'Orders' was matched without an accessibility identifier (element id='') —
// add .accessibilityIdentifier("Orders") to the view [strict mode]
// ↳ exists=true hittable=true id='' label='Orders' type=48 frame=(…)Run Apple's accessibility audit too:
if #available(iOS 17.0, *) { assertNoAccessibilityIssues() }The audit above runs at test time, on a live app. kassios-lint is its
compile-time twin: a SwiftSyntax linter that reads your screen-object source
and flags the same class of problems before you even launch the simulator —
KassScreen subclasses with no non-empty onLoad (KAS001) and element
builders whose identifier isn't a static string literal (KAS002).
It lives in a nested package at Plugins/ so swift-syntax never becomes a
dependency of the core library — adding KassiOS to your app pulls in nothing.
Run it as a command plugin or a standalone tool:
# From a checkout of this repo (or vendor the Plugins/ folder):
cd Plugins
swift run kassios-lint ../MyApp/UITests # informational; --strict to fail on any finding
# or, as an SPM command plugin:
swift package kassios-lintAdd swift run kassios-lint --strict UITests to CI to keep every screen object
identifier-clean. See the DocC guide Static linting with kassios-lint.
Share one config across a group with KassSuite, and structure a body with
before/after/run (after runs even on a hard failure):
class CheckoutSuite: KassSuite {
override func configure() -> KassConfig {
KassConfig(reporter: AllureReporter(), accessibilityIdentifierPolicy: .enforce)
}
}
before { launch() }.after { device.screenshot("end") }.run {
step("Checkout") { … }
}override func setUp() {
super.setUp()
config = KassConfig(timeout: 20, pollInterval: 0.25)
}The library wraps XCUITest, so it builds with Xcode rather than bare
swift build. Run the (pure-logic) unit tests on the macOS destination:
xcodebuild test -scheme KassiOS -destination 'platform=macOS'Real UI coverage lives in IntegrationTests/: a bundled
SwiftUI demo app plus KassiOS-driven UI tests that run on the simulator. Generate
the project and run them:
cd IntegrationTests && ruby gen.rb # needs the `xcodeproj` gem
xcodebuild test -scheme KassDemoUITests -destination 'platform=iOS Simulator,name=iPhone 16'Lint locally with SwiftLint:
swiftlint lint --strictCI (.github/workflows/ci.yml) runs three jobs on every push and PR — SwiftLint,
unit tests on macOS, and UI tests on a simulator. A separate workflow
(.github/workflows/docs.yml) publishes the DocC site to GitHub Pages (enable
Pages with the "GitHub Actions" source in repo settings).
1.7.0 — stable. The public API is frozen under semantic versioning since 1.0.0; releases since are additive (see the changelog).
Core DSL, waits, flaky-safety, step logging, gestures + scroll-to +
multitouch + coordinate/drag + pull-to-refresh, slider/switch/picker controls,
rich assertions (strict assertVisible vs. soft assertPresent,
label-contains/value-regex/placeholder/waitUntil), soft assertions
(verifyAll — run the whole block, report every mismatch), per-call
within(timeout:), element collections, scoped child elements, web content
(webView/link/links), wait-combinators + app-alert DSL, Kaspresso-style
flow primitives, page objects, robots & scenarios
(KassScreen/KassRobot/KassScenario), parameterized tests, KassSuite +
structured before/after/run, an accessibility-identifier policy
(ignore/warn/enforce) + accessibility audit + screen-object codegen
(KassScaffold), precise failure diagnostics (+ accessibility-tree dump +
"did you mean?" identifier suggestions), an identifier inventory
(device.dumpIdentifiers) for agent-driven discovery/scaffolding,
device/permission/deep-link/stub helpers + a kass-simctl CI toolkit, localized
screenshot runs, reusable scenarios, Allure + JUnit reporters, video-on-failure,
zero-dep snapshot regression, the kassios-lint compile-time linter, and a
pluggable synchronizer applied to every wait — including a built-in, dependency-free
StabilizingSynchronizer (idle-waiting without EarlGrey). Real UI coverage
runs on the simulator in CI (SwiftLint + unit + UI), with DocC auto-published to
Pages. See the full guide.
Verified end-to-end against Apple's open-source Food Truck app on the iOS Simulator — multi-screen navigation, flow primitives, screenshots and Allure export all green. It's also dogfooded against ChaosBank, a purpose-built buggy fintech app: the full ChaosBank ⨯ KassiOS suite passes the clean build and goes red on each planted defect — a complete, worked example you can read.
MIT — see LICENSE.