iOS simulator tests on GitHub Actions
Simulator tests are the part of macOS CI that behaves differently on a fresh VM than on a laptop. Three things explain most of the flakiness.
First boot is the slow part
A simulator that has never been booted on this VM takes tens of seconds to come up, and xcodebuild test will happily time out waiting for it. Manzanita's Standard image installs the iOS 26.5 runtime, boots an iPhone 17 Pro once during image build, and shuts it down cleanly, so that device is warm on every job. Other devices exposed by the installed runtime can be created, but they pay the cold-boot cost.
If you need a different device on any runner, boot it explicitly before testing so the wait is visible and bounded:
- run: |
UDID=$(xcrun simctl list devices available -j | jq -r '.devices | to_entries[] | .value[] | select(.name=="iPhone 17 Pro") | .udid' | head -1)
xcrun simctl boot "$UDID" || true
xcrun simctl bootstatus "$UDID" -b
Name destinations so they survive updates
Hard-coding OS=26.5 breaks the day the runtime moves. Use OS=latest and the device name; pin the OS only in a dedicated compatibility lane:
xcodebuild test -scheme App \
-destination 'platform=iOS Simulator,OS=latest,name=iPhone 17 Pro' \
-parallel-testing-enabled NO
Parallel testing spawns clones of the simulator; on a 6-vCPU profile it is usually slower than serial, and it multiplies cold boots. Measure before enabling it.
Older iOS versions
The Manzanita Standard image promises one runtime, iOS 26.5, and does not currently ship visionOS, watchOS or tvOS runtimes. If you need behavioural coverage on iOS 17 or 18, keep those matrix lanes on a runner that has the runtime installed, or download it in the job with xcodebuild -downloadPlatform iOS -buildVersion ... and accept the download time. The list of promised runtimes lives in the docs and changes only through the changelog.
GUI session and accessibility
XCUITest and Accessibility-based tests need a logged-in Aqua session with Accessibility enabled, persistent scrollbars and animations off. The Manzanita image sets all of these at build time, which is the difference between a UI test that passes locally and one that hangs on waitForExistence in CI.
runs-on to manzanita-standard and keep everything else. Start free · Read the docs