# Xcode test sharding {#xcode-test-sharding} > [!WARNING] > **Requirements** > > - A Tuist account and project > - Test Insights configured (for optimal shard balancing) Test sharding for Xcode projects uses `tuist xcodebuild build-for-testing` to create a shard plan and `tuist xcodebuild test` to execute each shard. ## How it works {#how-it-works} Test sharding follows a two-phase workflow: 1. **Build phase:** Tuist enumerates your tests and creates a **shard plan** on the server. The server uses historical test timing data from the last 30 days to distribute tests across shards so each shard takes roughly the same amount of time. The build phase outputs a **shard matrix** that your CI system uses to spawn parallel runners. 2. **Test phase:** Each CI runner receives a **shard index** and executes only the tests assigned to that shard. ## Build phase {#build-phase} Build your tests and create a shard plan: ```sh tuist xcodebuild build-for-testing \ -scheme MyScheme \ -destination 'platform=iOS Simulator,name=iPhone 16' \ --shard-total 5 ``` This command: 1. Builds your tests with `xcodebuild build-for-testing` 2. Creates a shard plan on the Tuist server using historical timing data 3. Uploads the `.xctestproducts` bundle for use by shard runners 4. Outputs a shard matrix for your CI system ### Build options {#build-options} | Flag | Environment variable | Description | |------|---------------------|-------------| | `--shard-max ` | `TUIST_TEST_SHARD_MAX` | Maximum number of shards. Used with `--shard-max-duration` to cap the shard count | | `--shard-min ` | `TUIST_TEST_SHARD_MIN` | Minimum number of shards | | `--shard-total ` | `TUIST_TEST_SHARD_TOTAL` | Exact number of shards (mutually exclusive with `--shard-min`/`--shard-max`) | | `--shard-max-duration ` | `TUIST_TEST_SHARD_MAX_DURATION` | Target maximum duration per shard in milliseconds | | `--shard-granularity ` | `TUIST_TEST_SHARD_GRANULARITY` | `module` (default) distributes entire test modules across shards; `suite` distributes individual test classes for finer-grained balancing | | `--shard-reference ` | `TUIST_SHARD_REFERENCE` | Unique identifier for the shard plan (auto-derived on supported CI providers) | ## Test phase {#test-phase} Each shard runner executes its assigned tests: ```sh tuist xcodebuild test \ -scheme MyScheme \ -destination 'platform=iOS Simulator,name=iPhone 16' ``` ### Test options {#test-options} | Flag | Environment variable | Description | |------|---------------------|-------------| | `--shard-index ` | `TUIST_SHARD_INDEX` | Zero-based index of the shard to execute | | `--shard-reference ` | `TUIST_SHARD_REFERENCE` | Unique identifier for the shard plan (auto-derived on supported CI providers) | Tuist downloads the `.xctestproducts` bundle and filters it to include only the tests assigned to that shard. ## Continuous integration {#continuous-integration} Tuist automatically detects the following CI providers: - [GitHub Actions](#github-actions) - [GitLab CI](#gitlab-ci) - [CircleCI](#circleci) - [Buildkite](#buildkite) - [Codemagic](#codemagic) - [Bitrise](#bitrise) For other providers, refer to the `.tuist-shard-matrix.json` file to set up parallel jobs. ### GitHub Actions {#github-actions} Use a matrix strategy to run shards in parallel: ```yaml name: Tests on: [pull_request] jobs: build: name: Build test shards runs-on: macos-latest outputs: matrix: ${{ steps.build.outputs.matrix }} steps: - uses: actions/checkout@v4 - uses: jdx/mise-action@v2 - run: tuist auth login - id: build run: | tuist xcodebuild build-for-testing \ -scheme MyScheme \ -destination 'platform=iOS Simulator,name=iPhone 16' \ --shard-total 5 test: name: "Shard #${{ matrix.shard }}" needs: build runs-on: macos-latest strategy: fail-fast: false matrix: shard: ${{ fromJson(needs.build.outputs.matrix).shard }} env: TUIST_SHARD_INDEX: ${{ matrix.shard }} steps: - uses: actions/checkout@v4 - uses: jdx/mise-action@v2 - run: tuist auth login - run: | tuist xcodebuild test \ -scheme MyScheme \ -destination 'platform=iOS Simulator,name=iPhone 16' ``` ### GitLab CI {#gitlab-ci} Tuist generates a `.tuist-shard-child-pipeline.yml` that you trigger as a [child pipeline](https://docs.gitlab.com/ee/ci/pipelines/downstream_pipelines.html#parent-child-pipelines). Define a `.tuist-shard` template job that the generated shard jobs extend: ```yaml # .gitlab-ci.yml stages: - build - test build-shards: stage: build tags: [macos] script: - tuist auth login - | tuist xcodebuild build-for-testing \ -scheme MyScheme \ -destination 'platform=iOS Simulator,name=iPhone 16' \ --shard-total 5 artifacts: paths: - .tuist-shard-child-pipeline.yml test-shards: stage: test needs: [build-shards] trigger: include: - artifact: .tuist-shard-child-pipeline.yml job: build-shards strategy: depend ``` ```yaml # .gitlab/shard-template.yml .tuist-shard: tags: [macos] script: - tuist auth login - | tuist xcodebuild test \ -scheme MyScheme \ -destination 'platform=iOS Simulator,name=iPhone 16' ``` ### CircleCI {#circleci} Tuist generates a `.tuist-shard-continuation.json` with parameters for the [continuation orb](https://circleci.com/developer/orbs/orb/circleci/continuation): ```yaml # .circleci/config.yml version: 2.1 setup: true orbs: continuation: circleci/continuation@1 jobs: build-shards: macos: xcode: "16.0" steps: - checkout - run: name: Build and plan shards command: | tuist auth login tuist xcodebuild build-for-testing \ -scheme MyScheme \ -destination 'platform=iOS Simulator,name=iPhone 16' \ --shard-total 5 - continuation/continue: configuration_path: .circleci/continue-config.yml parameters: .tuist-shard-continuation.json workflows: setup: jobs: - build-shards ``` ```yaml # .circleci/continue-config.yml version: 2.1 parameters: shard-indices: type: string default: "" shard-count: type: integer default: 0 jobs: test-shard: macos: xcode: "16.0" parameters: shard-index: type: integer steps: - checkout - run: name: Run shard command: | export TUIST_SHARD_INDEX=<< parameters.shard-index >> tuist auth login tuist xcodebuild test \ -scheme MyScheme \ -destination 'platform=iOS Simulator,name=iPhone 16' workflows: test: jobs: - test-shard: matrix: parameters: shard-index: [<< pipeline.parameters.shard-indices >>] ``` ### Buildkite {#buildkite} Tuist generates a `.tuist-shard-pipeline.yml` with one step per shard. Upload it with `buildkite-agent pipeline upload`: ```yaml # pipeline.yml steps: - label: "Build test shards" command: | tuist auth login tuist xcodebuild build-for-testing \ -scheme MyScheme \ -destination 'platform=iOS Simulator,name=iPhone 16' \ --shard-total 5 buildkite-agent pipeline upload .tuist-shard-pipeline.yml agents: queue: macos ``` Each generated step has `TUIST_SHARD_INDEX` set in its environment. Add the test command to each shard step using a shared script: ```bash # .buildkite/shard-step.sh #!/bin/bash tuist auth login tuist xcodebuild test \ -scheme MyScheme \ -destination 'platform=iOS Simulator,name=iPhone 16' ``` ### Codemagic {#codemagic} Codemagic does not support dynamic matrix jobs, so define a separate workflow per shard. Tuist writes `TUIST_SHARD_MATRIX` and `TUIST_SHARD_COUNT` to the `CM_ENV` file for use within each workflow: ```yaml # codemagic.yaml workflows: build-shards: name: Build test shards instance_type: mac_mini_m2 environment: xcode: latest scripts: - name: Build and plan shards script: | tuist auth login tuist xcodebuild build-for-testing \ -scheme MyScheme \ -destination 'platform=iOS Simulator,name=iPhone 16' \ --shard-total 5 test-shard-0: &shard-workflow name: "Shard #0" instance_type: mac_mini_m2 environment: xcode: latest vars: TUIST_SHARD_INDEX: 0 scripts: - name: Run shard script: | tuist auth login tuist xcodebuild test \ -scheme MyScheme \ -destination 'platform=iOS Simulator,name=iPhone 16' test-shard-1: <<: *shard-workflow name: "Shard #1" environment: xcode: latest vars: TUIST_SHARD_INDEX: 1 test-shard-2: <<: *shard-workflow name: "Shard #2" environment: xcode: latest vars: TUIST_SHARD_INDEX: 2 test-shard-3: <<: *shard-workflow name: "Shard #3" environment: xcode: latest vars: TUIST_SHARD_INDEX: 3 test-shard-4: <<: *shard-workflow name: "Shard #4" environment: xcode: latest vars: TUIST_SHARD_INDEX: 4 ``` ### Bitrise {#bitrise} On Bitrise, Tuist writes `.tuist-shard-matrix.json` to the `BITRISE_DEPLOY_DIR`, making it available as a build artifact for downstream pipeline stages. Use Bitrise Pipelines with pre-defined parallel workflows: ```yaml # bitrise.yml pipelines: test-pipeline: stages: - build-stage: {} - test-stage: {} stages: build-stage: workflows: - build-shards: {} test-stage: workflows: - test-shard-0: {} - test-shard-1: {} - test-shard-2: {} - test-shard-3: {} - test-shard-4: {} workflows: build-shards: steps: - script: title: Build and plan shards inputs: - content: | tuist auth login tuist xcodebuild build-for-testing \ -scheme MyScheme \ -destination 'platform=iOS Simulator,name=iPhone 16' \ --shard-total 5 test-shard-0: &shard-workflow envs: - TUIST_SHARD_INDEX: 0 steps: - script: title: Run shard inputs: - content: | tuist auth login tuist xcodebuild test \ -scheme MyScheme \ -destination 'platform=iOS Simulator,name=iPhone 16' test-shard-1: <<: *shard-workflow envs: - TUIST_SHARD_INDEX: 1 test-shard-2: <<: *shard-workflow envs: - TUIST_SHARD_INDEX: 2 test-shard-3: <<: *shard-workflow envs: - TUIST_SHARD_INDEX: 3 test-shard-4: <<: *shard-workflow envs: - TUIST_SHARD_INDEX: 4 ``` > [!TIP] > Bitrise does not support dynamic parallel job creation at runtime. Define a fixed number of shard workflows in your pipeline stages — workflows within a stage run in parallel automatically. ## Shared volumes {#shared-volumes} By default, the build phase uploads the `.xctestproducts` bundle to remote storage, and each shard runner downloads it. If your CI provider supports **shared volumes** (persistent storage mounted across jobs), you can skip this upload/download entirely by passing the test products through a shared filesystem. This can significantly reduce shard startup time, especially for large test bundles. To use shared volumes: 1. In the **build phase**, pass `-testProductsPath` pointing to a shared volume and add `--shard-skip-upload` to skip the remote upload: ```sh tuist xcodebuild build-for-testing \ -scheme MyScheme \ -destination 'platform=iOS Simulator,name=iPhone 16' \ --shard-total 5 \ --shard-skip-upload \ -testProductsPath /path/to/shared/volume/$UNIQUE_ID/MyScheme.xctestproducts ``` 2. In the **test phase**, pass the same `-testProductsPath` so Tuist reads the test products locally instead of downloading them: ```sh tuist xcodebuild test \ -scheme MyScheme \ -destination 'platform=iOS Simulator,name=iPhone 16' \ -testProductsPath /path/to/shared/volume/$UNIQUE_ID/MyScheme.xctestproducts ``` > [!IMPORTANT] > Use a unique path per workflow run (e.g. include the CI run ID) to avoid collisions between concurrent runs. You should also clean up the test products after sharding completes to avoid accumulating stale data on the volume. | Flag | Environment variable | Description | |------|---------------------|-------------| | `--shard-skip-upload` | `TUIST_TEST_SHARD_SKIP_UPLOAD` | Skip uploading the test products bundle to remote storage | ### Namespace {#namespace} [Namespace](https://namespace.so) runners support shared volumes across GitHub Actions jobs. Since volumes persist across workflow runs, include `${{ github.run_id }}` in the path to isolate concurrent runs and clean up afterwards: ```yaml name: Tests on: [pull_request] env: TEST_PRODUCTS_PATH: /Volumes/test-products/${{ github.run_id }}/MyScheme.xctestproducts jobs: build: name: Build test shards runs-on: namespace-profile-default-macos volumes: - name: test-products path: /Volumes/test-products outputs: matrix: ${{ steps.build.outputs.matrix }} steps: - uses: actions/checkout@v4 - uses: jdx/mise-action@v2 - run: tuist auth login - id: build run: | tuist xcodebuild build-for-testing \ -scheme MyScheme \ -destination 'platform=iOS Simulator,name=iPhone 16' \ --shard-total 5 \ --shard-skip-upload \ -testProductsPath $TEST_PRODUCTS_PATH test: name: "Shard #${{ matrix.shard }}" needs: build runs-on: namespace-profile-default-macos strategy: fail-fast: false matrix: shard: ${{ fromJson(needs.build.outputs.matrix).shard }} volumes: - name: test-products path: /Volumes/test-products env: TUIST_SHARD_INDEX: ${{ matrix.shard }} steps: - uses: actions/checkout@v4 - uses: jdx/mise-action@v2 - run: tuist auth login - run: | tuist xcodebuild test \ -scheme MyScheme \ -destination 'platform=iOS Simulator,name=iPhone 16' \ -testProductsPath $TEST_PRODUCTS_PATH - if: always() run: rm -rf /Volumes/test-products/${{ github.run_id }} ```