OpenXR Testing: Cross-Platform XR Application Validation

OpenXR Testing: Cross-Platform XR Application Validation

OpenXR standardizes XR APIs across runtimes, but runtimes still differ in behavior, extension support, and performance characteristics. Test against each runtime you ship on. Use the Khronos OpenXR Conformance Test Suite for spec compliance, test input paths explicitly against each controller profile, and run your CI against the OpenXR loader with a mock runtime before testing on real hardware.

OpenXR was designed to solve the platform fragmentation problem in XR — one API, every headset. In practice, it shifts the problem rather than eliminating it. The spec is implemented by runtimes (SteamVR, Meta's runtime, Windows Mixed Reality, Monado), and runtimes have different extension support, different input path mappings, and different performance profiles. Testing an OpenXR application means validating against the spec and against each runtime you intend to ship on.

OpenXR Architecture and What It Means for Testing

The OpenXR stack has three layers relevant to testing:

Your Application
       ↓
OpenXR API (calls like xrCreateSession, xrPollEvent, xrLocateViews)
       ↓
OpenXR Loader (dispatches to the active runtime)
       ↓
Runtime (SteamVR / Meta / WMR / Monado / etc.)
       ↓
Hardware (headset, controllers, tracking system)

Bugs can live at any layer:

  • Your code: Calling the API incorrectly, wrong extension usage, missed error codes
  • Loader: Rare, but loader versions matter — test with the loader version you bundle
  • Runtime: Extension not implemented, different timing behavior, input remapping
  • Hardware: Controller firmware, tracking calibration

Your testing strategy needs to cover all four layers separately.

Spec Compliance: The Conformance Test Suite

The Khronos Group maintains the OpenXR Conformance Test Suite (CTS). This is your first line of defense — it validates that you're using the API correctly and that the runtime responds as specified.

Running the CTS

# Clone the CTS
git clone https://github.com/KhronosGroup/OpenXR-CTS.git
cd OpenXR-CTS

# Build (requires CMake, a C++17 compiler, and Vulkan SDK)
cmake -B build -DBUILD_CONFORMANCE_TESTS=ON
cmake --build build --config Release

# Run against the active system runtime
./build/src/conformance/conformance_cli/conformance_cli \
    --reporter console \
    --graphics Vulkan2 \
    --formfactor Hmd \
    all

The all argument runs all test cases. For CI integration, filter to the conformance sections relevant to your feature set:

# Test only session management and event handling
./conformance_cli --reporter xml --output-file results.xml \
    "[session][events]"

# Test input binding
./conformance_cli "[input]"

# Test view enumeration and projection
./conformance_cli "[views][projection]"

CTS output in XML format integrates directly with JUnit-compatible CI reporters.

What the CTS Does Not Cover

The CTS validates spec compliance, not application behavior or runtime-specific quality. After CTS passes, you still need:

  • Runtime-specific behavioral testing
  • Input path coverage for each controller profile
  • Performance testing on target hardware
  • Extension compatibility testing

Runtime Switching and Compatibility

Target at minimum the runtimes your users will actually use. As of 2026:

Runtime Platform Market share (PCVR)
SteamVR PC (all headsets) ~65%
Meta OpenXR Runtime Quest (Link/Air Link) + Rift ~25%
Windows Mixed Reality WMR headsets declining
Monado Linux, development use small

Runtime-Specific Test Matrix

For each runtime, test:

[ ] Session lifecycle: create, begin, end, destroy
[ ] Event polling: XR_TYPE_EVENT_DATA_SESSION_STATE_CHANGED covers all states
[ ] View enumeration: correct view count and recommended resolution
[ ] Reference spaces: LOCAL, STAGE, VIEW — all create successfully
[ ] Swapchain creation: with your required format and usage flags
[ ] Frame loop: xrWaitFrame / xrBeginFrame / xrEndFrame cycle stable at target rate
[ ] Action binding: input action suggested bindings resolve correctly
[ ] Extension features you depend on work as documented

Automate this matrix against each runtime using a test harness that initializes OpenXR, runs assertions, then tears down cleanly:

// Minimal OpenXR test harness
class OpenXRTestFixture {
public:
    XrInstance instance = XR_NULL_HANDLE;
    XrSystemId systemId = 0;
    XrSession session = XR_NULL_HANDLE;
    
    void SetUp() {
        XrInstanceCreateInfo createInfo{XR_TYPE_INSTANCE_CREATE_INFO};
        createInfo.applicationInfo.apiVersion = XR_CURRENT_API_VERSION;
        strcpy(createInfo.applicationInfo.applicationName, "ConformanceTest");
        
        XrResult result = xrCreateInstance(&createInfo, &instance);
        ASSERT_EQ(result, XR_SUCCESS) << "xrCreateInstance failed: " << result;
        
        XrSystemGetInfo systemInfo{XR_TYPE_SYSTEM_GET_INFO};
        systemInfo.formFactor = XR_FORM_FACTOR_HEAD_MOUNTED_DISPLAY;
        result = xrGetSystem(instance, &systemInfo, &systemId);
        ASSERT_EQ(result, XR_SUCCESS) << "xrGetSystem failed: " << result;
    }
    
    void TearDown() {
        if (session != XR_NULL_HANDLE) xrDestroySession(session);
        if (instance != XR_NULL_HANDLE) xrDestroyInstance(instance);
    }
};

TEST_F(OpenXRTestFixture, SystemPropertiesReturnValidValues) {
    XrSystemProperties props{XR_TYPE_SYSTEM_PROPERTIES};
    XrResult result = xrGetSystemProperties(instance, systemId, &props);
    
    EXPECT_EQ(result, XR_SUCCESS);
    EXPECT_GT(props.graphicsProperties.maxSwapchainImageWidth, 0u);
    EXPECT_GT(props.graphicsProperties.maxSwapchainImageHeight, 0u);
    EXPECT_GT(props.graphicsProperties.maxLayerCount, 0u);
    EXPECT_FALSE(std::string(props.systemName).empty());
}

Input Path Testing

OpenXR input uses interaction profiles that map physical controller buttons to action paths. Testing input is testing that mapping.

Interaction Profiles to Test

Profile Runtime Controller
/interaction_profiles/khr/simple_controller All Baseline (mandatory)
/interaction_profiles/oculus/touch_controller Meta Quest Touch / Rift
/interaction_profiles/valve/index_controller SteamVR Index Knuckles
/interaction_profiles/microsoft/motion_controller WMR WMR controllers
/interaction_profiles/htc/vive_controller SteamVR Vive Wand
/interaction_profiles/meta/touch_controller_plus Meta Quest 3 Touch Plus

Testing Suggested Bindings

Your application suggests bindings for each profile. Test that each suggestion is valid for its profile:

TEST_F(OpenXRTestFixture, GrabActionBindsOnAllSupportedProfiles) {
    XrActionSetCreateInfo setInfo{XR_TYPE_ACTION_SET_CREATE_INFO};
    strcpy(setInfo.actionSetName, "gameplay");
    XrActionSet actionSet;
    xrCreateActionSet(instance, &setInfo, &actionSet);
    
    XrActionCreateInfo actionInfo{XR_TYPE_ACTION_CREATE_INFO};
    actionInfo.actionType = XR_ACTION_TYPE_FLOAT_INPUT;
    strcpy(actionInfo.actionName, "grab");
    XrAction grabAction;
    xrCreateAction(actionSet, &actionInfo, &grabAction);
    
    // Test each profile binding
    struct ProfileBinding {
        const char* profile;
        const char* path;
    };
    
    std::vector<ProfileBinding> bindings = {
        {"/interaction_profiles/khr/simple_controller", 
         "/user/hand/right/input/select/click"},
        {"/interaction_profiles/oculus/touch_controller",
         "/user/hand/right/input/squeeze/value"},
        {"/interaction_profiles/valve/index_controller",
         "/user/hand/right/input/squeeze/value"},
    };
    
    for (const auto& binding : bindings) {
        XrPath profilePath;
        xrStringToPath(instance, binding.profile, &profilePath);
        
        XrPath bindingPath;
        xrStringToPath(instance, binding.path, &bindingPath);
        
        XrActionSuggestedBinding actionBinding{grabAction, bindingPath};
        XrInteractionProfileSuggestedBinding suggestion{
            XR_TYPE_INTERACTION_PROFILE_SUGGESTED_BINDING};
        suggestion.interactionProfile = profilePath;
        suggestion.suggestedBindings = &actionBinding;
        suggestion.countSuggestedBindings = 1;
        
        XrResult result = xrSuggestInteractionProfileBindings(instance, &suggestion);
        EXPECT_EQ(result, XR_SUCCESS) 
            << "Binding failed for profile: " << binding.profile;
    }
}

Testing Action State

After binding, test that action state reads correctly:

TEST_F(OpenXRTestFixture, TriggerActionReturnsFloatState) {
    // Setup session and attach action sets...
    
    XrActionStateFloat triggerState{XR_TYPE_ACTION_STATE_FLOAT};
    XrActionStateGetInfo getInfo{XR_TYPE_ACTION_STATE_GET_INFO};
    getInfo.action = triggerAction;
    
    XrResult result = xrGetActionStateFloat(session, &getInfo, &triggerState);
    
    EXPECT_EQ(result, XR_SUCCESS);
    EXPECT_GE(triggerState.currentState, 0.0f);
    EXPECT_LE(triggerState.currentState, 1.0f);
    // isActive=false in test environments is acceptable
    // The key test: result is XR_SUCCESS and values are in valid range
}

Extension Testing

Extensions are optional OpenXR capabilities that runtimes may or may not implement. Your application must:

  1. Check for extension availability before use
  2. Fail gracefully when an extension is unavailable
  3. Behave correctly when an extension is available

Extension Availability Testing

TEST(ExtensionTests, HandTrackingExtensionHandledCorrectly) {
    // Enumerate available extensions
    uint32_t extensionCount = 0;
    xrEnumerateInstanceExtensionProperties(nullptr, 0, &extensionCount, nullptr);
    
    std::vector<XrExtensionProperties> extensions(extensionCount, 
        {XR_TYPE_EXTENSION_PROPERTIES});
    xrEnumerateInstanceExtensionProperties(nullptr, extensionCount, 
        &extensionCount, extensions.data());
    
    bool handTrackingAvailable = false;
    for (const auto& ext : extensions) {
        if (strcmp(ext.extensionName, XR_EXT_HAND_TRACKING_EXTENSION_NAME) == 0) {
            handTrackingAvailable = true;
            break;
        }
    }
    
    if (handTrackingAvailable) {
        // Test that hand tracking initializes correctly when available
        EXPECT_TRUE(AppHandTracking::Initialize()) 
            << "Hand tracking failed to initialize despite extension availability";
    } else {
        // Test that app falls back correctly when extension unavailable
        EXPECT_FALSE(AppHandTracking::Initialize())
            << "Hand tracking should report unavailable";
        EXPECT_EQ(AppHandTracking::GetFallbackMode(), HandFallbackMode::ControllerOnly)
            << "App should fall back to controller-only mode";
    }
}

Critical Extensions to Test

Extension Purpose Fallback behavior to test
XR_EXT_hand_tracking Hand joint positions Controller-only mode
XR_FB_passthrough Camera passthrough Opaque mode or hide feature
XR_KHR_composition_layer_depth Depth info submission Disable depth submission
XR_EXT_eye_gaze_interaction Eye tracking input No gaze-based features
XR_FB_scene Scene understanding Manual room setup
XR_FB_spatial_entity Persistent anchors Session-only anchors

CI Integration with OpenXR Loaders

Testing OpenXR in CI without headsets requires a null or mock runtime. Monado and the Khronos null driver are the primary options.

Setting Up CI with Monado

# .github/workflows/openxr-tests.yml
name: OpenXR Tests

on: [push, pull_request]

jobs:
  openxr-conformance:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Install Monado runtime
        run: |
          sudo apt-get update
          sudo apt-get install -y monado-service monado-cli
          
      - name: Start Monado service
        run: |
          monado-service &
          sleep 2  # Wait for service to start
          
      - name: Set OpenXR runtime
        run: |
          mkdir -p ~/.config/openxr/1/
          echo '{"file_format_version": "1.0.0", "runtime": {"library_path": "/usr/lib/x86_64-linux-gnu/libopenxr_monado.so"}}' \
            > ~/.config/openxr/1/active_runtime.json
            
      - name: Build tests
        run: cmake -B build && cmake --build build --config Release
        
      - name: Run OpenXR unit tests
        run: ./build/tests/openxr_unit_tests --gtest_output=xml:results.xml
        
      - name: Upload test results
        uses: actions/upload-artifact@v4
        with:
          name: test-results
          path: results.xml
          
      - name: Publish test report
        uses: mikepenz/action-junit-report@v4
        with:
          report_paths: results.xml

Using the Khronos Null Driver

For tests that only need to validate API call sequences without real hardware response:

// Force null driver in test environment
// Set XR_RUNTIME_JSON environment variable before tests
// export XR_RUNTIME_JSON=/path/to/openxr_null_driver_manifest.json

class NullDriverTest : public ::testing::Test {
    void SetUp() override {
        // Null driver accepts any extension request
        // Use this to test your extension-checking code without real hardware
        setenv("XR_RUNTIME_JSON", "/path/to/null_driver.json", 1);
    }
};

Runtime-Specific Behavioral Differences

After spec testing, document and test for known behavioral differences:

SteamVR vs. Meta Runtime

Reference space coordinates: SteamVR STAGE space may have different floor height calibration than Meta's. Test that your floor-relative object placement is correct on both.

Swapchain format support: SteamVR and Meta support different texture formats with priority ordering. Test that your format selection code correctly falls back:

const std::vector<int64_t> preferredFormats = {
    VK_FORMAT_R8G8B8A8_SRGB,      // Preferred
    VK_FORMAT_B8G8R8A8_SRGB,      // Fallback 1
    VK_FORMAT_R8G8B8A8_UNORM,     // Fallback 2
};

// Your swapchain creation should select the first format
// in preferredFormats that appears in runtime's supported formats
int64_t selected = SelectSwapchainFormat(runtimeFormats, preferredFormats);
ASSERT_NE(selected, -1) << "No supported swapchain format found";

Event timing: Meta's runtime delivers XR_SESSION_STATE_READY faster than SteamVR after xrCreateSession. Test your session state machine handles both fast and slow transitions.

Performance Testing Across Runtimes

Different runtimes add different overhead to the API call path. Measure and set budgets:

void BenchmarkFrameLoopOverhead() {
    auto start = std::chrono::high_resolution_clock::now();
    
    XrFrameWaitInfo waitInfo{XR_TYPE_FRAME_WAIT_INFO};
    XrFrameState frameState{XR_TYPE_FRAME_STATE};
    xrWaitFrame(session, &waitInfo, &frameState);
    
    auto waitEnd = std::chrono::high_resolution_clock::now();
    
    XrFrameBeginInfo beginInfo{XR_TYPE_FRAME_BEGIN_INFO};
    xrBeginFrame(session, &beginInfo);
    
    auto beginEnd = std::chrono::high_resolution_clock::now();
    
    // Record overhead
    auto waitOverhead = std::chrono::duration_cast<std::chrono::microseconds>(
        waitEnd - start).count();
    auto beginOverhead = std::chrono::duration_cast<std::chrono::microseconds>(
        beginEnd - waitEnd).count();
        
    printf("xrWaitFrame overhead: %lldus\n", waitOverhead);
    printf("xrBeginFrame overhead: %lldus\n", beginOverhead);
    
    // Assert within budget (runtime overhead should be <1ms)
    ASSERT_LT(waitOverhead, 1000) << "xrWaitFrame overhead too high";
}

Summary

OpenXR testing has two distinct phases: spec correctness (use the Khronos CTS) and runtime compatibility (test against each runtime in your target set). Invest in a CI pipeline that runs against Monado for fast feedback, and validate against real runtimes before shipping. Input path testing is the most common source of cross-platform bugs — test every controller profile you claim to support, not just the one you developed on.

Read more

Start now free