Spatial Computing QA: Testing for Apple Vision Pro and Mixed Reality
visionOS testing requires new mental models for input (eyes + hands, no touchscreen), space (3D window placement, volumes, immersive spaces), and audio (spatial positioning matters). Run unit and integration tests in the simulator for fast iteration. Device testing is mandatory for eye tracking accuracy, spatial audio presence, and real comfort validation. Accessibility in XR is not optional — visionOS enforces it more strictly than any previous Apple platform.
Apple Vision Pro introduced a computing paradigm where applications exist in three-dimensional space, receive input from eye gaze and hand gestures, produce audio that appears to come from specific locations in a room, and can range from a floating window to a fully immersive environment. Testing this is not iOS testing with extra steps — the input model, rendering pipeline, and interaction design are categorically different.
visionOS Application Types and What Each Requires
visionOS applications run in three modes, and testing requirements differ significantly by mode:
Windowed apps behave closest to iPadOS. Windows float in space, user interacts via look-and-pinch or indirect touch. Test window placement, content correctness, and standard SwiftUI interactions.
Volumetric apps place 3D content in a bounded volume that exists in the user's space. Test 3D object rendering, interaction with RealityKit entities, and how the volume responds to the user approaching or moving away.
Full immersive spaces replace the user's environment entirely or blend with it via passthrough. Test environment rendering, spatial audio positioning, locomotion (if any), and comfort metrics similar to VR testing.
Most apps use a combination: a window to launch, a volume or immersive space for the core experience.
Eye Tracking Input Testing
Eye tracking is the primary pointing mechanism on Vision Pro. The user looks at an element, then pinches to activate it. This is fundamentally different from touch — the activation zone must be large enough to be reliably gazeable, but not so large that adjacent elements conflict.
What to Test
Gaze target sizing: Every interactive element must meet minimum size requirements. Apple recommends at least 60×60 points for primary actions, larger for secondary. Test that all interactive elements meet this threshold.
// Test that all buttons meet minimum tap target size
func testInteractiveElementMinimumSize() throws {
let app = XCUIApplication()
app.launch()
let interactiveElements = app.buttons.allElementsBoundByIndex
+ app.links.allElementsBoundByIndex
for element in interactiveElements {
let size = element.frame.size
XCTAssertGreaterThanOrEqual(size.width, 60,
"Button '\(element.label)' width \(size.width) below minimum 60pt")
XCTAssertGreaterThanOrEqual(size.height, 60,
"Button '\(element.label)' height \(size.height) below minimum 60pt")
}
}Hover effect correctness: Elements in visionOS show a hover effect when the user gazes at them. Test that hover state changes are visible, not delayed, and don't affect layout:
// Test hover state doesn't cause layout shift
func testHoverStateDoesNotCauseLayoutShift() {
let view = PrimaryActionButton(title: "Confirm")
let hostController = UIHostingController(rootView: view)
let normalFrame = hostController.view.frame
// Simulate hover state
let hoverView = PrimaryActionButton(title: "Confirm").hovered(true)
let hoverController = UIHostingController(rootView: hoverView)
let hoverFrame = hoverController.view.frame
XCTAssertEqual(normalFrame.size, hoverFrame.size,
"Hover state caused layout shift: \(normalFrame.size) → \(hoverFrame.size)")
}Gaze disambiguation: When interactive elements are adjacent, the system must correctly attribute gaze to one element. Test layouts where buttons are near each other:
- Adjacent buttons in a toolbar
- List items with trailing action buttons
- Overlapping windows in a multi-window layout
These require device testing — the simulator does not simulate eye tracking accuracy.
Eye Tracking in XCTest
XCTest on visionOS does not support direct eye tracking simulation (as of visionOS 2). Work around this by testing via accessibility actions, which use the same underlying activation mechanism:
func testPrimaryActionActivatesOnLookAndPinch() throws {
let app = XCUIApplication()
app.launch()
let confirmButton = app.buttons["Confirm Order"]
XCTAssertTrue(confirmButton.exists)
// In visionOS, tap() uses the look-and-pinch mechanism in tests
confirmButton.tap()
XCTAssertTrue(app.staticTexts["Order Confirmed"].waitForExistence(timeout: 2))
}Window Placement Testing
Windows in visionOS float in 3D space. The system manages their position, but your application defines their initial placement, size, and constraints.
Window Geometry Constraints
Test that your window opens at the correct size and respects constraints:
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.defaultSize(width: 800, height: 600)
.windowResizability(.contentSize) // or .automatic
}
}
// Test: window opens at defined default size
func testWindowOpensAtDefaultSize() {
let app = XCUIApplication()
app.launch()
let mainWindow = app.windows.firstMatch
XCTAssertTrue(mainWindow.waitForExistence(timeout: 3))
// Windows can't be precisely measured via XCTest frame in visionOS —
// test via content layout instead
let contentView = app.otherElements["MainContentView"]
XCTAssertTrue(contentView.exists, "Content view should be visible at default window size")
}Multiple Windows
visionOS supports multiple windows per application. Test window management:
func testSecondWindowOpensIndependently() {
let app = XCUIApplication()
app.launch()
// Trigger second window open
app.buttons["Open Details"].tap()
// Both windows should exist
XCTAssertEqual(app.windows.count, 2, "Expected 2 windows, found \(app.windows.count)")
// Closing one doesn't close the other
app.windows.element(boundBy: 1).buttons["Close"].tap()
XCTAssertEqual(app.windows.count, 1)
// Main window content unchanged
XCTAssertTrue(app.windows.firstMatch.staticTexts["Main Content"].exists)
}RealityKit and SwiftUI Testing
Most visionOS 3D content uses RealityKit entities within SwiftUI views via RealityView. Testing this requires separating entity logic from presentation.
Testing RealityKit Entity State
Keep entity configuration logic in testable, non-UI code:
// Testable entity configurator — no UI dependencies
struct GlobeEntityConfigurator {
static func configure(_ entity: Entity, radius: Float, color: UIColor) {
var material = SimpleMaterial()
material.color = .init(tint: color)
let sphere = MeshResource.generateSphere(radius: radius)
entity.components[ModelComponent.self] = ModelComponent(
mesh: sphere,
materials: [material]
)
entity.components[CollisionComponent.self] = CollisionComponent(
shapes: [.generateSphere(radius: radius)]
)
}
}
// XCTest — no headset required
class GlobeEntityTests: XCTestCase {
func testEntityHasCorrectRadius() {
let entity = Entity()
GlobeEntityConfigurator.configure(entity, radius: 0.1, color: .blue)
let collision = entity.components[CollisionComponent.self]
XCTAssertNotNil(collision, "Entity should have collision component")
let model = entity.components[ModelComponent.self]
XCTAssertNotNil(model, "Entity should have model component")
}
func testEntityPositionUpdates() {
let entity = Entity()
entity.position = SIMD3<Float>(0, 0, -1)
// Simulate interaction
entity.position.x += 0.5
XCTAssertEqual(entity.position.x, 0.5, accuracy: 0.001)
}
}Testing Gestures on 3D Content
RealityKit gestures (tap, drag, rotate, scale) need integration testing:
// Integration test for drag gesture
func testEntityFollowsDragGesture() throws {
let entity = ModelEntity(mesh: .generateBox(size: 0.1))
entity.generateCollisionShapes(recursive: false)
entity.components.set(InputTargetComponent())
let initialPosition = entity.position
// Simulate drag
let dragAmount = SIMD3<Float>(0.2, 0, 0)
entity.position += dragAmount
XCTAssertEqual(entity.position.x, initialPosition.x + 0.2, accuracy: 0.001,
"Entity x position should reflect drag")
}Spatial Audio Testing
Spatial audio in visionOS positions sounds in 3D space relative to the user. A sound from a virtual TV on the left should appear to come from the left. This is both a technical and experiential test domain.
Technical Spatial Audio Validation
// Test that audio source is positioned at entity location
func testAudioSourcePositionMatchesEntity() {
let entity = Entity()
entity.position = SIMD3<Float>(1.0, 0, -2.0) // 1m right, 2m forward
var audioComponent = SpatialAudioComponent()
audioComponent.directLevel = 0 // dB adjustment
entity.components.set(audioComponent)
// Verify the component is attached at the correct position
let attachedAudio = entity.components[SpatialAudioComponent.self]
XCTAssertNotNil(attachedAudio, "Spatial audio component should be attached")
// Position comes from entity transform — verify entity is where we put it
XCTAssertEqual(entity.position.x, 1.0, accuracy: 0.001)
XCTAssertEqual(entity.position.z, -2.0, accuracy: 0.001)
}Human Testing for Spatial Audio
Automated tests can verify that audio components are configured correctly. Human testing verifies the experience:
Test checklist for spatial audio sessions:
[ ] Sound from left virtual source perceived as left — not front or ambient
[ ] Sound from above perceived as above
[ ] Sound volume attenuates naturally with virtual distance
[ ] Moving virtual source produces continuous position tracking (no jumps)
[ ] Audio occlusion: sound from behind virtual wall is attenuated
[ ] Spatial audio disables correctly in accessibility mode (mono audio setting)
[ ] Real-world sounds (system alerts) still audible at appropriate volume during immersionConduct spatial audio testing sessions with testers wearing Vision Pro in the target room type. Open spaces and highly reflective rooms (tile, glass) behave differently.
Simulator vs. Device Testing
The visionOS simulator runs on Mac and handles most functional testing. Know what it cannot test:
| Feature | Simulator | Device |
|---|---|---|
| SwiftUI layout | Full | Full |
| RealityKit entities | Partial (no GPU performance data) | Full |
| Eye tracking input | No (uses mouse) | Full |
| Hand tracking | Partial (keyboard shortcuts) | Full |
| Spatial audio | Stereo only | Full spatial |
| Passthrough | No | Full |
| LiDAR/depth | No | Full |
| Thermal behavior | No | Full |
| Display calibration | No | Full |
Simulator testing strategy: run all unit tests and most integration tests in simulator for speed. Run a targeted device test pass for every release candidate covering eye tracking, spatial audio, comfort, and performance.
Simulator Limitations to Work Around
For eye tracking tests in simulator, use accessibility actions:
// Works in simulator and device
element.tap() // Uses accessibility path, equivalent to look-and-pinch
// Device only — direct gaze simulation not available in simulator
// XCUIElement.gaze(at:) does not existFor spatial audio in simulator, test configuration and fallback:
func testSpatialAudioFallsBackInSimulator() {
// In simulator, spatial audio renders as stereo
// Test that your app handles this gracefully
#if targetEnvironment(simulator)
XCTAssertTrue(AudioEngine.shared.isInStereoFallbackMode,
"Should be in stereo fallback mode in simulator")
#else
XCTAssertFalse(AudioEngine.shared.isInStereoFallbackMode,
"Should use full spatial audio on device")
#endif
}Accessibility Testing in XR
visionOS enforces accessibility more strictly than previous Apple platforms. Vision Pro users include people with motor impairments (pointer control, dwell selection), cognitive differences, and visual differences.
Accessibility Requirements for XR
Dynamic Type: All text in windowed apps must respond to Dynamic Type. Test at all size categories:
func testTextScalesWithDynamicType() {
let sizes: [UIContentSizeCategory] = [
.extraSmall, .medium, .large, .extraExtraExtraLarge, .accessibilityExtraExtraExtraLarge
]
for size in sizes {
let view = ContentView()
.environment(\.sizeCategory, ContentSizeCategory(size))
let controller = UIHostingController(rootView: view)
controller.view.layoutIfNeeded()
// No text should be clipped
XCTAssertFalse(isTextClipped(in: controller.view),
"Text clipped at size category: \(size.rawValue)")
}
}Reduce Motion: visionOS users can enable Reduce Motion. Test that your application respects it:
func testReduceMotionDisablesAnimations() {
UIAccessibility.isReduceMotionEnabled = true
let view = OnboardingFlow()
// Verify no particle effects, no parallax, no fly-in animations
XCTAssertFalse(view.hasParticleEffects,
"Particle effects should be disabled with Reduce Motion")
UIAccessibility.isReduceMotionEnabled = false
}Voice Control: Test that all actions are accessible via Voice Control commands ("tap Confirm", "scroll down"):
func testVoiceControlLabelsAreUnique() {
let app = XCUIApplication()
app.launch()
let buttons = app.buttons.allElementsBoundByIndex
let labels = buttons.map { $0.label }
let uniqueLabels = Set(labels)
XCTAssertEqual(labels.count, uniqueLabels.count,
"Duplicate button labels prevent Voice Control disambiguation")
}Immersive Space Accessibility
Immersive spaces create additional accessibility considerations:
- Photo Sensitivity: No flashing content above 3Hz. Test all particle systems and animation loops.
- Vestibular: Reduce Motion should disable camera movement and parallax in immersive spaces.
- Audio Balance: Support mono audio and balanced stereo for users with single-ear hearing.
// Test that immersive space respects photo sensitivity limits
func testNoFlashingContentAbove3Hz() {
let flashingElements = ImmersiveSpaceAudit.findFlashingContent(
in: MainImmersiveSpace(),
durationSeconds: 30
)
let violations = flashingElements.filter { $0.frequencyHz > 3.0 }
XCTAssertTrue(violations.isEmpty,
"Found flashing content above 3Hz: \(violations.map { $0.description })")
}Performance Testing
Vision Pro has thermal constraints. The device runs warm under sustained 3D rendering, and the OS will reduce performance before triggering warnings. Test under sustained load:
func testImmersiveSpaceFrameRateUnder5Minutes() {
let metrics = [
XCTCPUMetric(),
XCTMemoryMetric(),
XCTStorageMetric(),
XCTClockMetric()
]
measure(metrics: metrics) {
// Launch immersive space and simulate 5 minutes of active use
let app = XCUIApplication()
app.launch()
app.buttons["Enter Immersive Space"].tap()
// Simulate user interactions over 5 minutes
simulateActiveUse(duration: 300)
}
// XCTest will fail if metrics exceed defined baseline
}Monitor these during device test sessions:
- Frame rate: must stay at 90 FPS (90/96 Hz display)
- Thermal state: should stay in nominal range for typical session lengths (30-60 minutes)
- Memory: headset has 16GB unified memory; watch for growth over time
Summary
Spatial computing QA requires testing three distinct input modalities (eyes, hands, voice), three application modes (windowed, volumetric, immersive), and a new set of spatial properties (audio positioning, window placement, 3D object interaction) that have no precedent in mobile or desktop testing. Run unit and integration tests in the simulator for speed and CI integration. Reserve device time for eye tracking accuracy, spatial audio presence, thermal behavior, and comfort validation. Treat accessibility as a first-class requirement — visionOS enforces it, and the user population expects it.