
Answer first. Run xcodebuild test once per Simulator destination as a background shell job, give every job its own -derivedDataPath and -resultBundlePath, wait on the recorded PIDs, and merge the resulting .xcresult bundles with xcrun xcresulttool merge. Host the matrix on a Mac with enough memory for the Simulators you boot together. Figment's M-series Mac tier starts at $95/month, but $95 does not pin a specific chip, RAM or storage — confirm those in the quote before you size your matrix. The $29 virtual Intel macOS instance runs on Linux/KVM without GPU acceleration, so it is not the right host for iOS Simulator work. Pick M-series for this job, or the Managed Build Mac at $249/month if you want the CI pipeline wired up and maintained for you. Request a configuration at /mac-hosting/start.
Running Simulator targets back-to-back on an M-series machine usually leaves cores and memory headroom on the table, but how much depends on your tests. Rather than quoting a per-Simulator core or RAM number, this guide gives you a short measurement worksheet and a wrapper that does not step on itself across jobs or reruns. Apple's running your app in Simulator or on a device covers how xcodebuild resolves a destination, and WWDC19 session 413, "Testing in Xcode" is the Apple reference for xcresulttool and the result-bundle format you will merge.
Match destinations by UDID so matrix rows cannot collide with a duplicate name (an "iPhone 16 Pro" on two runtimes, for example):
xcrun simctl list devices available -j \
| python3 -c '
import json, sys
data = json.load(sys.stdin)
for runtime, devices in data["devices"].items():
for d in devices:
if d.get("isAvailable"):
print(d["udid"], runtime, d["name"])
' | grep -i "iOS"
Copy the UDIDs you want into your job. If a runtime is missing, install it from Xcode → Settings → Platforms or run xcodebuild -downloadPlatform iOS. For xcresulttool and xcodebuild option details, check the installed help on your own machine — the surface changes between releases:
xcrun xcresulttool help
xcrun xcresulttool help merge
xcrun xcodebuild -help | less
The subcommand that used to be written xcresulttool get --format json --path … has been reshaped in recent Xcode versions; prefer what xcresulttool help prints on the box you are actually using (for example xcresulttool get test-results summary --path … on current Xcode). Do not copy old flags blindly.
The wrapper below targets explicit UDIDs, gives every background job its own unique -derivedDataPath and -resultBundlePath, waits only on the PIDs it started, and cleans up only its own children on interrupt — it never runs pkill xcodebuild or wipes shared DerivedData. Adapt the scheme, project/workspace and UDID list to your project; this example is unexecuted and must be verified on your installed Xcode.
#!/usr/bin/env bash
# parallel-destinations.sh — fan xcodebuild test out across iOS Simulator UDIDs.
set -euo pipefail
SCHEME="${SCHEME:?Set SCHEME to your Xcode test scheme}"
: "${UDIDS:?Set UDIDS to a bash array of Simulator UDIDs}"
if [[ -n "${WORKSPACE:-}" ]]; then
BUILD_FLAGS=(-workspace "$WORKSPACE")
elif [[ -n "${PROJECT:-}" ]]; then
BUILD_FLAGS=(-project "$PROJECT")
else
echo "Set PROJECT=... or WORKSPACE=..." >&2; exit 2
fi
# Truly unique per invocation; safe across matrix cells and reruns.
RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)-$$-$RANDOM"
ROOT="$(pwd)/.parallel-sim/$RUN_ID"
mkdir -p "$ROOT"
PIDS=(); RESULTS=(); LABELS=()
cleanup() {
# Only signal children we started; never a global xcodebuild sweep.
for pid in "${PIDS[@]:-}"; do
kill "$pid" 2>/dev/null || true
done
}
trap cleanup INT TERM
for UDID in "${UDIDS[@]}"; do
JOB_DIR="$ROOT/$UDID"
mkdir -p "$JOB_DIR"
RESULT="$JOB_DIR/result.xcresult"
LOG="$JOB_DIR/xcodebuild.log"
xcodebuild test \
"${BUILD_FLAGS[@]}" \
-scheme "$SCHEME" \
-destination "platform=iOS Simulator,id=$UDID" \
-parallel-testing-enabled NO \
-derivedDataPath "$JOB_DIR/DerivedData" \
-resultBundlePath "$RESULT" \
> "$LOG" 2>&1 &
PIDS+=("$!"); RESULTS+=("$RESULT"); LABELS+=("$UDID")
echo "started $UDID pid=$!"
done
FAILED=0
for i in "${!PIDS[@]}"; do
if wait "${PIDS[$i]}"; then
echo "pass ${LABELS[$i]}"
else
echo "fail ${LABELS[$i]} (see $ROOT/${LABELS[$i]}/xcodebuild.log)" >&2
FAILED=1
fi
done
# Only merge bundles the build actually produced; do not count failures as passes.
MERGE_INPUTS=()
for r in "${RESULTS[@]}"; do [[ -d "$r" ]] && MERGE_INPUTS+=("$r"); done
if (( ${#MERGE_INPUTS[@]} > 0 )); then
xcrun xcresulttool merge "${MERGE_INPUTS[@]}" --output-path "$ROOT/merged.xcresult"
echo "merged bundle: $ROOT/merged.xcresult"
fi
exit "$FAILED"
Every destination uses id=UDID, so no two rows can be ambiguous. Each job writes into ./.parallel-sim/<run-id>/<udid>/, which collides neither across matrix cells nor across reruns. Failed builds exit non-zero and are excluded from the merge; the overall exit status reflects reality instead of being masked by the merge succeeding on the passes.
Invoke it with an explicit UDID list rather than hard-coding device names in the script:
UDIDS=(ABCD1234-... EFGH5678-... IJKL9012-...) \
SCHEME=MyAppTests PROJECT=MyApp.xcodeproj \
./parallel-destinations.sh
Instead of guessing cores and gigabytes per Simulator, record real numbers once on your actual host. /usr/bin/time -l reports wall time and peak resident set size on macOS; vm_stat 2 samples memory pressure during the run.
| Run | How to execute | Record |
|---|---|---|
| A — one device, baseline | UDIDS=(UDID1) /usr/bin/time -l ./parallel-destinations.sh | wall seconds, peak RSS, idle headroom from vm_stat |
| B — full matrix sequential | Loop the wrapper once per UDID (no backgrounding) under /usr/bin/time -l | wall seconds, peak RSS, swap activity |
| C — full matrix concurrent | UDIDS=(UDID1 UDID2 UDID3) /usr/bin/time -l ./parallel-destinations.sh | wall seconds, peak RSS, any swap activity |
| D — acceptance | C's wall time ≤ B's wall time and no swap in vm_stat during C | pass / change plan |
If C triggers swap or runs slower than B, drop a Simulator from the matrix or move to a Mac with more RAM. Common rules of thumb such as "two to four GB per Simulator" come from old anecdotes; your tests, asset catalogs and UI automation move those numbers, so measure before you commit to the sizing.
After the wrapper exits, inspect the merged bundle and emit per-device artifacts for your CI:
# Use the get form your installed Xcode prints in `xcrun xcresulttool help`.
xcrun xcresulttool get test-results summary --path "$ROOT/merged.xcresult" | less
# Extract attachments (screenshots, logs) from a bundle:
brew install chargepoint/xcparse/xcparse
xcparse screenshots "$ROOT/merged.xcresult" "$ROOT/attachments"
xcparse exports attachments, coverage and screenshots from a bundle; it is not an HTML reporter. If you want a per-test HTML view, point XCTestHTMLReport at the merged bundle. Both read the same .xcresult format XCTest emits, and both run on the same hosted Mac that ran the tests.
A Figment M-series Mac from $95/month is request-based, and the exact chip, RAM and storage are agreed in the quote — size them against the worksheet above before you order. Attach the machine as a self-hosted GitHub Actions or Buildkite runner and the wrapper runs against your own repository with no extra plumbing. If you would rather not maintain the matrix yourself, the Managed Build Mac at $249/month includes one M-series Mac, CI setup, backups, monitoring and 30 minutes of monthly support. The Managed Mac Team at $999/month covers two M-series Macs, two CI runners, team setup, backups, monitoring and one hour of monthly support, and larger fleets go through an Enterprise quote.
Entry prices from directly comparable hosts: CloudMac publishes a dedicated Mac mini M4 (16 GB / 256 GB) at $99.99/month, with its 512 GB and 1 TB tiers marked "Fully reserved" at the time of writing; its homepage also advertises a $999/year option, which works out to roughly $83.25/month and about $141/year less than 12 × $95 = $1,140 if your workload fits the published M4 configuration and the 256 GB tier is available. MacStadium lists the M4.S (M4, 16 GB / 256 GB) at $149/month with included support and a 99.99% uptime claim. Figment's $95 M-series entry is a lower starting price for an unspecified M-series configuration; the tangible Figment difference for this test-matrix job is the managed CI setup and monitoring in the $249 Managed Build tier, not a promise of equal hardware specs.
Ready to size and order? Request a quote at /mac-hosting/start, or review the plans at /mac-hosting.