Skip to main content
All articles

XCUITest Mechanics: Waits, Hittability, and Platform Traps

Ben Van AkenCo-Founder & CTO12 min read

The first three parts covered strategy, harness and identifiers. This part is about what actually happens between a query and a tap: how waits are priced, what isHittable really tells you, and the iPad, iPhone and macOS specifics that produce a red naming the wrong subsystem.

Waits: budget in queries, not seconds

On a heavy accessibility tree, a leg's wall-clock is its query count times roughly 19 seconds. A failed waitForExistence captures an element debug description, six full hierarchy snapshots, before retrying. One such call on an already-resolved element cost 123 seconds. Removing about 52 redundant resolutions took a suite from 5,134 seconds to 1,909 with no assertion changed. On a workspace publishing around 2,666 rows, identical app-scope existence checks climbed from 0.1 seconds to 31 until XCTest gave up with Failed to get matching snapshots.

  • Scope queries to a window or container. App scope is a cost that becomes a timeout on a large tree.
  • Never re-prove existence for an element you are already holding. A guard on exists followed by a read of value is two resolutions for one fact; a single try snapshot carries the value and throws on absence.
  • Pace a polling read with a run-loop pump, never with waitForExistence. On a heavy tree it returns instantly for an element that exists, pacing nothing, and costs about 20 seconds for one that does not, so eight of them in a settle loop silently become 150 seconds. A bare XCTWaiter on a fresh expectation with a half-second timeout pumps the run loop without touching the tree.
  • waitForExistence is the wrong instrument for a transient shorter than about 2.5 seconds after an interaction. The tap's own quiescence wait consumed 1.2 seconds and the waiter's first evaluation fired a second later, so the first look at the tree came 2.2 seconds post-tap, after a 1.8 second transient had closed. Catch a rising edge with a raw exists hot loop; if a state is transient by design, make it observable under test or assert its side effect.

The most expensive single lesson: app.activate() inside a read-only poll loop costs the full 60 second idle timeout whenever anything animates. activate blocks on XCUITest's wait for quiescence, and a repeatForever symbol effect, a pulsing indicator or a spinner keeps the run loop from ever going idle. The app is fully responsive the whole time; only the harness starves, and the trace reads exactly like a frozen product. activate exists to bring the app frontmost so a synthesised interaction lands. Keep it immediately before a tap or a type; an existence, absence, count or value poll must not call it. Never remove a product animation to satisfy the harness.

One more shape we found in 55 macOS files and 27 iOS files: take the first candidate that exists, fall back to the first candidate, then call waitForExistence on it. That degrades an N-way OR into a one-candidate wait, because the other rungs were evaluated once at time zero. Worse, on a total miss it returns a non-existent element, and a caller reading isEnabled on it asserts against nothing. Use an any-of wait that polls every candidate on every tick.

Swift
extension XCUITestCase {
    /// Polls every candidate each tick; returns the first that exists.
    /// Never returns a non-existent element as a "best guess".
    func waitForAny(_ candidates: [XCUIElement], timeout: TimeInterval) -> XCUIElement? {
        let deadline = Date().addingTimeInterval(timeout)
        repeat {
            if let hit = candidates.first(where: { $0.exists }) { return hit }
            // Pump the run loop without touching the AX tree.
            _ = XCTWaiter().wait(for: [XCTestExpectation()], timeout: 0.5)
        } while Date() < deadline
        return nil
    }
}

And a triage signal that costs nothing: near-identical failure durations across unrelated tests mean one shared early blocker, not N defects. Five iOS legs failing at about 33 seconds each were all burning the same bounded retry budget on an un-instantiated row.

Hittability is not what you think it is

isHittable is not a visibility oracle. It returned true for a card 3.25 points short of the viewport bottom and for one 349 points above it. It is satisfied by partial visibility: a 116 point text view straddling the window edge had a hit point in its visible 53 point sliver, and the click on its geometric centre landed 5 points below the window edge, on the window behind. A helper that early-returns on not hittable gives such a card zero scroll steps: a permanent stall, not a flake. Use a geometric oracle on whole-rect containment with an inset, never centre containment.

exists and isHittable do not imply unoccluded either. The iOS software keyboard lives in its own UIWindow; a text field it fully covered reported both, six synthesised taps were absorbed with no error, and the keyboard-dismiss helper had silently failed. Where occlusion is possible, check whether the field's frame intersects the keyboard's.

The single most expensive class of failure we have is the click that landed nowhere. SwiftUI keeps virtualised, scrolled-out rows in the accessibility tree with a zero frame parked at the scroll view's origin. A firstMatch over a predicate returns the first match in document order, routinely one of those zero-frame rows. Double-clicking its normalised centre computes the centre of a 0 by 0 frame, a screen point that can lie outside the app window entirely, and the event goes to whatever is there. The run fails about 15 seconds later against an unrelated assertion. One such red survived four investigations over four weeks: 66 identically-named rows, the first zero-frame at (0, 1440), failure message the detail window did not open.

  1. Resolve across allElementsBoundByIndex and pick a match that isHittable. Never firstMatch on a list or table row.
  2. The fallback for not hittable is to resolve a different hittable match or scroll it into view, or fail loudly. Never click the unhittable one's centre anyway.
  3. A failed actuation gets its own assertion with its own diagnostic: match count, each match's frame and hittability. The message must name the step that actually failed.
  4. A fixture found by name must not be assumed unique. Assert cardinality or tolerate duplicates explicitly.

A tap can even land on another application. A card scrolled to centre y 713 in a window whose bottom edge is y 703 fired 10 points below the app window, clicked the full-screen app behind it, and backgrounded the one under test. XCUITest logged it as Found 1 interrupting element, and nobody read it. What made it reachable: a long label wrapped, making the card 118 points tall instead of the 80 the heuristic assumed. Before concluding a tap missed, check whether the coordinate was inside the app window at all.

Finally, element.tap() aims at the accessibility element's centre, which is often not the control. For a SwiftUI Toggle in a Form the element spans the whole row and the centre is the label, about 155 points from the switch. Two suites had recorded that XCUITest cannot actuate a SwiftUI Toggle; both were driver bugs.

Swift
// Measured on a Form row Toggle, 12 samples each:
//   .tap()                      → no actuation
//   normalised dx 0.92, dy 0.5  → actuates, confirmed server-side
//   normalised dx 0.97          → misses (inside the cell padding)
let toggle = form.switches["settings-toggle-autoLock"]
toggle.coordinate(withNormalizedOffset: CGVector(dx: 0.92, dy: 0.5)).tap()

iPad and iPhone

Every iPad lane forces landscape before it expects a sidebar row. The simulator boots portrait, where a balanced NavigationSplitView collapses the primary column behind a toggle even with column visibility set to all. The lazy List then never instantiates the rows, so they are absent from the tree, not merely un-hittable. Set the device orientation before navigating, and key it to intent (side by side at regular width), not to a device model. This rule was written down under one domain's suite and still missed by the engine's own smoke test, which went red and was misdiagnosed as a product defect. A precondition filed under one domain's bullet list is not applied by sibling suites; promote standing findings into the shared base class in the same pull request that discovers them.

An unguarded iPad test that sets orientation leaves the simulator rotated, persistently, across test cases and across xcodebuild invocations. An iPad-named test ran on the iPhone destination, rotated it, and every later iPhone class failed at login with anchors that never resolve in landscape. The symptom names the wrong subsystem, login or credentials or tenant, and three hypotheses in that direction were all measured false. One simctl screenshot settled it: the app was logged in and rendered sideways. Guard iPad-only tests on the user interface idiom, not a width check: an iPhone 17 Pro in landscape is 852 points wide.

The keyboard is the iPad lane's biggest wall, and it is narrower than it looks. The simulator may refuse keyboard focus to a tapped field even with the software keyboard shown; typeText then raises an uncatchable event-synthesis failure, and there is no reliable focus probe. Before parking an iPad lane on this, eliminate typing from the flow. Every needs-typing step so far had a keyboard-free equivalent: reach a list row by momentum-free scoped press-drags instead of search; select a below-fold Menu option by dragging the bottom-most hittable option onto the top-most one, then tapping. The keyboard's separate window also poisons container queries: a firstMatch on scrollViews resolved the keyboard's typing-predictions strip, not the form. Select the app's scroll view by size.

The AutoFill Save Password sheet swallows every tap while the rows underneath stay in the tree. It is a remote-hosted overlay inside the app window, so a reached-workspace check passes and the next navigation dead-ends with no visible dialog in the tree dump. Dismiss it title-agnostically by tapping Not Now at app scope, in every login path. Not Now is safe app-wide; Save is not, because every create form exposes a Save and an app-scope tap would silently commit it.

Two iOS 26 changes bit us. The entire navigation bar is removed while a searchable search is active, so a nav-bar-hosted control is absent, not un-hittable, and an am-I-already-here early return keyed on a nav-bar id can never fire. And a confirmationDialog renders as an anchored popover and UIKit discards its cancel-role action at presentation with no diagnostic; the tell is a 240 point callout bubble on a 402 point phone. A binary confirm on iOS now uses an alert.

macOS

Multi-window discipline first. When a flow opens a parent detail window and a child inside it, then navigates away and reopens, close both before each detour and never accumulate windows across a reopen loop. macOS's first-click-raises-a-background-window behaviour otherwise turns the post-detour reopen into a raise of a stale window. And a window resolver that ends in app.windows.firstMatch makes a wait-for-existence assertion structurally unfailable, because the parent window always exists; subsequent assertions and the screenshot scope then apply to the wrong window, so absence claims pass vacuously. Gate on a contained anchor.

Command-W is not a window-scoped operation. An unconditional pair of them closed the detail window and then the main window, taking every seed marker out of the tree; the run failed 80 seconds later accusing the seeder. Gate it on an anchor whose lifetime is the window you intend to close.

A macOS modal attached to a window blanks every anchor behind it from the snapshot. A stranded confirmation alert surfaces two steps later as the sidebar could not be selected, and a family of reds was misattributed to navigation for several sessions. When a modal is suspected, measure it directly by querying sheets and dialogs after an appear-then-clear wait, rather than inferring from the downstream failure.

A hidden control in a background window will not actuate while a separate detail window is frontmost; a coordinate click on it only raises the hub. The reliable path for an app-global action is an app-global CommandMenu shortcut, which fires regardless of the key window. One lock leg was skipped for a month as an XCUITest background-window limitation while a sibling passed. The sibling passed because it had stopped using the button. Before accepting a window-layering explanation for a sibling-passes-here-fails divergence, diff the two call paths line by line.

The canon that XCUITest cannot expand an in-content macOS Menu was overturned by measurement: a menu whose component stamps its option rows expanded (the app-wide menu item count went from 229 to 236), resolved and committed a selection. The walled precedents were components that stamped nothing on their rows. Author expansion legs for real, and downgrade to a documented skip only if a leg actually flakes.

Previously (Part 3): SwiftUI Accessibility Identifiers: Why Your Stamp Never Surfaces

Next up (Part 5): Oracles and Isolation: When a Green XCUITest Proves Nothing

Is your UI-test lane slow, flaky, or both?

We profile and repair XCUITest suites for production SwiftUI apps on iPhone, iPad and Mac. Book a call and let's find where the minutes go.