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 \
allThe 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 documentedAutomate 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:
- Check for extension availability before use
- Fail gracefully when an extension is unavailable
- 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.xmlUsing 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.