Testing
sweetpad test builds your test targets, runs them on the destination you picked, and prints a
summary instead of xcodebuild's transcript:
$ sweetpad test
testing SweetpadCIApp for platform=iOS Simulator,id=F92801F8-…
Linking SweetpadCIAppTests
Suite All tests
Suite SweetpadCIAppTests.xctest
Suite AppTests
✓ SweetpadCIAppTests.AppTests.testArithmetic (0.001 seconds)
✓ Tests succeeded (32.8s)
1 passed, 0 failed, 0 skipped (1 total)
result bundle: /Users/you/.local/state/sweetpad/results/SweetpadCIApp-fb7b1c91.xcresult
The result bundle on the last line is kept per project, replacing the previous run's. Several commands read it back, so the run you just did stays inspectable after the output has scrolled away.
When something fails
A failure prints where it happened, what was expected, and a repeat of the failing tests at the end so you don't have to scroll up past a long run:
$ sweetpad test
…
✓ SweetpadCIAppTests.AppTests.testArithmetic (0.001 seconds)
error: /path/to/Tests/AppTests/AppTests.swift:10: -[SweetpadCIAppTests.AppTests testGreeting] : XCTAssertEqual failed: ("Hello, SweetPad") is not equal to ("Hello, Sweetpad")
✗ SweetpadCIAppTests.AppTests.testGreeting
✗ Tests failed
1 passed, 1 failed, 0 skipped (2 total)
✗ SweetpadCIAppTests/AppTests/testGreeting: XCTAssertEqual failed: ("Hello, SweetPad") is not equal to ("Hello, Sweetpad")
When a test records more than one failure, each one after the first gets a line of its own. A
message that runs over several lines, like the n → 2 a failed Swift Testing expectation adds, has
its extra lines indented deeper under it:
✗ SweetpadCIAppTests/AppTests/testGreeting: XCTAssertEqual failed: ("hello") is not equal to ("world")
XCTAssertTrue failed - second failure in the same test
✗ SweetpadCIAppTests/GreetingSuite/suiteGreeting(): Expectation failed: n == 1
n → 2
With -o json, each failure has message, the first of them, and messages, which lists all of
them in the order the test recorded them.
If the tests don't compile, sweetpad test stops with the compile errors, and
sweetpad build diagnostics reads them back afterwards, as it does after sweetpad build.
Red tests exit with code 3, the same code a failed build uses, since both mean "the work ran and
the answer was no". A missing scheme or an unresolvable destination is code 4 instead, so a CI
script can tell a genuine test failure apart from a broken invocation.
On Xcode 26 and later, xcodebuild normally collects a system diagnostic report after a failed run,
which can hold the run open for up to ten minutes. sweetpad test turns that off, so a failing run
ends as soon as the tests do. To get the report, pass -- -collect-test-diagnostics on-failure.
When the app goes away mid-test
If the app under test crashes or is killed during a test, XCTest only reports that it's gone, with
a message like "Failed to application … is not running" or "… crashed". A unit test's host app that
crashes or calls exit gets "Crash: MyApp at …" or "The test runner exited with code 3 …". For
those failures, SweetPad looks up the system's record of how the process ended and prints it under
the failure:
✗ ExitProbeUITests/ProbeUITests/testAppAbortsMidTest: dev.sweetpad.exitprobe.app crashed
Failed to application dev.sweetpad.exitprobe.app is not running
app terminated: crashed with SIGABRT (sent by ExitProbe[96265])
This also covers kills that leave no crash report, such as the simulator host ending the app. With
-o json, the same failure carries a terminationReason object with the raw reason, code, and
explanation, plus the crash report's path and the fault it names (exception) when there is one.
Other failures get no such field, including an assertion whose own text mentions a crash, such as
XCTFail("the helper crashed").
sweetpad app logs --exits shows the same records outside a test run.
A crash doesn't always come with a crash report. macOS saves only so many for one app (25 on
macOS 27), so a suite that crashes the app many times in a day stops getting them, and the fault
is missing from the app terminated: line. SweetPad adds a line under the failure when that may
be the reason, and the JSON failure gets a note with the same text:
app terminated: crashed with SIGTRAP (sent by exc handler[79175])
no crash report was found; macOS may have reached its limit of crash reports for this app
If SweetPad can't find the exit at all, the line under the failure says so and gives the
sweetpad app logs --exits command for the simulator or Mac the tests ran on, which lists the
app's recent exits.
The JSON failure's note has the same text.
A unit-test bundle with no host app runs in xctest, and a crash there reads "Crash: xctest at …".
The system keeps no exit record for xctest, so SweetPad reads the crash log XCTest attached to the
test instead:
✗ AppTests/CrashTests/testCBadPointer: Crash: xctest at static xctest.main()
xctest terminated: crashed with SIGSEGV (sent by exc handler[18468]; EXC_BAD_ACCESS KERN_INVALID_ADDRESS at 0x0000000000000010)
With -o json, the failure gets terminationReason and crashedIn from that crash log.
bundleId is null, since xctest has none, and crashReport is the same report in
~/Library/Logs/DiagnosticReports. When that file is missing, or the result bundle holds no crash
log SweetPad can read, the line under the failure gives a sweetpad test attachments command that
exports the crash log XCTest attached.
A crash in a unit test's host app doesn't always fail the test that caused it. When tests run in
parallel, XCTest fails every test that was running at the time, all with the same message. When the
crash comes from work a test left running after it passed, XCTest fails whichever test runs next.
So SweetPad reads the crash report's backtrace, and when the crash is in another test's code it adds
a line under the failure. With -o json, the failure's crashedIn names that test:
✗ AppTests/ParallelSuite/a_recordsCrashed(): Crash: MyApp at specialized static Runner._applyScopingTraits(for:testCase:_:)
app terminated: crashed with SIGTRAP (sent by exc handler[42305]; EXC_BREAKPOINT)
the crash report's backtrace is in AppTests/ParallelSuite/b_crashesHost(), not this test
When the backtrace doesn't go through any test, or there's no crash report, SweetPad can't tell
which test caused the crash. If the crash failed more than one test, the line says so, and the JSON
failure's crashCandidates lists every test it failed.
Compiling the tests without running them
sweetpad test build compiles the test targets and stops, the way sweetpad build does for the app.
Nothing launches, so it's a quick way to check that a change didn't break a test:
$ sweetpad test build
building SweetpadCIApp's tests (Debug) for platform=iOS Simulator,id=F92801F8-…
Compiling AppTests.swift
Linking SweetpadCIAppTests
✓ Build succeeded (5.6s)
Its output matches sweetpad build's: -q and -o json work the same, a failure exits 3, and
sweetpad build diagnostics reads its errors back. It picks the scheme, configuration, and
destination the way sweetpad test does. It doesn't touch the retained result bundle, so
--failed, test output, and test attachments still read your last run.
There's no --only-testing here. xcodebuild compiles every test target in the scheme even when
given a filter, so the flag would narrow nothing.
Running just some of the tests
--only-testing and --skip-testing narrow the run. Both are repeatable, and both take an
identifier in the form Target/Class/method, and you can stop at any level:
sweetpad test --only-testing SweetpadCIAppTests # one target
sweetpad test --only-testing SweetpadCIAppTests/AppTests # one class
sweetpad test --only-testing SweetpadCIAppTests/AppTests/testGreeting # one test
sweetpad test --skip-testing MyAppUITests # everything but the UI tests
The target here is the test target, the one that produces the .xctest bundle rather than the class the
tests live in. It's the first component of the Suite line in the output above.
--failed reruns only what failed last time, reading the identifiers out of the retained result
bundle:
sweetpad test --failed
When the test process fails outside any test, for example when the host app crashes at launch,
XCTest records a failure named like MyApp (36652) encountered an error. That isn't a test, and
given it as a selector xcodebuild runs nothing and reports success. So --failed leaves it out and
says so. If it's the only failure, --failed stops with an error, and a plain sweetpad test is
the rerun.
The failure lines at the end of a run use the same Target/Class/method form, so you can paste any
of them into --only-testing. A Swift Testing test keeps its parentheses, as in
SweetpadCIAppTests/GreetingSuite/suiteGreeting(). Without them xcodebuild selects nothing.
Watching, retrying, and measuring
Three flags cover most of what you'd otherwise script by hand.
--watch reruns the suite on every Swift save and keeps going after a failure. Pair it with
--only-testing so the loop stays fast while you work on one area:
sweetpad test --watch --only-testing SweetpadCIAppTests/AppTests
--retry-flaky N runs each failing test up to N times before calling it failed. A test that
passes on retry is reported as flaky rather than broken.
--coverage collects code coverage and folds the summary into the report.
sweetpad test --retry-flaky 3
sweetpad test --coverage
Looking at what the run left behind
The summary is deliberately small. Two commands dig into the retained result bundle when it isn't enough, and neither reruns anything. SweetPad keeps one bundle per project, from its last run, so neither command takes a scheme, configuration, or destination flag.
What the tests printed
sweetpad test output shows each test's own stdout and stderr, grouped by test, in the order the
tests started:
$ sweetpad test output
SweetpadCIAppTests/AppTests/testGreeting
greeting under test
/path/to/Tests/AppTests/AppTests.swift:10: error: -[SweetpadCIAppTests.AppTests testGreeting] : XCTAssertEqual failed: ("Hello, SweetPad") is not equal to ("Hello, Sweetpad")
1 test wrote output (recorded less than a minute ago)
Long output is trimmed to the last few KB per test; --full prints all of it. A test's own print
lands here. XCTest's assertion messages and a UI test's screenshots do not.
Parallel testing doesn't change this, since each worker writes its own output. A test that runs another test inside itself keeps its own lines, and the inner test's lines are listed under the inner test. If two tests run at the same time in one process, their lines can't be told apart. SweetPad then warns and names the directory that holds the output as it was written.
Lines written outside any test, such as the app's own logging at launch, are listed only when no test wrote anything. When some of them were written between two tests, a note says how many and where to read them.
Screenshots and UI dumps
sweetpad test attachments exports what a UI test recorded, meaning screenshots and view hierarchy dumps, as
files on disk:
sweetpad test attachments # everything, next to the result bundle
sweetpad test attachments --only-failures # only the failed tests' files
sweetpad test attachments --output-dir ./out # somewhere you choose
Without --output-dir the files land beside the retained result bundle, replacing the previous
export. Each test gets its own directory, and the listing names tests in the same
Target/Class/method form as the failure lines, so --only-testing takes any of them.
--only-failures keeps everything a failed test attached, including the crash log and screen
recording Xcode adds to a UI test whose app crashed, and the crash log it adds on macOS when a test
crashes its host app or xctest. Xcode's own marking can't be relied on for this: on an iOS simulator it marks
none of a UI test's files as belonging to a failure, not even the crash log, so SweetPad goes by
which tests failed. When nothing is left, the note says whether
the run had no failures or its failing tests attached nothing. The listing marks a failed test's
files with (failure), and -o json sets failure to true for them.
Tests in CI
Three things make a test run pipeline-friendly.
A JUnit report. --junit <path> writes one alongside the normal output, for whatever your CI
displays test results with:
sweetpad test --junit ./reports/tests.xml
The report has a <testcase> for every test, including the ones that passed or were skipped, with
the time each took. A test's classname is Target.Class and its name is the method, as in
classname="SweetpadCIAppTests.AppTests" name="testGreeting", so report viewers group the tests by
target and then by class. A failure's message is the first line of its first message, and the
<failure> element's text has every message the test recorded, in full. When the app went away
mid-test, the app terminated: line comes after them.
Inline annotations on GitHub. --gh-annotations emits GitHub Actions annotations, so failures
show up on the diff instead of only in the log:
sweetpad test --gh-annotations
No prompts. --non-interactive turns a missing scheme or destination into an error rather than a
question. SweetPad enables it automatically when it detects a CI environment, so you rarely have to
pass it. A raw specifier is still worth pinning, since fuzzy name matching against whatever
simulators the runner happens to have is a liability:
sweetpad test --destination 'platform=iOS Simulator,name=iPhone 16 Pro' --junit ./reports/tests.xml
--result-bundle <path> puts the .xcresult somewhere you control, which is what you want when the
job archives it as a build artifact. Use it rather than -- -resultBundlePath, which sweetpad test
refuses because it passes its own.
For the machine-readable form, -o json returns the run as a single envelope and -o ndjson streams
one event per line as tests finish. In both cases a successful envelope means the command ran, and the
pass/fail counts are inside the payload, under data.passed.
Each finished test streams as {"event":"test","status":"passed","name":…}, with failed or
skipped in place of passed. XCTest and Swift Testing tests both stream, in serial and parallel
runs.
Scripts and CI has the whole automation surface, including a workflow file that does the above.
Where the tests run
Tests use the same destination machinery as everything else, so --on works the way it does for
builds:
sweetpad test --on "iPhone 16 Pro"
sweetpad test --on booted
sweetpad test --on mac
sweetpad test --mac # the same thing
SweetPad keeps a separate remembered destination for testing, so you can develop against one simulator and test against another without re-answering the question each time. Set it with:
sweetpad context select --testing
See Destinations and devices for the rest of the story.