Automating iOS UI tests with XCUITest on a dedicated Mac
To automate XCUITest, run xcodebuild test with a simulator destination and a -resultBundlePath, on a Mac with a logged-in user session. Create a dedicated simulator with xcrun simctl. Add -parallel-testing-enabled YES to split tests across simulator clones, and -retry-tests-on-failure to rerun flaky tests. We ran every command here on a Mac with Xcode 26.6 and the iOS 26.5 simulator.
What you need first
- A Mac with Xcode and at least one iOS simulator runtime.
- A UI test target in your project and a shared scheme that tests it.
- A logged-in desktop session. UI tests drive the Simulator app, so whatever runs them must live in that session. That means a CI agent started as a LaunchAgent, not a LaunchDaemon. Buildkite's plist template says the same: GUI mode allows Xcode UI testing but needs a login.
1. Check Xcode and the runtimes
xcodebuild -version sudo xcodebuild -runFirstLaunch xcrun simctl list runtimes xcrun simctl list devicetypes | grep iPhone
-runFirstLaunch installs packages and accepts the license. Run it after every Xcode install or upgrade.
2. Create a simulator just for CI
A dedicated device keeps CI apart from anything a person opened by hand. bootstatus -b boots it and waits until it is ready.
UDID=$(xcrun simctl create "CI iPhone 17" "iPhone 17" com.apple.CoreSimulator.SimRuntime.iOS-26-5) xcrun simctl bootstatus "$UDID" -b echo "$UDID"
Use the UDID in the destination: -destination "platform=iOS Simulator,id=$UDID". A name works too, like name=iPhone 17,OS=26.5, but two devices can share a name.
3. Run the UI tests with a result bundle
rm -rf build/TestResults.xcresult xcodebuild test \ -project MyApp.xcodeproj \ -scheme MyApp \ -destination "platform=iOS Simulator,id=$UDID" \ -derivedDataPath build/DerivedData \ -resultBundlePath build/TestResults.xcresult
The result bundle holds every test result, log, screenshot and failure. Read a summary on the command line:
xcrun xcresulttool get test-results summary --path build/TestResults.xcresult
It prints JSON with passed, failed and skipped counts per device. Attach the bundle to your CI run as an artifact, zipped with ditto -c -k --keepParent.
4. Build once, test many times
Split the build from the test run. Then you can rerun a subset without compiling again.
xcodebuild build-for-testing -project MyApp.xcodeproj -scheme MyApp \ -destination "platform=iOS Simulator,id=$UDID" -derivedDataPath build/DerivedData xcodebuild test-without-building -project MyApp.xcodeproj -scheme MyApp \ -destination "platform=iOS Simulator,id=$UDID" -derivedDataPath build/DerivedData \ -only-testing:MyAppUITests/LoginTests/testSignIn
-only-testing takes Target, Target/Class, or Target/Class/method. -skip-testing works the same way in reverse.
5. Run tests in parallel
xcodebuild test -project MyApp.xcodeproj -scheme MyApp \ -destination "platform=iOS Simulator,id=$UDID" \ -parallel-testing-enabled YES \ -parallel-testing-worker-count 2 \ -resultBundlePath build/TestResults.xcresult
Xcode clones the simulator and spreads test classes across the clones. Our log showed tests running on Clone 1 of iPhone 17. -parallel-testing-enabled overrides the setting in the scheme. On a 16 GB Mac, start with 2 workers and measure before you raise it. Each clone is a full simulator in memory. To test on several device types at once, list several destinations and set -maximum-concurrent-test-simulator-destinations.
6. Retry flaky tests
xcodebuild test -project MyApp.xcodeproj -scheme MyApp \ -destination "platform=iOS Simulator,id=$UDID" \ -retry-tests-on-failure \ -test-iterations 3 \ -resultBundlePath build/TestResults.xcresult
xcodebuild -help says a failed test reruns up to the iteration count. Without -test-iterations, the maximum is 3. We checked it with a test that fails once and then passes. The run ended in TEST SUCCEEDED. The summary reported 3 test runs for 2 tests.
So a retry turns a flaky test green, and the flake hides. Read the result bundle on every run, and track which tests needed a retry. Add -test-repetition-relaunch-enabled YES to rerun each attempt in a fresh process. To hunt a flake, use -run-tests-until-failure. It cannot be combined with -retry-tests-on-failure.
7. Put limits on stuck tests
-test-timeouts-enabled YES \ -default-test-execution-time-allowance 120 \ -maximum-test-execution-time-allowance 300
Add these flags to the test command. A UI test that waits forever on an element then fails after its allowance, and does not hold the machine until the CI timeout.
8. Keep simulators clean between runs
xcrun simctl shutdown "$UDID" xcrun simctl erase "$UDID" xcrun simctl delete unavailable
Erase resets the simulator's contents and settings. Delete unavailable removes devices the current Xcode no longer supports. Run it after each Xcode upgrade.
How this survives reboots
Simulators you create persist across reboots. The CI agent that runs the tests is what must come back. Run it as a LaunchAgent, turn on automatic login, and boot the CI simulator at the start of each job with bootstatus -b. That command is safe on a device that is already booted.
Errors we hit and how to fix them
Unable to find a device matching the provided destination specifier. The name or OS does not exist. Checkxcrun simctl list devicesand the runtimes list.xcodebuild: error: Existing file at -resultBundlePath. Delete the old bundle before each run.Unable to erase contents and settings in current state: Booted. Shut the simulator down before you erase it.
Why a dedicated Mac helps
UI tests spend a lot of time before the first tap. They wait to build, boot a simulator and install the app. A machine that stays on keeps DerivedData and a booted simulator ready. In our benchmark we built the Wikipedia iOS app with Xcode 26.6. A job after a small change took 27 seconds on a warm M6. It took 269 seconds on a fresh GitHub-hosted macos-26 runner. Our M6 has 12 CPU cores, so two parallel clones still leave room for the build.
When hosted CI is enough
A small suite run on a few pull requests a day fits metered minutes. At GitHub's $0.062 per macOS minute (checked September 2026), a $139 Mac pays off after about 2,242 minutes a month. Below that, stay hosted. If you must test on many physical iPhones, you need a device cloud. A Mac mini runs simulators. Does your parallel suite need more than 16 GB of memory? Our M5 Pro tiers have 48 or 64 GB, but they are a pre-order, so plan for about a week's wait.
The iOS CI/CD pipeline guide shows where UI tests fit in a full pipeline. The calculator runs your own numbers.
Frequently asked questions
Does xcodebuild have a flag to retry failed tests?
+
Yes. -retry-tests-on-failure reruns a failed test, up to -test-iterations times or 3 by default. It cannot be combined with -run-tests-until-failure.
How do I run XCUITest tests in parallel from the command line?
+
Add -parallel-testing-enabled YES, and optionally -parallel-testing-worker-count. Xcode clones the simulator and splits test classes across the clones.
Can XCUITest run on a headless Mac?
+
It needs a logged-in user session, because UI tests drive the Simulator app. Run the CI agent as a LaunchAgent and turn on automatic login.
How do I read xcodebuild test results without opening Xcode?
+
Pass -resultBundlePath, then run xcrun xcresulttool get test-results summary --path on the bundle. It prints the counts as JSON.