I open-sourced SemanticsTesting, a small Swift package for testing SwiftUI views from a normal unit-test target. It hosts the view in a window inside the test process and lets you query and drive what VoiceOver would see. A test looks like this:
@Suite(.serialized)
@MainActor
struct CounterViewTests {
@Test func incrementUpdatesCount() throws {
let tester = SemanticsTester { CounterView() }
defer { tester.tearDown() }
try tester.expect("Count: 0")
try tester.tap("Increment")
try tester.expect("Count: 1")
}
}
That test runs in about 200 ms on a simulator.
A test driving a form, slowed down with one-second pauses so you can watch it. Without the pauses it takes a couple of seconds, most of it sheet animation and typing.
Why
The two established ways to test SwiftUI views both cost something.
- ViewInspector reflects the view hierarchy. It needs hooks in production code (
didAppearclosures,Inspectableconformances, wrapperGroups) and it breaks when SwiftUI’s internals change. - XCUITest runs out of process. It launches the app, takes seconds per test, needs its own target, and flakes.
I wanted a third thing: a test in the module’s own test bundle that renders a view, taps a button and asserts on a label, in milliseconds, without touching production code.
How it works
SemanticsTester puts the view in a UIWindow inside the test process and turns on the process-wide accessibility automation flag that XCUITest uses. Without that flag, SwiftUI leaves its accessibility node tree empty. With it, the tree is there, and the tester walks it.
Queries return SemanticsNode values with label, value, identifier, traits, isEnabled and custom action names. Actions go through the same elements: tap activates a button, type(_:into:) fills a text field, node.performCustomAction(named:) fires a custom action. Waits poll the tree until a node appears or disappears, and scroll lazy lists so rows below the fold materialize.
Nothing is reflected. The test sees exactly what VoiceOver sees. That has a useful side effect: a control the test cannot find is a control VoiceOver users cannot use. When we ported an app to this, about twenty views gained proper labels and button traits because the tests demanded them.
When it fails
A failed expectation throws with a dump of the accessibility tree, so you see what was actually there:
Timed out waiting for node with label 'Hello, Bob'
--- accessibility tree ---
○ UIWindow
○ UITransitionView
○ _UIHostingView<AnyView>
…
● AccessibilityNode label='Count: 2' [text]
● AccessibilityNode label='Increment' [button]
● AccessibilityNode label='Notifications' value='1' [button]
● AccessibilityNode label='Edit name' [button]
…
○ UITransitionView
○ UIDropShadowView
…
● UIKitTextField value='Ada'
● AccessibilityNode label='Save' [button]
● AccessibilityNode label='Hello, Ada' [text]
Filled circles are accessibility elements, hollow ones are containers. The second UITransitionView is the presented sheet.
The rules
These come from running the tester against several hundred view tests. Break them and you get flaky trees, hangs, or “0 tests” runs.
@Suite(.serialized)and@MainActoron every suite that hosts a view. The tester owns one key window per process.defer { tester.tearDown() }right after creating a tester.- Disable parallel testing for the bundle in the scheme. Parallel Swift Testing suspends an async test while another suite spins the run loop, and the suspended test never resumes.
- Use the async API (
tapAsync,expectAsync,settle) when the effect crosses anawait. The synchronouspump()blocks the main actor, so aTaskinside a button action makes no progress in it. - Add an empty app target as the test host. Sheets, menus, popovers and text fields need a live
UIApplication. Without a host you still get queries, taps, toggles, custom actions and scrolling.
Migration numbers
For the app that motivated this, replacing ViewInspector was one pull request:
| Tests ported | 375 |
| Whole diff | 325 files, +21.6k / −7.0k lines |
| Of that, test code | 212 files, +20.2k / −6.4k |
| Of that, the harness | 5 files, +1.0k |
| Of that, production code | 96 files, +283 / −452 |
| Production files changed only to remove test scaffolding | 69 |
| Views that gained accessibility labels or traits | 20 |
Production code got smaller. The big number is tests, and tests are the safe part of the diff.
Caveats
It uses private API to do its job: the accessibility automation flag, UIApplication.sharedApplication through KVC, and a few UIAlertController and UIAction internals to fire alert buttons and menu items. Any iOS release can break one of them. The package test suite is the canary. Never link it into an app you ship; add it to test targets only.
Requirements: iOS 17+, Swift 6 language mode, Xcode 26. Works with Swift Testing or XCTest and depends on neither.
.package(url: "https://github.com/appswithlove/SemanticsTesting", from: "0.1.0")
Source, full API table and the host-app pattern are in the README.