Running iOS UI tests without opening Xcode: xcodebuild, clean output, and an agent that fixes itself
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.
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.
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.
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.
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.
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.
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.
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.
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.
| Xcode (⌘U) | xcodebuild (CLI) | |
|---|---|---|
| Runs in CI / hooks | No | Yes |
| Drivable by an agent | No | Yes |
| Output for machines | Visual diamonds | .xcresult JSON |
| Human ergonomics | Excellent | Good with xcbeautify |
| Best for | Writing & debugging | Running everywhere else |
Is this test safe to put in a loop?
Before you let it run unattended
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