CI Integration¶
This page covers running AutoMobile XCTestRunner tests in GitHub Actions using a macOS runner with
a built-in iOS Simulator. The automobile-tests job shown here is the reference workflow used in
the ios-build repository.
Overview¶
AutoMobile tests need:
- A pre-built
.xctestruncontaining theYourAppAutoMobileTestsbundle - A booted iOS Simulator on the runner
- A running AutoMobile daemon reachable over the Unix socket
- The CtrlProxy iOS app installed in the simulator
The sections below walk through each step.
No cloud device service required
Unlike Android — which uses emulator.wtf to provision managed cloud emulators — iOS AutoMobile tests run on the macOS runner’s built-in iOS Simulator. No external device service or additional secrets are needed.
Job structure¶
The reference workflow splits testing into two independent jobs that share the same build artifact:
build-for-testing ──┬──► simulator-tests (unit tests, skips AutoMobile bundle)
└──► automobile-tests (AutoMobile tests only)
Both jobs download the same .xctestrun artifact from build-for-testing and run
xcodebuild test-without-building with different filtering flags, so there is no duplicate
compilation.
Full workflow job¶
automobile-tests:
name: "AutoMobile XCTestRunner Tests"
runs-on: macos-26
needs: [build-for-testing]
timeout-minutes: 30
steps:
- uses: actions/checkout@v6
- name: Select Xcode 26.2
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: "26.2"
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Install auto-mobile CLI
run: bun install -g @kaeawc/auto-mobile
- name: Ensure iOS Simulator runtime
uses: ./.github/actions/ensure-ios-simulator-runtime
- name: Download build products
uses: actions/download-artifact@v7
with:
name: build-for-testing-macos-26
path: build/DerivedData/Build/Products/
- name: Boot iOS Simulator
run: bash scripts/ios/boot-simulator.sh
- name: Start simulator log stream
run: |
mkdir -p build
xcrun simctl spawn booted log stream \
--level debug --style compact --color none \
--predicate 'subsystem BEGINSWITH "com.example.ios"' \
> build/automobile-simulator.log 2>&1 &
echo "LOG_STREAM_PID=$!" >> "$GITHUB_ENV"
- name: Start AutoMobile daemon
run: |
auto-mobile --daemon start &
echo "AUTOMOBILE_DAEMON_PID=$!" >> "$GITHUB_ENV"
- name: Wait for daemon and register simulator
run: auto-mobile --cli observe --platform ios
- name: Run AutoMobile tests
run: bash scripts/ios/xcode-automobile-tests.sh
- name: Stop AutoMobile daemon
if: always()
run: kill "$AUTOMOBILE_DAEMON_PID" 2>/dev/null || true
- name: Stop simulator log stream
if: always()
run: kill "$LOG_STREAM_PID" 2>/dev/null || true
- name: Upload test results
uses: actions/upload-artifact@v6
if: always()
with:
name: automobile-test-results
path: build/automobile-tests.xcresult
retention-days: 7
- name: Upload simulator logs on failure
uses: actions/upload-artifact@v6
if: failure()
with:
name: automobile-simulator-logs
path: build/automobile-simulator.log
retention-days: 7
Step-by-step breakdown¶
1. Bun and auto-mobile¶
The daemon is a Node/Bun application. setup-bun ensures the Bun runtime is available on the
runner so auto-mobile --daemon start works correctly.
- uses: oven-sh/setup-bun@v2
- run: bun install -g @kaeawc/auto-mobile
2. Build products artifact¶
AutoMobile tests run test-without-building against the .xctestrun produced by the
build-for-testing job. Downloading the artifact avoids recompiling on the test runner:
- uses: actions/download-artifact@v7
with:
name: build-for-testing-macos-26
path: build/DerivedData/Build/Products/
The artifact contains both test bundles (YourAppTests.xctest and
YourAppAutoMobileTests.xctest) and a .xctestrun file that references them.
3. Boot the simulator¶
bash scripts/ios/boot-simulator.sh
The helper script finds the simulator that matches the current Xcode SDK, creates it if missing, and boots it. Alternatively:
xcrun simctl boot "iPhone 16"
open -a Simulator
xcrun simctl list devices booted # confirm it booted
4. Daemon startup¶
Start the daemon in the background and save its PID for clean shutdown:
auto-mobile --daemon start &
echo "AUTOMOBILE_DAEMON_PID=$!" >> "$GITHUB_ENV"
The --cli observe step below implicitly waits for the daemon to be ready, so no explicit socket
poll is needed here.
5. CtrlProxy pre-installation¶
auto-mobile --cli observe --platform ios
This step does two things:
- Waits for the daemon socket to become available (blocks until the daemon is up).
- Installs the CtrlProxy iOS app into the booted simulator if it is not already present, so
the first
observestep in a test plan does not pay the installation cost.
Running this once before the test task ensures CtrlProxy is ready before observe steps in test
plans execute.
6. Run AutoMobile tests¶
bash scripts/ios/xcode-automobile-tests.sh
The script finds the .xctestrun, identifies the booted simulator, and runs:
xcodebuild test-without-building \
-xctestrun "$xctestrun_file" \
-destination "platform=iOS Simulator,id=$booted_udid" \
-only-testing:YourAppAutoMobileTests \
-enableCodeCoverage NO \
-skipMacroValidation
-only-testing:YourAppAutoMobileTests limits execution to the AutoMobile bundle. Unit tests in
YourAppTests are handled by the separate simulator-tests job.
7. Cleanup¶
The if: always() guards ensure cleanup runs even when tests fail:
kill "$AUTOMOBILE_DAEMON_PID" 2>/dev/null || true
kill "$LOG_STREAM_PID" 2>/dev/null || true
The daemon process exits cleanly; the simulator remains booted for the remainder of the runner’s lifetime (macOS runners are ephemeral so no explicit teardown is needed).
8. Artifacts¶
| Artifact | When uploaded | Contents |
|---|---|---|
automobile-test-results |
Always | build/automobile-tests.xcresult — pass/fail, timing, screenshots |
automobile-simulator-logs |
On failure | build/automobile-simulator.log — simulator log stream filtered to your app’s subsystem |
Keeping unit tests and AutoMobile tests separate¶
The simulator-tests job runs unit tests using -skip-testing:YourAppAutoMobileTests:
# scripts/ios/xcode-test-without-building.sh
# -skip-testing:YourAppAutoMobileTests excludes the AutoMobile bundle
xcodebuild test-without-building \
-xctestrun "$xctestrun_file" \
-destination "platform=iOS Simulator,id=$booted_udid" \
-skip-testing:YourAppAutoMobileTests \
-enableCodeCoverage NO \
-parallel-testing-enabled YES \
-skipMacroValidation
The automobile-tests job runs the complement using -only-testing:YourAppAutoMobileTests.
This mirrors the Android pattern where testDebugUnitTest -PexcludeAutoMobileTests runs unit tests
and testDebugUnitTest --tests '*.automobiletest.*' runs the AutoMobile tests.
Caching strategy¶
The build-for-testing job uses two cache layers that the automobile-tests job benefits from
indirectly through the artifact:
| Cache | Key | What it stores |
|---|---|---|
| SPM packages | runner-xcode<ver>-spm-<hash(project.yml)> |
Alamofire, XCTestRunner, etc. — avoids re-cloning |
| DerivedData intermediates | runner-xcode<ver>-intermediates-<hash(sources+configs)> |
Compiled .o and .swiftmodule files — makes incremental builds fast |
Both caches are keyed so a clean rebuild only triggers when sources actually change.
Required secrets¶
No additional secrets are required for a basic AutoMobile test run. The macOS runner has Xcode and iOS Simulator built in, and the daemon communicates entirely over a local Unix socket.
If you add features that require external services (e.g., a staging backend with an API key), pass them as environment variables through the test scheme:
- name: Run AutoMobile tests
env:
AUTOMOBILE_TEST_API_KEY: ${{ secrets.TEST_API_KEY }}
run: bash scripts/ios/xcode-automobile-tests.sh
Resolve them in the plan via parameter substitution (see Writing Tests → Plan parameters).
Conditional execution¶
To skip the job on draft pull requests or when a specific label is absent:
automobile-tests:
if: github.event.pull_request.draft != true
Reading results from CI¶
Download the automobile-test-results artifact and inspect with xcresulttool:
xcrun xcresulttool get test-results summary \
--path automobile-tests.xcresult
xcrun xcresulttool get test-results tests \
--path automobile-tests.xcresult --format json \
| jq '[.. | objects | select(.testStatus? == "Failure") | .nodeIdentifier]'
See also¶
- Project Setup — Dependency, XcodeGen config, running locally
- Writing Tests —
AutoMobileTestCaseproperties, YAML plan reference - CtrlProxy iOS — iOS automation server setup