> ## Documentation Index
> Fetch the complete documentation index at: https://docs.mobileboost.io/llms.txt
> Use this file to discover all available pages before exploring further.

# XCUITest

> Package your compiled XCUITest runner and execute it on the MobileBoost device grid

This guide covers how to build and package your compiled XCUITest suite (`*-Runner.app`) so it runs correctly on the MobileBoost device grid, and how to trigger and monitor runs via the API. Follow the section that matches where you want to run: physical devices or virtual devices (simulators). The two are not interchangeable - a runner built for one will not launch on the other.

<Note>
  You do not need to solve code signing or provisioning for our devices. Our grid re-signs the test runner with the certificate and device profile for the specific hardware it runs on.
</Note>

All API endpoints below authenticate with your `mb_live_` API key passed as a Bearer token in the `Authorization` header.

## Prerequisites

* Xcode (matching or newer than the OS versions you target).
* Your project has a UI Testing bundle target and a scheme that includes it in its Test action.
* You know three bundle identifiers - you'll need them at upload time:
  * App under test (must match the `.ipa` you upload)
  * Test runner (`*-Runner`)
  * Test bundle (`*UITests`)

## 1. Why physical vs. virtual matters

`build-for-testing` compiles your runner and its `.xctest` bundle for one platform slice at a time:

| Target   | Destination                      | Output folder            | Runs on            |
| -------- | -------------------------------- | ------------------------ | ------------------ |
| Physical | `generic/platform=iOS`           | `Debug-iphoneos/`        | Real iPhones/iPads |
| Virtual  | `generic/platform=iOS Simulator` | `Debug-iphonesimulator/` | Simulators         |

A simulator build links the simulator XCTest frameworks and CPU slice; a device build links the device ones. Uploading the wrong slice is the single most common failure - it will install but the test harness won't start. If you want to run on both, build and upload two separate zips.

## 2. Build the runner

<Tabs>
  <Tab title="Physical devices">
    ```bash theme={null}
    xcodebuild build-for-testing \
      -scheme <YOUR_SCHEME> \
      -destination 'generic/platform=iOS' \
      -derivedDataPath ./build
    ```

    Output lands in:

    ```
    ./build/Build/Products/Debug-iphoneos/<YOUR_SCHEME>UITests-Runner.app
    ./build/Build/Products/<...>.xctestrun
    ```
  </Tab>

  <Tab title="Virtual devices (simulators)">
    ```bash theme={null}
    xcodebuild build-for-testing \
      -scheme <YOUR_SCHEME> \
      -destination 'generic/platform=iOS Simulator' \
      -derivedDataPath ./build
    ```

    Output lands in:

    ```
    ./build/Build/Products/Debug-iphonesimulator/<YOUR_SCHEME>UITests-Runner.app
    ./build/Build/Products/<...>.xctestrun
    ```
  </Tab>
</Tabs>

## 3. Package the runner zip

Zip from inside the Products slice folder so the `.app` is at the root of the archive, and use `--symlinks` (framework bundles contain symlinks - omitting this bloats the zip and can break the embedded frameworks).

<Tabs>
  <Tab title="Physical devices">
    ```bash theme={null}
    cd ./build/Build/Products/Debug-iphoneos
    zip --symlinks -r ~/MyAppUITests-physical.zip \
      *-Runner.app \
      ../*.xctestrun
    ```
  </Tab>

  <Tab title="Virtual devices (simulators)">
    ```bash theme={null}
    cd ./build/Build/Products/Debug-iphonesimulator
    zip --symlinks -r ~/MyAppUITests-simulator.zip \
      *-Runner.app \
      ../*.xctestrun
    ```
  </Tab>
</Tabs>

Zip contents (required + recommended):

* `*-Runner.app` - required (contains `PlugIns/*.xctest`, your compiled tests)
* `*.xctestrun` - recommended. It preserves your test configuration: launch environment variables, launch arguments, the target-app reference, and skip/only test lists. Include it and your tests run exactly as configured in your scheme.

## 4. Verify the zip before uploading

```bash theme={null}
# Confirm the runner and the compiled test bundle are present
unzip -l ~/MyAppUITests-physical.zip | grep -E 'Runner.app|\.xctest|\.xctestrun'
```

You should see the `*-Runner.app`, a `PlugIns/*UITests.xctest` inside it, and the `.xctestrun`.

To sanity-check you built the device slice (not simulator) when targeting physical hardware, confirm it came out of the `Debug-iphoneos` folder - that folder is the source of truth for the slice.

## 5. Bundle-ID matching

<Warning>
  The app under test you upload as `.ipa` must have the same bundle identifier the test scheme was built against. If your tests use a bare `XCUIApplication()`, it launches the scheme's target app by bundle ID; a mismatch means the tests launch nothing (or the wrong app).
</Warning>

* For physical runs, upload your enterprise-distribution `.ipa` as the app under test. Do not upload the debug `Debug-iphoneos/*.app` produced by `build-for-testing` as the app under test - that debug app and your test runner just need to share the bundle ID, not the binary.
* If your tests target a specific bundle explicitly, use `XCUIApplication(bundleIdentifier: "<APP_UNDER_TEST_BUNDLE_ID>")` and tell us that ID.

## 6. Code signing - what you do and don't need to do

**Physical devices:** You do not need a provisioning profile that includes our devices. We re-sign the runner with our own Apple Development certificate and a profile scoped to the target hardware. To avoid any local provisioning friction, you can even build without signing:

```bash theme={null}
xcodebuild build-for-testing \
  -scheme <YOUR_SCHEME> \
  -destination 'generic/platform=iOS' \
  -derivedDataPath ./build \
  CODE_SIGNING_ALLOWED=NO
```

(Building with your own team's automatic signing is also fine - we strip and re-sign either way.)

**Virtual devices:** Simulators don't enforce code signing; no certificates or profiles are required.

Because we re-sign the runner, please keep bundle identifiers stable and don't hard-code entitlements that depend on your own team's provisioning (e.g. keychain-sharing / app-group IDs tied to your Team ID) in the UI test target - those can break re-signing. Your app under test is unaffected; it keeps your enterprise signature.

## 7. Optional: get your test list for sharding

If you want to split tests across devices, we can shard automatically, or you can send us the test identifiers:

```bash theme={null}
xcodebuild -scheme <YOUR_SCHEME> -showTestPlans
```

Identifiers use `TargetName/ClassName/testMethod` and can be passed as `only-testing` / `skip-testing` filters at launch time.

## 8. What to upload

You'll end up with, per target type:

| File                                     | Purpose                                                                           | Upload endpoint                      |
| ---------------------------------------- | --------------------------------------------------------------------------------- | ------------------------------------ |
| `MyApp.ipa`                              | App under test (enterprise-signed for physical; simulator `.app`-zip for virtual) | `POST /uploadBuild`                  |
| `MyAppUITests-<physical\|simulator>.zip` | Compiled test runner + `.xctestrun`                                               | `POST /app-automate/xcui/test-suite` |

### Common mistakes checklist

* Built for simulator, uploaded for a physical run (or vice-versa). → Build the matching slice; upload the matching zip.
* Zipped from the wrong directory, so the `.app` is nested under `Build/Products/...` inside the archive. → `cd` into the slice folder first; the `.app` must be at the zip root.
* Zipped without `--symlinks`. → Frameworks break / signing fails.
* App-under-test bundle ID ≠ what the tests expect. → Keep them identical; tell us the ID.
* Uploaded the debug `build-for-testing` app instead of the enterprise `.ipa` for physical runs. → Upload your enterprise IPA as the app under test.
* Ran `test` instead of `build-for-testing` (produces no reusable runner). → Use `build-for-testing`.

## 9. Trigger a test run

Once both files are uploaded, start a run with the IDs returned by the two upload calls: the `app_id` from `POST /uploadBuild` and the `test_suite_id` from `POST /app-automate/xcui/test-suite`.

**Endpoint**

```
POST /app-automate/xcui/build
```

**Minimal request**

```bash theme={null}
curl -X POST "https://api.mobileboost.io/app-automate/xcui/build" \
  -H "Authorization: Bearer $MOBILEBOOST_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "app": "<APP_ID>",
    "testSuite": "<TEST_SUITE_ID>"
  }'
```

**Required parameters**

| Field       | Description                                                           |
| ----------- | --------------------------------------------------------------------- |
| `app`       | The `app_id` returned by `POST /uploadBuild`.                         |
| `testSuite` | The `test_suite_id` returned by `POST /app-automate/xcui/test-suite`. |

**Optional parameters**

| Field                           | Description                                                                                                       |
| ------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `project`, `buildTag`           | Free-text labels for grouping and filtering runs.                                                                 |
| `gpsLocation`                   | Location applied to every device before tests start.                                                              |
| `only-testing` / `skip-testing` | Run or exclude specific tests (whole run, no sharding). Values are `TargetName/ClassName/testMethod` identifiers. |
| `shards`                        | Split the suite across devices, optionally with an explicit test list per shard.                                  |

**Full request with GPS and sharding**

```bash theme={null}
curl -X POST "https://api.mobileboost.io/app-automate/xcui/build" \
  -H "Authorization: Bearer $MOBILEBOOST_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "app": "<APP_ID>",
    "testSuite": "<TEST_SUITE_ID>",
    "project": "My iOS App",
    "buildTag": "main-2026-07-01",

    "gpsLocation": { "latitude": 40.698979, "longitude": -73.9958661 },

    "shards": {
      "numberOfShards": 2,
      "mapping": [
        {
          "name": "shard-1",
          "strategy": "only-testing",
          "values": ["MyAppUITests/LoginTests", "MyAppUITests/CheckoutTests/testAddToCart"]
        },
        {
          "name": "shard-2",
          "strategy": "only-testing",
          "values": ["MyAppUITests/SearchTests"]
        }
      ]
    }
  }'
```

### GPS location

`gpsLocation` overrides the device's location for the whole run; it is applied on each device before your first test starts and cleared automatically at teardown.

```json theme={null}
{ "latitude": 40.698979, "longitude": -73.9958661, "altitude": 0.0 }
```

| Field       | Required | Notes                                                                                                                                    |
| ----------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `latitude`  | yes      | Decimal degrees.                                                                                                                         |
| `longitude` | yes      | Decimal degrees.                                                                                                                         |
| `altitude`  | no       | Meters, default `0.0`. Honored on virtual devices only; ignored on physical iOS (the simulated-location channel there is lat/long only). |

If you omit `gpsLocation`, physical devices keep their real GPS and virtual devices use our neutral default. To read or change the location from inside a test, drive your app's own location flows as usual - `gpsLocation` sets the starting position for the run.

### Sharding

Sharding is optional. Provide a `shards` object to split the suite across devices and run them in parallel.

**Auto-distribute** - give only `numberOfShards`; we spread the discovered tests evenly across that many shards:

```json theme={null}
{ "numberOfShards": 3 }
```

**Explicit test cases per shard** - provide a `mapping` array. Each entry names a shard and lists the tests it runs via `strategy: "only-testing"` and a `values` list of `TargetName/ClassName[/testMethod]` identifiers. `numberOfShards` must match the number of `mapping` entries.

```json theme={null}
{
  "numberOfShards": 2,
  "mapping": [
    { "name": "shard-1", "strategy": "only-testing", "values": ["MyAppUITests/LoginTests"] },
    { "name": "shard-2", "strategy": "only-testing", "values": ["MyAppUITests/SearchTests"] }
  ]
}
```

A strategy of `skip-testing` is also accepted per shard if you prefer to express a shard as "everything except these". Use `-showTestPlans` to list valid identifiers.

**Response**

```json theme={null}
{
  "message": "Success",
  "build_id": "4d2b4deb810af077d5aed98f479bfdd2e64f36c3"
}
```

Store the `build_id`; it identifies the run when you poll status and download results.

## 10. Check run status

Poll the build with the `build_id` from the launch response.

```
GET /app-automate/xcui/build/<BUILD_ID>
```

```bash theme={null}
curl -H "Authorization: Bearer $MOBILEBOOST_API_KEY" \
  "https://api.mobileboost.io/app-automate/xcui/build/$BUILD_ID"
```

The build carries an overall status plus one session per device/shard combination. Each session has its own `session_id`.

```json theme={null}
{
  "build_id": "4d2b4deb810af077d5aed98f479bfdd2e64f36c3",
  "status": "running",
  "sessions": [
    {
      "session_id": "9f1c2a7b6e3d4f018a2b5c6d7e8f9012",
      "device": "iPhone 15 Pro-18.5",
      "shard": "shard-1",
      "status": "passed",
      "tests": { "total": 12, "passed": 12, "failed": 0, "skipped": 0 }
    },
    {
      "session_id": "1a2b3c4d5e6f70819aabbccddeeff0011",
      "device": "iPhone 15 Pro-18.5",
      "shard": "shard-2",
      "status": "running",
      "tests": { "total": 8, "passed": 3, "failed": 0, "skipped": 0 }
    }
  ]
}
```

Status values (both build and session): `queued`, `running`, `passed`, `failed`, `error` (infrastructure/setup problem, e.g. install or signing failure), `timedout`.

**Poll until terminal**

```bash theme={null}
while :; do
  STATUS=$(curl -s -H "Authorization: Bearer $MOBILEBOOST_API_KEY" \
    "https://api.mobileboost.io/app-automate/xcui/build/$BUILD_ID" | jq -r '.status')
  echo "status: $STATUS"
  case "$STATUS" in
    queued|running) sleep 10 ;;
    *) break ;;
  esac
done
```

## 11. Retrieve results

Once the build reaches a terminal status, fetch the machine-readable report at the build level.

```
GET /app-automate/xcui/build/<BUILD_ID>/report?format=<junit|json>
```

```bash theme={null}
curl -H "Authorization: Bearer $MOBILEBOOST_API_KEY" \
  "https://api.mobileboost.io/app-automate/xcui/build/$BUILD_ID/report?format=junit" \
  -o results.xml
```

`junit` returns a standard JUnit XML your CI can ingest; `json` returns the same pass/fail data as structured JSON.
