Running iOS UI tests without opening Xcode: xcodebuild, clean output, and an agent that fixes itself
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.
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.
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.
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.
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.
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.
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.
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.
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.
| 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 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 testwith a scheme, a pinned simulator destination, and-resultBundlePathruns any suite from a shell.-only-testing:reruns one test in seconds.- Pipe the output through xcbeautify for humans, and use
xcrun xcresulttoolto get failures as JSON for machines. Keepset -o pipefailso 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