Engineering practice

Stabilize XCUITest on a Cloud Mac with Deterministic Waits

Stabilize XCUITest on a Cloud Mac with Deterministic Waits

The same XCUITest suite may pass repeatedly on a local machine yet intermittently fail on a cloud Mac because a button cannot be found, a wait times out, or the app stalls on its launch screen. This is rarely as simple as the machine being slow. More common causes include a drifting test destination, state left behind by a previous test, fixed-duration waits, and insufficient evidence captured after a failure. The right order of operations is to pin the inputs first, remove implicit dependencies next, and only then consider retries.

Pin the Inputs for Every Run

Specify Xcode, the test plan, simulator destination, and build directory in the command instead of relying on selections left in the graphical interface. A cloud Mac may run workloads over long periods, and different pipelines may reuse the same working directory. Every run should therefore have its own DerivedData directory and result bundle.

RUN_ID="${CI_RUN_ID:-local-$(date +%s)}"
ARTIFACTS="$PWD/artifacts/$RUN_ID"
DERIVED="$PWD/.derived/$RUN_ID"

mkdir -p "$ARTIFACTS" "$DERIVED"

xcodebuild test \
  -workspace Sample.xcworkspace \
  -scheme SampleUITests \
  -testPlan Smoke \
  -destination 'platform=iOS Simulator,name=iPhone 16,OS=latest' \
  -derivedDataPath "$DERIVED" \
  -resultBundlePath "$ARTIFACTS/Smoke.xcresult" \
  -parallel-testing-enabled NO

When first addressing flaky tests, disable parallel testing to establish a serial baseline. Once that baseline is stable, re-enable parallel execution one group at a time. Otherwise, simultaneous changes across simulators, test data, and background tasks make it difficult to determine whether a failure comes from the test or the scheduler.

Run a Minimal Preflight Check

Before execution, record xcodebuild -version and use xcrun simctl list devices available to confirm that the destination actually exists. Do not specify only the device name while omitting the OS requirement, and do not let different runners share the same DerivedData directory.

Replace Fixed Sleeps with Observable State

Thread.sleep looks straightforward, but it effectively rewrites “the screen is ready” as “a few seconds have passed.” It wastes time when the network responds quickly and finishes too early when an animation or disk operation takes slightly longer.

let app = XCUIApplication()
app.launchArguments += ["--ui-testing", "--reset-state"]
app.launch()

let continueButton = app.buttons["onboarding.continue"]
let ready = continueButton.waitForExistence(timeout: 12)

XCTAssertTrue(ready, "Continue button did not appear")
XCTAssertTrue(continueButton.isHittable)
continueButton.tap()

Elements should use stable accessibility identifiers rather than localized labels or view hierarchy paths. If a button exists but is not hittable, inspect overlays, animations, and scroll position instead of simply increasing the timeout.

A timeout is a failure boundary, not a performance guarantee. It should accommodate normal variation while still failing quickly and preserving evidence when the app is genuinely stuck.

Start Every Test from a Known State

Intermittent failures often originate in the preceding test: onboarding has already been dismissed, test data remains in place, a dialog appears only once, or a background process never exited. The most reliable approach is to have the app perform dedicated reset logic when launched with test arguments and to generate unique data for every test identity.

Handle each state boundary as follows:

State type Recommended approach Discouraged approach
App preferences Trigger a test-only reset with launch arguments Assume the state will always be empty after installation
Local database Import fixed fixtures or rebuild the test database Depend on data written by the previous test
Server-side data Use a unique namespace for the current run Share fixed records across multiple jobs
System permissions Prepare and verify them consistently before the suite Handle them ad hoc in arbitrary tests
App process Explicitly terminate it after the test Assume it will recover automatically after a crash

Suite-level cleanup can terminate the app. Uninstall it only when the first-install flow genuinely needs to be tested. Frequently erasing the entire simulator increases runtime and can also disguise an environment problem as a test that “passes after cleanup.”

xcrun simctl terminate booted "$APP_BUNDLE_ID" 2>/dev/null || true

Use Retries as a Classification Tool

Automatic retries must not replace the criteria for a passing test. A reasonable policy is to retry only once after the initial failure and preserve attempt-1.xcresult and attempt-2.xcresult separately. If both attempts fail, prioritize the issue as a consistent defect. If the first attempt fails and the second passes, classify the test as flaky and continue investigating synchronization conditions, shared state, and resource contention.

Do Not Preserve Only the Final Exit Code

For every failure, collect at least the following:

  1. A separate result bundle and test logs;
  2. A screenshot and UI hierarchy from the failed step;
  3. The Xcode version, target simulator, and launch arguments;
  4. The run identifier, test name, and start and end times;
  5. The app’s exit status and relevant system log excerpts.

The result bundle path must include the run identifier to prevent concurrent jobs from overwriting it. Tokens, credentials, and business data in logs should be redacted before upload.

Move from Single-Test Reproduction to Stable Acceptance

Start by running the target test repeatedly with -only-testing to reduce unrelated variables. Then run its test class, and finally return to the full suite. A failure that occurs only in the full suite usually indicates order dependence, shared data, or resource contention.

xcodebuild test \
  -workspace Sample.xcworkspace \
  -scheme SampleUITests \
  -destination 'platform=iOS Simulator,name=iPhone 16,OS=latest' \
  -only-testing:SampleUITests/CheckoutTests/testSubmit \
  -parallel-testing-enabled NO

For acceptance, do not rely on a single successful run. Execute several consecutive rounds, confirm that no fixed-order dependency remains, and check whether the total runtime has increased noticeably because timeout limits are too generous. After restoring parallel testing, verify that different shards do not modify the same test record or write to the same output directory.

The goal is not to hide the failure rate behind retries. It is to give every run deterministic inputs, explicit waits, isolated state, and complete evidence. With those four properties in place, XCUITest on a cloud Mac can progress from “occasionally passes” to a reliable engineering signal for merge and release decisions.

Frequently asked questions

Should a failed XCUITest run be retried automatically?

One retry is useful for classification, but it must not erase the original failure. Preserve both result bundles and track tests that pass only on retry as flaky work requiring repair.

Why should UI tests avoid fixed sleep calls?

A fixed delay neither proves that the interface is ready nor adapts when it finishes early. Wait for an observable condition such as existence, hittability, or expected text with a defined timeout.

Dedicated physical Mac mini

Choose a cloud Mac for your next development pipeline

Choose from three fixed configurations and four Asian nodes, then select a rental period based on your actual workload. Each rental includes one dedicated physical machine, not a virtual machine.

Choose a configuration and rent