nanda mochammad
Engineering Notes

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

11 min read

Hi iOS folks. This article is about running XCUITest from the command line with xcodebuild, and what that makes possible once nobody has to click anything in Xcode.

By the end of this article, you will be able to run any UI test suite with one xcodebuild command, turn its noisy output into something readable, pull failures out as JSON, and wire all of that into a loop where an AI agent writes code, runs the tests, reads the failure, and fixes itself.

My AI agent writes a feature, runs the UI tests, reads the failure, and fixes itself while I drink coffee. That sounds like a demo, but it is routine now. What made it work was moving the tests out of Xcode and onto the command line, not a smarter model.

Xcode is a good place for a human to run a test. You hit ⌘U, watch the diamonds turn green, and click a red one to see why. But every one of those steps needs a person with a mouse. The moment you want CI, a git hook, a script, or an 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. None of them can drive Xcode’s UI. Put your whole test suite behind a single command, and all three become possible at once. It is the same idea behind continuous integration. If running the tests is a manual chore, it happens rarely. If it is one command, it happens all the time.

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. Otherwise the command line has nothing to run. 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 is what lets -scheme LedgerApp run the UI tests. It also 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, check 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 looks for transfer.submit, so the control in the app needs that identifier. This edits an existing file in the app target (not the UITests target). Open Features/Transfer/TransferView.swift and add the .accessibilityIdentifier(...) modifier to 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 how the glass looks on screen.

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. Then 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. name=iPhone 17 alone will quietly pick whatever runtime is installed. That is exactly the kind of “works on my machine” gap that breaks CI. The -resultBundlePath writes an .xcresult bundle you can read later for structured pass/fail data. 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, and three of them matter. Pipe it through xcbeautify for a readable stream. For anything a program needs to read (CI annotations, or feeding an agent), pull structured failures out of the result bundle. 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 turns “somewhere in 4,000 lines” into a small JSON object that names the failed test and its message. A person can read it, and a machine can parse it.


The self-healing loop

Write, run, read, fix, repeat

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 on its own. It writes code, runs the tests via Bash, reads the structured failure, edits the fix, reruns the one test, and repeats 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 plain:

▸ 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 does what a careful engineer does, without getting bored on the fourth try. Kent Beck’s old line fits here: you get paid for code that works, not for tests. 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 are stable, and UI tests are the least stable kind. Two habits do most of the work. First, give every element you test a stable accessibility identifier. Never select by visible text, which changes with copy and language:

// 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. 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

Simulators sometimes get stuck and need an xcrun simctl shutdown all to recover. Code signing and real-device testing pull you back into setup work that does not belong in a fast loop. So keep device runs manual. And the feedback cycle is faster than clicking, but it is still measured in seconds to minutes, not milliseconds. A UI test loop is a coarse tool. Pair it 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 in one file. Save it at the project root and run chmod +x run_tests.sh. 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.

Recap

The goal was to run iOS UI tests without touching Xcode, and to use that to let an agent fix its own code. The main points:

  • xcodebuild test with a scheme, a pinned simulator destination, and -resultBundlePath runs any suite from a shell. -only-testing: reruns one test in seconds.
  • Pipe the output through xcbeautify for humans, and use xcrun xcresulttool to get failures as JSON for machines. Keep set -o pipefail so failures still fail the command.
  • Once failures come back as structured text, an agent can write, run, read, fix, and rerun until the test passes.
  • Stable accessibility identifiers and deterministic data keep the loop honest. A flaky test in a loop is worse than no test.
  • Keep signing and real-device runs manual, and pair the UI loop with fast unit tests.

Start small. Get one suite running clean from the shell, pipe it through xcbeautify, and wire the result into a script. Add the agent loop last. Once it is in place, the repetitive part of testing runs without you.

I hope this writing helps you. Thanks for reading.

Cited sources