Skip to content

Run iOS UI checks in CI without a device farm

Run iOS UI tests on every pull request with a Mac mini, simulators and one plugged-in iPhone: the xcodebuild flags, a GitHub Actions workflow and the limits.

A device farm rents you hundreds of phones. Most teams need three things: UI tests on every pull request, across a couple of screen sizes and iOS versions, and a real phone for what a simulator can’t show. A Mac mini does that for the price of the Mac. This guide builds the pipeline in four steps, and ends with what it can’t do and when a farm is the better tool.

Step 1: make Xcode fast on CI

Whatever tool runs your tests, these xcodebuild habits apply. Build once, then run the tests without rebuilding:

xcodebuild build-for-testing -scheme YourApp \  -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \  -derivedDataPath buildxcodebuild test-without-building -scheme YourApp \  -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \  -derivedDataPath build -resultBundlePath build/result.xcresult \  -parallel-testing-enabled YES -parallel-testing-worker-count 2
  • -parallel-testing-enabled YES clones the simulator and runs test classes side by side. Size the worker count to the Mac’s memory: one booted iPhone 17 Pro simulator with a small SwiftUI app used 0.7 GB in Mobster’s measurement (28 Sep 2026).
  • -resultBundlePath keeps the .xcresult, with the screenshots and the log of every failure. Upload it as an artifact.
  • Run only what a pull request touched with -only-testing:YourAppUITests/CheckoutTests.

Step 2: pick where it runs

RunnerSimulatorsReal iPhoneYou pay
A GitHub-hosted macOS runnerYesNoPer minute
A Mac mini of yours, as a self-hosted runnerYesOne or more, plugged inThe Mac, once
A device farmSomeYes, rentedPer device-minute or a plan

A hosted runner is the easiest start and needs no hardware. The Mac mini is the one that gets you a real phone in CI, and it has no per-run cost. Mobster’s CI guide sets one up start to finish: a standard ci user with automatic login, sudo pmset -a sleep 0 disksleep 0 autorestart 1 so the Mac never sleeps through a job, and the runner installed as a LaunchAgent of that user so it reaches the user’s simulators and iPhone.

Step 3: add Mobster’s checks to the pull request

Mobster tests the screens a pull request changed, then keeps one comment up to date with the verdict. From your project’s folder:

mobster ci init
Wrote .github/workflows/mobster.yml.Next:  1. On the Mac that runs the checks: mobster ci doctor  2. Register it as a self-hosted runner with the label mobster: https://docs.mobster.dev/ci/mac-runner  3. Add ANTHROPIC_API_KEY as a repository secret, for checks with steps.  4. Commit the file and open a pull request.

It writes a workflow that runs on your mobster runner, so your other jobs stay on hosted runners:

name: mobsteron: pull_requestpermissions:  contents: read  pull-requests: writejobs:  checks:    runs-on: [self-hosted, macOS, mobster]    timeout-minutes: 60    steps:      - uses: actions/checkout@v4        with:          fetch-depth: 0      - run: /Applications/Mobster.app/Contents/MacOS/mobster-agent ci run

The file mobster ci init writes has more: the report upload, the comment step and a concurrency group. mobster ci run fetches the base branch and runs mobster test pr --video. It reads the diff, picks the saved checks that cover the changed screens, builds the app, runs the checks, and drafts a check for each changed screen that has none. It fails the job when a check fails or couldn’t run.

The comment names what failed, with a folded repro for each check. An example:

### Mobster checks: 11 passed, 1 failed| Check | Device | Expected | Found | Frame || --- | --- | --- | --- | --- || Failed: The paywall shows three plans | iPhone 17 Pro, iOS 26.4 | text “Choose your plan” | not on screen; closest: "Choose a plan" | paywall.jpg |

Each failed check in the comment carries the device, the deep link it opened, its steps, what it expected and found, a GIF, the video and the command that runs it again. Typed text, email addresses and phone numbers show as •••. The junit.xml works with any JUnit reader, and GitLab shows it in the merge request by itself.

Launch-only checks need no key. A check with steps runs on your ANTHROPIC_API_KEY until it passes once, then can replay with no model call. Checks that use project secrets run Smart fresh.

Step 4: add a real iPhone and a nightly run

Plug a test phone into the runner Mac, set it up once in Mobster for Mac, and set Auto-Lock to Never. Then list it in the runner’s own environment file, not in the workflow, because a pull request can edit the workflow:

# ~/actions-runner/.envMOBSTER_RUNNER_DEVICES=Test iPhoneMOBSTER_RUNNER_TESTFLIGHT=com.example.yourapp
mobster ci run --device "Test iPhone" --testflight com.example.yourapp

A device or TestFlight app the runner doesn’t list stops the job before a check runs. With several phones on one runner, add -- --spread after the mobster ci run arguments to give each check to whichever phone is free.

Run every check, not only the changed screens, overnight:

mobster test nightly on --at 02:00 --device all

That writes a LaunchAgent. In the morning, .mobster/nightly/<date>/morning.md has the verdict, each failed or flaky check with the reason, and the command that runs it again. To split one suite across several Macs, use --shard 1/3, --shard 2/3 and --shard 3/3 with a matrix strategy.

What this doesn’t do

  • No phone fleet. You test the iPhones you own. If you must cover 40 physical models and iOS versions at once, rent a farm for that, and keep the Mac for every pull request.
  • No hosted dashboard. Mobster hosts no runners or phones. Reports are files in your CI artifacts and your project folder.
  • A self-hosted runner runs the code in each pull request. Don’t use one on a public repository that takes pull requests from forks, and use a test phone on a test account. GitHub’s hardening guide explains why.
  • On a phone, a check drives only your own build, never an App Store app, and never clears the app’s data.
  • The runner Mac needs Mobster for Mac, signed in, with a plan or the free trial. A plan is $12 a month, $99 a year or $149 once, with every phone and every run included.

Download Mobster for Mac to run mobster ci init on your own Mac mini: $12 a month, $99 a year, or $149 once, with no per-run or per-device fee.

Running checks in CI has every mobster ci flag and the GitLab setup, and Checks explains the check file.

Questions

  • Do I need a device farm to run iOS UI tests in CI?

    Not to catch most regressions. Simulators on a Mac cover your screens across device sizes and iOS versions, and one real iPhone on the same Mac covers what simulators miss. A farm earns its cost when you must test many physical models at once.

  • Can GitHub-hosted runners run iOS UI tests?

    Yes, on a macOS runner with Xcode and simulators. They have no real iPhone attached, so real-device checks need a Mac of your own with the phone plugged in.

  • Is it safe to plug a phone into a self-hosted runner?

    Only with a test phone on a test account, and not on a public repository that takes pull requests from forks, because a pull request can run code on the runner. Mobster lists the phones a runner may test in the runner's own environment file, which a pull request can't edit.

Sources

We read each of these on 17 October 2026. Reviewed by Mobster team on . Corrections are welcome at the GitHub repo.