When an iOS project contains thousands of unit and UI tests, running xcodebuild test directly often combines compilation, simulator startup, app installation, and the entire test suite in a single job. A failure may occur in the final few minutes, yet a rerun starts over from compilation. On a cloud Mac, it is more efficient to split this workflow into two stages: create a testable build once, then reuse it across multiple well-defined test shards.
Separate the Build and Test Phases
Xcode’s build-for-testing action builds the app, test host, and test bundles, then generates an .xctestrun file describing the execution environment. Subsequent runs can use test-without-building to avoid resolving dependencies and recompiling source code for every shard.
First, fix the workspace, Scheme, destination device, and DerivedData path:
set -euo pipefail
DERIVED_DATA="$PWD/.ci/DerivedData"
RESULTS="$PWD/.ci/Results"
DESTINATION="platform=iOS Simulator,name=iPhone 16,OS=latest"
rm -rf "$DERIVED_DATA" "$RESULTS"
mkdir -p "$RESULTS"
xcodebuild build-for-testing \
-workspace App.xcworkspace \
-scheme App \
-configuration Debug \
-destination "$DESTINATION" \
-derivedDataPath "$DERIVED_DATA" \
CODE_SIGNING_ALLOWED=NO
find "$DERIVED_DATA/Build/Products" -name "*.xctestrun" -print
CODE_SIGNING_ALLOWED=NO is suitable only for simulator tests that do not depend on device signing. If a test target includes components that must be signed, remove this argument and let the project’s own signing configuration take effect. Do not move DerivedData after a successful build; the .xctestrun file may reference absolute paths within it.
Sharding solves a test scheduling problem; it does not fix shared state between tests. If one test depends on another running first, it may appear stable during a serial run on one machine, but sharding will expose the underlying defect.
Define Stable Shards with Test Manifests
Do not manually split tests into groups of “roughly one hundred methods.” Method names change frequently, making that approach expensive to maintain. A more reliable strategy is to divide tests by test Target, test class, or business domain and commit each manifest to the repository.
| Shard | Recommended scope | Suitable tests |
|---|---|---|
| unit-core | Pure logic layer | Data transformation, validation, state machines |
| unit-storage | Persistence layer | Databases, caches, migrations |
| ui-account | Account flows | Login, settings, permission screens |
| ui-checkout | Transaction flows | Product selection, confirmation, and error paths |
A manifest can remain a plain text file:
AppTests/ParserTests
AppTests/SessionReducerTests
AppTests/ValidationTests
At runtime, convert each line into an -only-testing: argument. Test identifiers usually take the form Target/Class or Target/Class/testMethod. Run xcodebuild -list first to verify the Scheme, then validate each identifier with a small test command so that a typo does not cause a shard to execute zero tests.
Run a Single Shard
XCTESTRUN="$(find "$DERIVED_DATA/Build/Products" -name '*.xctestrun' -print -quit)"
xcodebuild test-without-building \
-xctestrun "$XCTESTRUN" \
-destination "$DESTINATION" \
-parallel-testing-enabled NO \
-only-testing:AppTests/ParserTests \
-only-testing:AppTests/SessionReducerTests \
-resultBundlePath "$RESULTS/unit-core.xcresult"
Every shard must use a different resultBundlePath. Reusing the same path overwrites evidence and may cause concurrent processes to contend for the same directory.
Assign an Isolated Simulator to Every Concurrent Job
If two xcodebuild processes target the same simulator at the same time, app installation, startup, permission state, and clipboard contents can interfere with one another. Prepare a dedicated device for each concurrent slot and select it by UDID.
First, inspect the available runtimes:
xcrun simctl list runtimes
xcrun simctl list devicetypes
After obtaining the runtime identifier for the current environment, create a dedicated device:
xcrun simctl create \
ci-shard-1 \
com.apple.CoreSimulator.SimDeviceType.iPhone-16 \
"$RUNTIME_ID"
Save the returned UDID in a controlled runtime configuration. Before each run, shut down the device and erase its state:
xcrun simctl shutdown "$SIMULATOR_UDID" 2>/dev/null || true
xcrun simctl erase "$SIMULATOR_UDID"
xcrun simctl boot "$SIMULATOR_UDID"
xcrun simctl bootstatus "$SIMULATOR_UDID" -b
Then change the destination to platform=iOS Simulator,id=$SIMULATOR_UDID. For jobs that require a predefined language, permissions, or test data, inject them consistently after erase instead of depending on state left behind by the previous run.
Let Resource Pressure Determine Concurrency
If you explicitly start two shards and also enable Xcode’s built-in parallel testing within each shard, you create nested concurrency. The number of simulators, test processes, and compilation helper processes all increases, which can ultimately make the pipeline slower than a serial run. Start with -parallel-testing-enabled NO and add only one shard slot at a time.
Track at least three categories of signals:
- Whether
memory_pressureremains in a high-pressure state; - Whether compression and paging reported by
vm_statincrease rapidly; - Whether each shard’s test count, execution time, and failure location remain stable.
Shards should not be balanced solely by test count. A UI test class that restarts the app repeatedly may take longer than hundreds of pure-function tests. You can adjust manifests using durations from several recent pipeline runs, but do not assign tests randomly at runtime; doing so makes failures harder to reproduce and trends harder to compare.
Make Failures Independently Rerunnable and Diagnosable
Each shard should preserve its exit code, console log, and a separate .xcresult. The pipeline’s aggregation stage may fail, but a failure in the first shard should not delete evidence from the other shards. When rerunning a failed group, continue using the original .xctestrun while writing to a new result path:
xcodebuild test-without-building \
-xctestrun "$XCTESTRUN" \
-destination "platform=iOS Simulator,id=$SIMULATOR_UDID" \
-parallel-testing-enabled NO \
-only-testing:AppUITests/CheckoutTests \
-resultBundlePath "$RESULTS/ui-checkout-retry-1.xcresult"
If the source code, compiler arguments, Xcode version, or simulator runtime changes, run build-for-testing again. Old build products cannot serve as evidence for a new commit. Retain the test manifests, build logs, shard results, and environment versions so you can determine whether a failure originated in the code, test state, or execution environment.
The key to this design is not duplicating the test command, but defining three ownership boundaries: build products are generated only once, each simulator belongs exclusively to one shard, and result bundles never overwrite one another. Once these boundaries are in place, you can scale the shard count and perform targeted reruns without sacrificing reproducibility.
Frequently asked questions
Why not rely only on Xcode parallel testing?
Built-in parallel testing is convenient inside one invocation, while explicit shards provide stable ownership, separate result bundles, and targeted reruns. Avoid stacking both without a resource limit.
Can test-without-building artifacts be copied to another Mac?
Not safely by default. The xctestrun file and products can contain environment-dependent paths, so the Xcode version, simulator runtime, architecture, and directory layout must be validated first.
Need a physical Mac mini dedicated to a single order?
Compare the M4 and M4 Pro configurations, four rental periods, and five available regions, then choose the device that fits your workflow.