Spatial Computing QA: Testing for Apple Vision Pro and Mixed Reality

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 immersion

Conduct 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 exist

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

Read more

Start now free