Skip to content

UI-Driven Test Infrastructure: Replace RCTTest with XCUITest/UIAutomator#459

Merged
wmathurin merged 98 commits into
forcedotcom:devfrom
wmathurin:rn-ui-driven-test-infrastructure
Jun 5, 2026
Merged

UI-Driven Test Infrastructure: Replace RCTTest with XCUITest/UIAutomator#459
wmathurin merged 98 commits into
forcedotcom:devfrom
wmathurin:rn-ui-driven-test-infrastructure

Conversation

@wmathurin

@wmathurin wmathurin commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR replaces the fragile RCTTest-based test infrastructure with a UI-driven approach using standard native UI testing frameworks (XCUITest on iOS, UIAutomator on Android). Tests are now driven by tapping buttons in a standard React Native app that displays test results inline.

Motivation

The current test infrastructure relies on platform-specific test bridges (RCTTestModule on iOS, SalesforceTestBridge on Android) that hook into React Native internals. React Native 0.84 introduced precompiled binaries which broke our iOS test infrastructure — the podspec_sources method used to extract RCTTest source code from the React Native pod no longer works with precompiled frameworks. This blocks us from upgrading beyond RN 0.83.

Previous RN upgrades also required test infrastructure fixes (e.g., PlatformConstants changes in 0.83), but RN 0.84 represents a fundamental breaking point: we cannot extract test infrastructure source from precompiled binaries.

Solution

iOS Android
Simulator Screenshot - iPhone 17 Pro - 2026-06-03 at 15 12 31 Screenshot_20260603_152838

New Architecture

Native UI Test (XCUITest/UIAutomator)
  ↓ (finds and taps test buttons)
React Native Test App (test/TestApp.js)
  ↓ (runs selected test)
Pure JS Test Runner (test/testRunner.js)
  ↓ (executes test suite)
SDK Bridge → Native SDK
  ↓
Test Results (displayed inline in app UI)

Key Benefits:

  • ✅ No coupling to React Native internal APIs
  • ✅ Works with React Native precompiled binaries (unblocks RN 0.84+ upgrades)
  • ✅ Full error messages visible in app UI
  • ✅ Tests can be run manually (tap buttons in app)
  • ✅ No complex test bridge modules
  • ✅ Survives RN upgrades without infrastructure changes

What Changed

Removed (Old Infrastructure)

  • src/react.force.test.ts - Test bridge module (no longer needed)
  • iosTests/RCTTest/ - Custom RCTTestRunner extraction (incompatible with precompiled binaries)
  • All Android test bridge classes:
    • ReactNativeTestHost.java
    • ReactTestActivity.java
    • SalesforceTestBridge.java
    • TestResult.java

Added (New Infrastructure)

  • test/testRunner.js - Pure JS test runner (no native bridge)
  • test/TestApp.js - Standard RN app with test list UI
  • iosTests/ios/SalesforceReactTests/ - XCUITest suite (5 test classes, 35 tests)
  • androidTests/android/app/src/androidTest/ - UIAutomator suite (5 test classes, 35 tests)
  • react.force.mobilesync.resetSyncManager() - Enables JS-side test cleanup

Test Execution Flow

  1. Native UI test (XCUITest/UIAutomator) launches app with instant login credentials
  2. Finds test button by testID/accessibilityLabel (e.g., run_testGetApiVersion)
  3. Taps button
  4. Waits for result indicator (result_testGetApiVersion_pass or result_testGetApiVersion_fail)
  5. If failed, reads error text (error_testGetApiVersion) and asserts

Test Results

iOS: All 35 tests passing via XCUITest on both iOS 18 and iOS 26

Test Suite 'All tests' passed
Executed 35 tests, with 0 failures in 1304.624 seconds
** TEST SUCCEEDED **

Android: All 35 tests passing via UIAutomator locally. CI integration in progress (Firebase Test Lab).

Both platforms use instant login (credentials via launch arguments) to bypass OAuth UI.

Test Infrastructure Details

iOS:

  • BaseReactNativeTest provides common test infrastructure
  • Subclasses override testTimeoutSeconds for longer tests (Net: 30s, MobileSync: 60s, default: 15s)
  • App launches once per test class via class func setUp()
  • Instant login via launch arguments (-creds <json>)

Android:

  • BaseReactNativeTest uses @Rule with ActivityScenarioRule pattern (follows AuthFlowTest in Android SDK)
  • Instant login via TestAuthenticationActivity with credentials intent extra
  • Subclasses override testTimeoutMs for longer tests (Net: 90_000ms, MobileSync: 180_000ms, default: 60_000ms)
  • Activity relaunches per test (fast with instant login)

Dependencies

This PR depends on instant login support in the native SDKs:

  • iOS SDK PR: #4050 (merged) - Semaphore-based instant login fix
  • Android SDK PR: Already merged - TestAuthenticationActivity

Files Changed

Core Changes (~80 commits):

  • JavaScript test infrastructure (test runner, test app UI)
  • iOS test suite (XCUITest-based)
  • Android test suite (UIAutomator-based)
  • Documentation updates (architecture, test docs)
  • Git URL dependencies for test apps (avoid yarn cache conflicts)

See commit history for detailed breakdown.

Testing

  • ✅ All 35 iOS tests pass via XCUITest (iOS 18 and iOS 26)
  • ✅ All 35 Android tests pass via UIAutomator locally
  • ✅ Templates build successfully with this branch (verified via test_template.sh)
  • ✅ CI: iOS ^18 and iOS ^26 both green on GitHub Actions

Before Merging

  • iosTests/package.json: Updated to git+https://github.com/forcedotcom/SalesforceMobileSDK-ReactNative.git#dev
  • androidTests/package.json: Updated to git+https://github.com/forcedotcom/SalesforceMobileSDK-ReactNative.git#dev

Impact

This PR unblocks React Native 0.84+ upgrades by eliminating our dependency on RCTTest source extraction. Future RN upgrades will only require version bumps in package.json, not test infrastructure rewrites.

Related Work

wmathurin added 30 commits June 2, 2026 09:33
- Add test/testRunner.js: registerSuite, registerTest, testDone, runTest
- Add test/TestApp.js: renders test list with run buttons and inline results
- Update all test files to use testRunner imports
- Update index.js entry points to render TestApp
- Switch from file:.. to link:.. to avoid recursive yarn copy
- Add watchFolders to metro config so Metro resolves symlinked test/
- Remove test/ from .npmignore (needed by Metro)
The app target needs a proper RCTAppDelegate that loads the React bundle.
The old bare UIResponder AppDelegate showed nothing because it never
created a React surface.
Uses SalesforceReactSDKManager, AuthHelper.loginIfRequired, and
RCTReactNativeFactory — matching the ReactNativeTemplate.

NOTE: Xcode project needs manual update to add AppDelegate.swift
and remove AppDelegate.h, AppDelegate.m, main.m references.
iOS:
- Remove RCTTest/ directory (custom test runner pod)
- Remove React-RCTTest from Podfile
- Remove old XCTest files (ReactTestCase, React*Tests.m)
- Podfile: all pods in app target, test target inherits

Android:
- Remove SalesforceTestBridge, ReactNativeTestHost, ReactTestActivity,
  ReactActivityTestDelegate, TestResult, SalesforceReactTestPackage,
  DebugSalesforceReactPackage
- Remove old instrumentation test classes
- Rewrite SalesforceReactTestApp.kt to match template pattern
- Add MainActivity.kt (standard SalesforceReactActivity)
- Update AndroidManifest for new MainActivity

JS:
- Remove src/react.force.test.tsx (replaced by test/testRunner.js)
- Remove forceTest export from src/index.ts
Copied the template's iOS project structure (AppDelegate.swift,
Podfile, xcodeproj, bootconfig, Info.plist) and renamed to
SalesforceReactTestApp. Uses use_frameworks! :static for Swift
module imports.
…nfig/Info.plist

- bundleURL() always loads index.ios.bundle from app bundle
- bootconfig.plist and Info.plist checked in with placeholders
- Fill in values per docs/TESTING_CREDENTIALS.md for local testing
…cursion)

- Switch back from link:.. to file:.. (link caused dep resolution failures)
- .npmignore now excludes node_modules/ which prevents recursive copy
- Simplified metro configs (no watchFolders needed — test/ is in the copy)
Having react and react-native in both dependencies and peerDependencies
caused yarn to install a nested copy under node_modules/react-native-force/
node_modules/react, breaking React hooks (useState returns null).
- New resetSyncManager in react.force.mobilesync.ts
- Added to TurboModule spec (NativeSFMobileSyncReactBridge.ts)
- iOS bridge: calls [SFMobileSyncSyncManager removeSharedInstances]
- Android bridge: calls SyncManager.reset()
- Removed test/alltests.js (no longer used, TestApp imports directly)
iOS:
- BaseReactNativeTest.swift: launches app, waits for testList, runTest()
- ReactHarnessTests, ReactOAuthTests, ReactNetTests,
  ReactSmartStoreTests, ReactMobileSyncTests

Android:
- BaseReactNativeTest.kt: launches activity, waits for testList, runTest()
- ReactHarnessTest, ReactOAuthTest, ReactNetTest,
  ReactSmartStoreTest, ReactMobileSyncTest
…testID

TouchableOpacity with testID doesn't map to XCUIElement Button type.
Use generic element query with identifier matching instead.
iOS:
- BaseReactNativeTest reads test_credentials.json from test bundle
- Passes as -creds launch argument (SDK instant login in DEBUG builds)

Android:
- BaseReactNativeTest launches TestAuthenticationActivity with creds
- Reads test_credentials.json from assets
- TestAuthenticationActivity registered in AndroidManifest

Both platforms authenticate without login screen before running tests.
- prepareandroid.js/prepareios.js: clean sibling node_modules before
  install, remove nested react-native-force/node_modules after install
  (prevents yarn v1 file: protocol from creating duplicate React)
- iOS: BaseReactNativeTest reads test_credentials.json from bundle,
  passes as -creds launch argument
- Android: BaseReactNativeTest launches TestAuthenticationActivity with
  credentials from assets, waits for test list
- Removed TestAuthenticationActivity from manifest (already in SDK)
prepareios.js now copies to both ios/ (app) and
ios/SalesforceReactTests/ (UI test bundle resources).
Both locations gitignored.
wmathurin added 11 commits June 3, 2026 15:42
Moves permissionRule and activityRule from @rule to @ClassRule in the
companion object. This ensures the activity launches only once for the
entire test class, not once per test method.

Since we're doing batch execution with cached results, we don't need
the activity to restart for each individual test method.
Tests now default to individual execution (ideal for Android Studio/Xcode),
with opt-in batch execution for CI via system property/environment variable.

Individual mode (default):
- Single test runs in isolation
- Fast iteration during development
- No activity flashing between tests (Android @ClassRule)

Batch mode (opt-in via useBatchExecution=true):
- Entire suite runs via "Run All" button
- Results cached and reused
- Much faster for CI

Android: -DuseBatchExecution=true
iOS: useBatchExecution=true environment variable

Added README.md files documenting both modes.
Reverted @ClassRule back to @rule - activity launches per test.
Removed batch execution logic - all tests run individually now.
Android tests are fast enough with ActivityScenarioRule that batch
execution optimization isn't needed.

Simplified BaseReactNativeTest:
- Single testTimeoutMs property (no suite vs individual distinction)
- runTest() directly executes the test (no caching)
- Removed suiteName and testNames requirements from subclasses

iOS still supports both batch and individual modes via useBatchExecution flag.
Removed batch execution logic - all tests run individually now.
Simplified BaseReactNativeTest:
- Single testTimeoutSeconds property
- runTest() directly executes the test (no caching)
- Removed suiteName and testNames requirements from subclasses

Both iOS and Android now use straightforward individual test execution.
- Check if button exists before attempting scroll
- Set maxSearchSwipes to 20 to allow scrolling further
- Log warnings when scroll fails
- Better error message when button not found

This should fix testGetSyncStatusDeleteSync not being reachable.
Added contentContainerStyle with paddingBottom: 100 to ensure
tests at the bottom of the list (like testGetSyncStatusDeleteSync)
are reachable by scrolling on Android.
The sed command in b96b69c deleted test methods along with suiteName
and testNames properties. Restored all test methods from dc4519c and
removed only the batch execution properties.
- iOS: select correct Xcode before pod install to avoid SwiftBridging redefinition error when two Xcode versions are present on the runner
- Android: double test timeouts (base: 15s→30s, MobileSync: 60s→120s) for Firebase Test Lab ARM devices which are slower than local emulators
iOS: remove testLogout and testAuthenticate which don't exist in oauth.test.js
Android: add testGetAuthCredentials which was missing from ReactOAuthTest
Android (reusable-android-workflow.yaml):
- Add --use-orchestrator + clearPackageData=true to prevent cascade failures
  when one test crashes the instrumentation process
- Add --num-flaky-test-attempts=1 for one automatic retry on transient failures
- Increase --timeout from 10m to 20m (MobileSync needs ~10m alone)
- Add explicit --project mobile-apps-firebase-test
- Route github context vars through env: per security best practice
- Fix results copy to use test_result_1.xml (not wildcard)

iOS (reusable-workflow.yaml):
- Switch from mxcl/xcodebuild test run to action:none + direct xcodebuild test
  enabling -retry-tests-on-failure (mxcl does not support it)
- Add explicit simulator resolver via xcrun simctl list with diagnostic output
- Add -retry-tests-on-failure for one automatic retry on flaky tests
- Add xcresult bundle verify step for clear failure when no tests ran
- Set job_summary:true unconditionally (was failure-only, suppressed pass summaries)

Validated: YAML parse OK, actionlint clean, zizmor 0 findings
@wmathurin

Copy link
Copy Markdown
Contributor Author

Locally all the tests are passing.
Fixing CI here: wmathurin#1

</activity>
</application>

<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎉

@brandonpage brandonpage left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Incredible work. This is awesome.

@wmathurin wmathurin merged commit 071a4ff into forcedotcom:dev Jun 5, 2026
4 of 7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants