nanda mochammad
Engineering Notes

Running iOS UI tests without opening Xcode: xcodebuild, clean output, and an agent that fixes itself

10 min read

My AI agent writes a feature, runs the UI tests, reads the failure, and fixes itself while I drink coffee. That sentence sounds like a demo, but it’s routine now, and the thing that unlocked it wasn’t a smarter model. It was moving the tests out of Xcode and onto the command line.

Xcode is a good place for a human to run a test: you hit ⌘U, watch the diamonds turn green, click a red one to see why. But every one of those steps assumes a person with a mouse. The moment you want CI, a git hook, a script, or an autonomous agent to run your tests, you need a door that opens without a cursor. That door is xcodebuild.

Why leave the GUI

The command line is the only door automation can walk through

A test you can only run by clicking is a test only a human can run. CI servers, pre-push hooks, and AI agents all speak the same language, a shell, and none of them can drive Xcode’s UI. Putting your whole test suite behind a single command is what makes all three possible at once. It’s the same realisation behind continuous integration: if running the tests is a manual ritual, it happens rarely; if it’s one command, it happens constantly.

Write your first UI test

A target and one test to give the loop something to run

If you don’t already have a UI test suite, make one first; the command line has nothing to run otherwise. In Xcode, go to File → New → Target…, pick UI Testing Bundle, and name it LedgerAppUITests. In the same sheet, set Target to be Tested to LedgerApp and accept Xcode’s prompt to update the scheme. That’s what lets -scheme LedgerApp run the UI tests. That gives you a target whose name matches the -only-testing: and -scheme examples below.

Then write one small test. The shape never changes: launch the app, act on a control, assert on the result.

// LedgerAppUITests/TransferFlowTests.swift
import XCTest

@MainActor
final class TransferFlowTests: XCTestCase {
    func testSubmitTransfer() {
        // Arrange — launch a fresh instance of the app
        let app = XCUIApplication()
        app.launch()

        // Act — tap the submit control by its accessibility identifier
        app.buttons["transfer.submit"].tap()

        // Assert — the confirmation appears
        XCTAssertTrue(
            app.staticTexts["transfer.confirmation"].waitForExistence(timeout: 2),
            "Confirmation should appear after a transfer"
        )
    }
}

That test queries transfer.submit, so the app-side control needs that identifier attached. This edits an existing file in the app target (not the UITests target): open Features/Transfer/TransferView.swift and add the .accessibilityIdentifier(...) modifier onto the existing Send button:

// Features/Transfer/TransferView.swift
Button("Send", action: submitTransfer)
    .accessibilityIdentifier("transfer.submit")

With the iOS 26 SDK, controls render as Liquid Glass, so query tests by accessibilityIdentifier, never by on-screen glass appearance.

xcodebuild, the parts that matter

Run any suite, on any simulator, in one command

You don’t need to memorise all of xcodebuild. Four ideas cover the daily work: pick a scheme, pick a destination (which simulator), optionally narrow to specific tests, and read the result bundle.

Not sure of your scheme name? List them with xcodebuild -list -project LedgerApp.xcodeproj (use -workspace LedgerApp.xcworkspace instead if you’re on a CocoaPods/SPM workspace), then use the name shown under Schemes: for -scheme.

# Run from the project root, where LedgerApp.xcodeproj lives.

# Run the whole UI test suite on a pinned simulator
xcodebuild test \
  -scheme LedgerApp \
  -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.0' \
  -resultBundlePath ./TestResults.xcresult

# Run just one class or one test — the speed trick for tight loops
xcodebuild test \
  -scheme LedgerApp \
  -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.0' \
  -only-testing:LedgerAppUITests/TransferFlowTests/testRetryOnFailure

List the simulators you actually have with xcrun simctl list devices available. If no iOS 26 / iPhone 17 runtime shows up, install it via Xcode → Settings → Components (download the iOS 26 simulator runtime), then match the name= and OS= in the destination string exactly to a device you have.

Pin the OS version explicitly. name=iPhone 17 alone will silently pick whatever runtime is installed, which is exactly the kind of “works on my machine” gap that breaks CI. The -resultBundlePath writes an .xcresult bundle you can mine for structured pass/fail data afterwards. The bundle lands in your current working directory: open it in Xcode with open TestResults.xcresult, or read it with the parsing command shown next.

Make the output human (and machine) readable

Pipe through xcbeautify, then extract the failures

Raw xcodebuild output is a wall of compiler noise: thousands of lines where three matter. Pipe it through xcbeautify for a readable stream, and pull structured failures out of the result bundle for anything programmatic (CI annotations, or feeding an agent). The | feeds xcodebuild’s output into xcbeautify; include set -o pipefail so a failing test still fails the command. Without it the pipe reports xcbeautify’s exit code, which is success even when a test failed:

# Run from the project root.
set -o pipefail   # so a test failure still fails the script through the pipe
xcodebuild test -scheme LedgerApp \
  -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.0' \
  -resultBundlePath ./TestResults.xcresult | xcbeautify

# Pull just the failures as JSON for tooling or an agent to read
xcrun xcresulttool get test-results summary \
  --path ./TestResults.xcresult --format json

That last command does the real work here: it turns “somewhere in 4,000 lines” into a small JSON object naming the failed test and its message. Readable by a person, parseable by a machine.


The self-healing loop

Write, run, read, fix, repeat

Here’s where it gets fun. Once tests run from a shell and failures come back as structured text, an AI agent (Claude Code, in my case) can close the loop entirely: write code, run the tests via Bash, read the structured failure, edit the fix, rerun the one test, repeat until green.

Diagram: a closed agent loop. The AI agent edits Swift code, shells out to xcodebuild test, the iOS simulator runs the UI tests, the results are parsed from the .xcresult bundle into structured failures, and those failures flow back to the agent, which fixes the code and runs again. When the run is green, the loop exits and the change ships. AI AGENT Edit Swift writes the fix BASH xcodebuild test -only-testing SIMULATOR Run UI tests taps, asserts RESULTS .xcresult parsed failures red → feed failures back, fix, run again green → ship the change You watch the loop spin; you only step in when it stalls or asks a question.
The loop that runs while you're away: the agent edits Swift, shells out to xcodebuild test, reads the structured failure, fixes, and reruns the single affected test until it passes.

A real session looks unremarkable, which is the point:

▸ wrote TransferView.swift, TransferModel.swift
▸ xcodebuild test -only-testing:…/testRetryOnFailure
  ✗ testRetryOnFailure — XCTAssertEqual failed: ("0") is not equal to ("3")
▸ read failure → retry counter never increments on timeout
▸ edited TransferModel.retry(); rerunning one test
  ✓ testRetryOnFailure passed in 7.4s

The model isn’t doing anything magical; it’s doing what a disciplined engineer does, without getting bored on the fourth iteration. Kent Beck’s old line fits here: you get paid for code that works, not for tests, and the loop is a tireless way of proving the code works.

Design UI the loop can actually test

Accessibility identifiers and deterministic data

An automated loop is only as good as the tests’ stability, and UI tests are where stability goes to die. Two habits do most of the work. First, give every element you test a stable accessibility identifier, and never select by visible text, which changes with copy and locale:

// In the view
TextField("Email", text: $email)
    .accessibilityIdentifier("login.email")

// In the UI test — stable regardless of wording or language
app.textFields["login.email"].tap()
app.buttons["login.submit"].tap()

Second, make the data deterministic. A test that depends on today’s date, the network, or a random sort order will pass and fail at random, and an agent loop turns that randomness into chaos.

Diagram: how an agent loop amplifies a flaky test. A test that fails at random feeds the agent a false failure. The agent dutifully edits working code to chase a bug that does not exist, the next run flakes differently, and the loop spirals into damaged code. The fix is upstream: deterministic test data and stable accessibility identifiers make every run say the same thing, so the loop only ever reacts to real failures. WITHOUT DETERMINISM: THE LOOP CHASES GHOSTS Flaky test fails at random False failure agent believes it Edits good code to fix a non-bug spirals: next run flakes differently WITH DETERMINISM: EVERY RUN AGREES Seeded, fixed test data no clock, no network, no order luck Accessibility identifiers stable handles, not visible text Loop reacts to real failures only red means red
Why flakiness and agent loops are a dangerous mix: a test that fails at random feeds the agent a phantom bug, so it 'fixes' working code, and may break it, chasing a failure that was never real.
Where this breaks

Be honest about the limits

This is useful, not magic. Simulators occasionally wedge and need an xcrun simctl shutdown all to recover. Code signing and real-device testing drag you back toward configuration that doesn’t belong in a fast loop, so keep device runs manual. And the feedback cycle, while faster than clicking, is still measured in seconds-to-minutes, not milliseconds; a UI test loop is a coarse tool, best paired with quick unit tests for the fine-grained work.

Diagram: a before-and-after comparison of the wall-clock time a developer spends per UI-test bug. The manual Xcode loop (open Xcode, click run, watch, read the log, switch back) costs roughly twelve minutes of attention per fix. The agent loop costs the developer about two minutes of attention, because the machine does the run-read-fix cycle and only interrupts when it is stuck. The total clock time is not always shorter, but the human time is. MANUAL · IN XCODE open · run · watch · read log · switch back ~12 min AGENT LOOP · CLI review ~2 min 0 6 min 12 min Human attention per fixed bug. Wall-clock time can be similar; what shrinks is the time you have to watch it.
The payoff measured honestly: setup costs you time up front (a11y identifiers, deterministic fixtures, a build script), but the per-bug cost drops once the loop runs unattended.
 Xcode (⌘U)xcodebuild (CLI)
Runs in CI / hooksNoYes
Drivable by an agentNoYes
Output for machinesVisual diamonds.xcresult JSON
Human ergonomicsExcellentGood with xcbeautify
Best forWriting & debuggingRunning everywhere else

Is this test safe to put in a loop?

Before you let it run unattended

Deterministic + has a11y IDsstable→ Loop it
Fails at randomflaky→ Fix flakiness first
Needs signing or a real deviceenvironment-bound→ Keep it manual
The whole pipeline in one file

A script you can copy, commit, and call

Here is everything above bundled into one file. Save it at the project root, chmod +x run_tests.sh, and that single command is the door CI, a git hook, or an agent walks through.

# run_tests.sh — project root
#!/usr/bin/env bash
set -euo pipefail

SCHEME="LedgerApp"
DESTINATION="platform=iOS Simulator,name=iPhone 17,OS=26.0"
RESULT="./TestResults.xcresult"

# Re-run cleanly: stale bundles confuse xcresulttool.
rm -rf "$RESULT"

# Build + test, piped through xcbeautify for readable output.
# pipefail (set above) makes a test failure fail the whole script.
xcodebuild test \
  -scheme "$SCHEME" \
  -destination "$DESTINATION" \
  -resultBundlePath "$RESULT" \
  "$@" | xcbeautify

# Emit the structured failure summary for tooling or an agent to read.
xcrun xcresulttool get test-results summary \
  --path "$RESULT" --format json

Pass extra xcodebuild flags straight through: ./run_tests.sh -only-testing:LedgerAppUITests/TransferFlowTests/testSubmitTransfer reruns just one test for a tight inner loop.

Start small: get one suite running clean from the shell, pipe it through xcbeautify, and wire the result into a script. The agent loop is the last brick, and once it’s in place, the boring part of testing runs itself.

Cited sources