Figment.so
Mac hostingWebsite hostingGuidesHow to usePricing

Parallelize XCTests Across Multiple Mac Nodes

Shard your XCTest suite by test-class identifier, run each shard on its own Mac node with xcodebuild ... -only-testing, then merge the per-shard xcresult bundles. The ready-made fit for most teams is Figment's Managed Mac Team plan at $999/month, which includes two M-series Macs, two CI runners, team setup, backups, monitoring and one hour of monthly support. If you prefer to assemble the fleet yourself, request two M-series Macs from $95/month each — physical plans are request-based, and the exact chip, RAM and disk are confirmed in the configuration quote rather than on the pricing card.

A 40-minute XCTest suite is a 40-minute block on every pull request. The common first move, -parallel-testing-enabled YES, fans tests across simulator clones on a single Mac but caps at that host's CPU and RAM. To cut wall-clock time further you need separate Mac nodes, which xcodebuild cannot coordinate on its own. This guide shows a correct sharding path, a CI matrix that distributes shards one per Mac, a merge step for the results, and a worked cost-vs-time worksheet tied to Figment's published plans.

Why single-node parallel testing runs out

xcodebuild -parallel-testing-enabled YES starts multiple simulator instances and divides tests across them; -parallel-testing-worker-count N caps the simulator count. Both flags act on one host. Apple's Running tests and interpreting results and the xcodebuild technote TN2339 document the command-line surface, including -only-testing, -skip-testing, destinations and schemes. Modern Xcode also schedules independent targets within one scheme in parallel, so a strict sum-of-target-times floor rarely matches your actual wall-clock. Measure before assuming.

Two practical limits on one Mac:

  1. Core and memory ceiling. All simulator clones share one host's CPU and RAM. Past roughly four to six workers the returns taper off and flake rate tends to rise.
  2. No cross-machine distribution. xcodebuild does not coordinate a single test run across separate hosts. Each node has to be handed its own identifier list.

The theoretical floor for a sharded suite is the time of your longest single test class, because no shard can finish before its slowest member. Measure that longest class on your own code before deciding how many nodes to pay for.

Shard by test identifier, not by generated test plans

A common mistake is to try to generate per-shard .xctestplan files at CI time and pass them with xcodebuild -testPlan shard-0.xctestplan. That flag selects a test plan that is already attached to the scheme by plan name — it is not an arbitrary file-path argument. A plan file generated outside the scheme will not be picked up.

The documented path that actually shards cleanly across nodes is -only-testing. The flag accepts an identifier of the form TestTarget/TestClass or TestTarget/TestClass/testMethod (see TN2339 linked above) and can be passed multiple times on one invocation. Hand each node a different set of identifiers and you have a cross-machine shard.

Two sharding strategies are common: by class count (simple, deterministic, works on the first run) and by estimated duration (needs historical timings per class). This guide uses class count; once you have a stable timings file you can swap in duration-balanced bins without changing the matrix shape.

A small shard-splitter and a manual acceptance worksheet

The script below reads one identifier per line (the list you maintain for your project, or one you derive from your test-navigator export), splits them round-robin into N buckets, and writes one bucket per shard file. It makes no network calls, writes only inside --out-dir, and is safe to re-run. The example is unexecuted here — run it against a copy of your list and inspect the shard files by hand before wiring anything into CI.

#!/usr/bin/env python3
"""shard_tests.py -- split a test-identifier list into N shard files.

Input:  a text file with one identifier per line, e.g. MyAppTests/LoginTests
Output: shard-0.txt ... shard-(N-1).txt inside --out-dir
Each line is suitable to pass as `xcodebuild -only-testing <identifier>`.
"""
import argparse
import pathlib
import sys


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("ids_file")
    ap.add_argument("n", type=int)
    ap.add_argument("--out-dir", required=True)
    args = ap.parse_args()
    if args.n < 1:
        sys.exit("N must be >= 1")

    raw = pathlib.Path(args.ids_file).read_text().splitlines()
    ids = [line.strip() for line in raw
           if line.strip() and not line.strip().startswith("#")]
    if not ids:
        sys.exit("No identifiers found. Populate the input list first.")

    out = pathlib.Path(args.out_dir)
    out.mkdir(parents=True, exist_ok=True)

    buckets: list[list[str]] = [[] for _ in range(args.n)]
    for i, ident in enumerate(ids):
        buckets[i % args.n].append(ident)

    for idx, bucket in enumerate(buckets):
        (out / f"shard-{idx}.txt").write_text("\n".join(bucket) + "\n")
        print(f"shard-{idx}: {len(bucket)} id(s)")


if __name__ == "__main__":
    main()

Manual acceptance worksheet — walk the splitter through these checks before trusting its output in CI:

StepWhat to check
1wc -l tests/ids.txt equals the sum of line counts across all shard-*.txt files.
2Every identifier appears in exactly one shard file (sort shards/shard-*.txt | uniq -d prints nothing).
3Shard sizes differ by at most one line (the round-robin property).
4Copy the CI step below for shard_index: 0, run it on one Figment Mac, and confirm it executes only the classes listed in shards/shard-0.txt.
5Mark one known test to fail inside shards/shard-0.txt; confirm only shard 0 fails and the other shards keep running (fail-fast: false).

Step 4 only works if $APP_SCHEME points at a scheme that actually exists in your workspace. Do not hard-code a scheme name you have not verified in the project.

Distribute shards across Figment Macs and merge the results

Generate the shard files once in a preparatory job, then fan them out across a matrix where each job runs on its own Figment Mac runner. Each shard runs sequentially inside its job so simulator-level parallelism does not stack on top of node-level sharding and oversubscribe the host:

# GitHub Actions example. Adjust runs-on labels for your self-hosted pool.
jobs:
  generate-shards:
    runs-on: [self-hosted, macOS, arm64]
    steps:
      - uses: actions/checkout@v4
      - name: Generate shard identifier lists
        run: python3 scripts/shard_tests.py tests/ids.txt 4 --out-dir shards/
      - uses: actions/upload-artifact@v4
        with: { name: shards, path: shards/ }

  test-shard:
    needs: generate-shards
    runs-on: [self-hosted, macOS, arm64]   # one Figment Mac per matrix job
    strategy:
      fail-fast: false
      matrix:
        shard_index: [0, 1, 2, 3]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/download-artifact@v4
        with: { name: shards, path: shards/ }
      - name: Run shard ${{ matrix.shard_index }}
        shell: bash
        env:
          APP_SCHEME: ${{ vars.APP_SCHEME }}
          SHARD: ${{ matrix.shard_index }}
        run: |
          set -euo pipefail
          mkdir -p results
          ONLY_FLAGS=()
          while IFS= read -r id; do
            [ -z "$id" ] && continue
            ONLY_FLAGS+=( -only-testing "$id" )
          done < "shards/shard-${SHARD}.txt"
          xcodebuild test \
            -scheme "$APP_SCHEME" \
            -destination 'platform=iOS Simulator,name=iPhone 15' \
            -parallel-testing-enabled NO \
            -resultBundlePath "results/shard-${SHARD}.xcresult" \
            "${ONLY_FLAGS[@]}"
      - if: always()
        uses: actions/upload-artifact@v4
        with:
          name: xcresult-${{ matrix.shard_index }}
          path: results/shard-${{ matrix.shard_index }}.xcresult

Notes on the matrix:

  • set -euo pipefail makes the step fail on a non-zero xcodebuild exit; a failed build is never counted as a fast run.
  • APP_SCHEME is injected from a CI variable, so no scheme name is hard-coded. It is not a secret channel — keep signing secrets in your CI secret store, not in vars.
  • -parallel-testing-enabled NO stops simulator-level parallelism from stacking on top of node-level sharding.
  • -destination is only an example; adapt it to a simulator or physical device your scheme actually supports.
  • if: always() uploads each shard's .xcresult even when tests fail, so the merge job can still inspect broken shards.

After all shard jobs finish, a follow-up job downloads the .xcresult bundles and merges them. The xcresulttool surface changed in Xcode 16: several introspection subcommands such as get object now require a --legacy flag, and tooling that parsed the legacy JSON has had to update. merge is a separate subcommand, but verify it against your installed Xcode before relying on it:

set -euo pipefail
xcrun --find xcresulttool     # confirm the tool is on PATH
xcrun xcresulttool version    # record the version in the job log
xcrun xcresulttool merge \
  results/shard-0.xcresult \
  results/shard-1.xcresult \
  results/shard-2.xcresult \
  results/shard-3.xcresult \
  --output-path results/merged.xcresult

If merge is unavailable or changes shape on your toolchain, a version-tolerant fallback is to convert each shard's .xcresult to JUnit XML with a third-party parser such as xcresultparser, then concatenate the <testsuite> elements under a single <testsuites> root. That path skips merge entirely and tends to feed CI dashboards more cleanly.

Sizing the Figment Mac fleet

Figment's M-series Mac plans start at $95/month per node; the specific chip, RAM and disk are confirmed in the configuration quote, not on the pricing card. Two à la carte M-series nodes therefore start at $190/month ($95 × 2 = $190). The Managed Mac Team plan at $999/month rolls two M-series Macs, two CI runners, team setup, backups, monitoring and one hour of monthly support into one line. Figment VM access is through the browser console; inbound SSH to the $29 Intel VM is not implemented, so a self-hosted CI agent on a Mac dials out to your CI service rather than accepting inbound shell connections.

A worksheet, not a benchmark — fill in your own first two rows before trusting the rest:

InputValue
Unsharded wall-clock time per PR40 min
Wall-clock time with 2 shards (illustrative — measure yours)22 min
Minutes saved per PR18 min
Blocked PRs per working day20
Minutes recovered per day18 × 20 = 360 min = 6.0 h
Loaded engineering rate$150/h
Recovered engineering value per day6.0 × 150 = $900
Incremental hosting cost of the second M-series nodefrom $95/month

If your longest single test class is already close to your target wall-clock, more shards will not help — refactor that class instead. Start with two nodes and add a third only when the merged time still dominates merge-queue waits. If you prefer not to run the CI setup, backups and monitoring in-house, the Managed Mac Team plan folds all of that into one monthly line.


Ready to shard your XCTest suite across Figment Mac nodes? Get started.

Related guides:


Subscribe for Figma design and website publishing guides.


Figment.so

Contact

Twitter

Website pricing

Compute index

About

Privacy

Terms