Skip to content
VadimToptunovPublic

About

A tiny, batteries-included Kaspresso-style DSL over XCUITest — implicit waits, flaky-safety, collections, flow primitives, parameterized tests, Allure export, accessibility-id enforcement. Zero dependencies.

Topics

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

KassiOS ☕️

CI Release Swift versions Platforms Docs License: MIT

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.

Why

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.

Where Swift Testing fits

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 Kass prefix (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.

Install

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")

Use

  1. 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] }
}
  1. 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()
        }
    }
}

How it works

  • 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 with config.flakySafetyEnabled.
  • Elements re-resolve on each attempt. KassElement stores a () -> XCUIElement closure, not a cached element — the fix for stale references after the hierarchy reloads.
  • Readable reports. onScreen and step wrap XCTContext.runActivity, so Xcode's test report and .xcresult group actions the way you wrote them.

How it compares

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.

Interactions & assertions

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()

Failure diagnostics: "did you mean?"

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).

Soft assertions: verifyAll

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.

Flow primitives (Kaspresso-style)

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 button

Collections (lists & tables)

KassElementCollection 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.within

Builders: all(_:), all(_:type:), buttons(), staticTexts(), cells(), images(), customCollection(_:_:).

Parameterized tests

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 helpers

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 host

Discovery: dumpIdentifiers

An 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 report

Each 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.

Tier B — relaunch with locale / language / Dynamic Type

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") }

Device control: kassios-agent (Tier C)

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 failure

Each 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.

Reusable flows (scenarios)

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.

Allure reports

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.

Synchronization backends

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.

Strict identifiers, suites & structured runs

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() }

Static lint — kassios-lint

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-lint

Add 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") { … }
}

Configure

override func setUp() {
    super.setUp()
    config = KassConfig(timeout: 20, pollInterval: 0.25)
}

Develop

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 --strict

CI (.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).

Status

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.

License

MIT — see LICENSE.

About

A tiny, batteries-included Kaspresso-style DSL over XCUITest — implicit waits, flaky-safety, collections, flow primitives, parameterized tests, Allure export, accessibility-id enforcement. Zero dependencies.

Topics

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages