Skip to main content
All articles

SwiftUI Accessibility Identifiers: Why Your Stamp Never Surfaces

Ben Van AkenCo-Founder & CTO7 min read

Parts 1 and 2 covered where UI tests belong and how our harness is built. This part is about the one thing every flow drives: the accessibility identifier, and the surprisingly long list of ways a correctly stamped one never reaches the accessibility tree.

Identifiers: the substrate everything drives

Accessibility and testability are the same property. The identifier a UI test drives is the handle VoiceOver already needs; a control with no accessible name is both an accessibility bug and untestable. We treat an inaccessible surface as a bug, not a polish item, and before recording a gap as unauthorable we check whether the blocker is simply a missing identifier. One disclosure recorded as having no oracle at any layer became a four-line assertion after one stamp per platform.

Our convention: camelCase for singletons (loginButton, customerSaveButton), family-dash-key for enumerated rows and per-instance controls (sidebar-crm, company-card-uuid, customer-field-businessName), keyed on an enum's rawValue and never on the localised display name. One string for one concept across platforms, so a surface that is a full-screen cover on iOS and a sheet on macOS takes one identifier. A control whose accessible name is a generic string like Add or Save needs a stamp more than a uniquely-named one; three Add buttons on three tabs resolved by label discriminate nothing and go red outright under a non-English run.

Where a stamped id does not simply surface

The identifier is in the source is worthless evidence. The only evidence is the id appearing in the app's debugDescription or the exported accessibility snapshot. Three independent inert stamps were found this way. The following are all measured, and all cost us at least a day each.

A container's identifier propagates down and overwrites its descendants' identifiers. Stamp a wrapping VStack, Section, Group, sheet root or overlay root, and every leaf underneath reports the container's id; the children's own stamps appear nowhere. The control is visibly on screen and correct, so the red reads as the field did not render and sends you hunting through configuration and permissions. Stamp the leaf. When the container is a deliberate root anchor, make it a real accessibility element first:

Swift
// Order matters. Reversed, SwiftUI pushes the root id down onto every leaf.
SlideContent()
    .accessibilityElement(children: .contain)
    .accessibilityIdentifier("onboarding-slide-2")

// Never on a container that encloses an actionable control:
// .combine merges the descendants away, so the button stops
// existing for VoiceOver, keyboard and XCUITest alike.

Three modifiers that all look like grouping produce three different outcomes: .contain keeps child ids, .combine destroys the children, and a bare stamp overwrites their ids. Diagnosing one as another sends you at the wrong file. A related trap: .accessibilityIdentifier applied directly to an NSViewRepresentable or UIViewRepresentable is inert, because the id does not cross into the AppKit or UIKit view's own accessibility subtree. The working shape is .contain plus the identifier.

On iOS, whether a stamp surfaces is decided by the control's authoring form. The label-less closure form, a Button whose label is an Image with a system name, surfaces the SF Symbol name as its identifier even when explicitly stamped, and as its VoiceOver announcement. The self-naming form surfaces its stamp and keeps the title as the VoiceOver name:

Swift
// Surfaces "plus" as the identifier, whatever you stamp on it.
Button { add() } label: { Image(systemName: "plus") }
    .accessibilityIdentifier("customerAddButton")

// Surfaces "customerAddButton"; VoiceOver still says "Add".
Button("Add", systemImage: "plus") { add() }
    .labelStyle(.iconOnly)
    .accessibilityIdentifier("customerAddButton")

The quiet case is the dangerous one. A query for the image named map resolved a tab row whose system image was map; the tap was inert rather than destructive, nothing moved, and a duplicate-of-the-previous-screen capture passed its rubric. Our flows now use a per-control resolver ladder (id, then accessibility label, then SF Symbol name) and report which rung fired. A ladder that silently falls back from id to a generic label goes green with the stamp deleted, masking exactly the regression the stamp exists to prevent.

A handful more, each measured:

  • A SwiftUI Button publishes one accessibility element whose label merges its child Texts. A staticTexts query for Saved is a false negative by construction on a Button-wrapped banner, and a decorative checkmark Image inside a card Button leaks a synthesised selected state into the ancestor, so both selection cards read Selected. Hide decorative glyphs and give each row an explicit accessibilityValue.
  • On the Xcode 26 toolchain a WindowGroup's identifier no longer reaches the AXWindow. Descendant ids still resolve, but app.windows[id] never matches. The fix is a small NSViewRepresentable that sets the window's accessibility identifier once the host window attaches, retrying on the next run-loop tick. Toolchain upgrades can silently disable your whole window-id substrate; the login smoke test is what catches it.
  • A macOS SwiftUI Table publishes as an AX Outline, so app.tables.firstMatch matches nothing on a screen that plainly shows a table. Use app.outlines. A row's identifier surfaces on the primary column's StaticText, not on a row or cell, so stamp the primary Text. On iOS stamp both idioms, the iPad Table primary text and the iPhone compact List row, or the iPhone lane can only open rows positionally.
  • Context-menu items never receive a stamped identifier, on AppKit or UIKit. They are title-addressable only, so verify the title is unique across the whole snapshot, menu bar included. And a context-menu item only enters the tree while the menu is open, so a mustNot-exist assertion on one is unfailable.
  • On macOS a stamped Text's string is its AX value, not its label. Read value then label, and use the same predicate for a negative and its positive control so a wrong-channel guess reds the control instead of silently satisfying the absence.
  • In a macOS Menu, stamp the rows. A Picker's own identifier does not survive inside a Menu; AppKit flattens it into a header item plus one item per option. Unstamped option rows inherit an id derived from their first SF Symbol, which is checkmark on the selected row, so the id moves with the selection. And every AXMenuItem has an empty label; names live on title.
  • A .toolbar attaches to the nearest enclosing navigation container, not to the view that declares it. A toolbar item declared three hops down inside a GroupBox inside a ScrollView was injected into the host window's chrome as a second, indistinguishable refresh glyph. Only the window root declares a toolbar.
  • A disabled SwiftUI control is still in the tree and still exists, while a missing element reads isEnabled false. Every XCTAssertFalse on isEnabled in one file was a false green because the resolver returned a non-existent element on a total miss.
  • A toolbar anchor proves the screen, never its content. A toolbar declared on an outer Group renders in the empty-state branch too, so a list-loaded oracle keyed on a toolbar control is green on a screen with no rows. Anchor content oracles on content: a row id, a match count.

Previously (Part 2): Designing an XCUITest Harness the App Cooperates With

Next up (Part 4): XCUITest Mechanics: Waits, Hittability, and Platform Traps

Are your SwiftUI screens accessible and testable?

Accessibility and testability are the same property. We audit and fix both in production SwiftUI apps. Book a call and let's look at your tree.