Run XCTest suites
lim xcode test builds your scheme's test targets on a remote Xcode sandbox, runs them on an attached cloud iOS simulator (unit and UI targets alike), and streams one line per test case as it finishes. It exits non-zero when any test fails, so it drops straight into CI. When you need more than pass and fail, the same command saves the run's .xcresult result bundle, with code coverage.
Everything about syncing from Build with Xcode applies here too: XcodeGen projects, ignore and include patterns, additional files, and the sync cache.
Run the suite
From the project directory, run the tests of the default scheme, or name the scheme explicitly:
lim xcode test .
lim xcode test ./MyProject --scheme MyAppThe command reuses your last simulator-backed Xcode target, or creates and attaches the sandbox and simulator it needs, so repeat runs skip provisioning and start with warm build state. The scheme must have a test action configured; shared schemes exported by Xcode have one whenever the project has test targets.
--xcode-version 27 builds the test targets with another installed Xcode (27 selects that major's newest GA release, 27.1 selects that exact version). The simulator keeps the fleet's default runtime, so the run warns that runtime-dependent failures are possible and then proceeds. See Xcode and tool versions.
The output streams the build log first, then one line per case and a closing summary:
PASS LimFixtureTests.CartModelTests/testDiscountAppliesOverThreshold (12ms)
FAIL LimFixtureTests.LegacyPricingTests/testLegacyRounding (31ms)
LimFixtureTests/LegacyPricingTests.swift:27: XCTAssertEqual failed: ("$4.50") is not equal to ("$5.00")
5 passed, 1 failedSwift test classes are reported module-qualified (Module.Class), Objective-C classes bare. A failed case shows its first failure, prefixed with the source file and line where the test recorded it. The file is relative to the synced project when it is inside it. A failure without a location, such as a crash, shows only the message.
Choose the simulator
The simulator a run creates is an iPhone on iOS 26 unless you choose otherwise. --model picks the device model (iphone, iphone-duo, or ipad) and --os-version picks the iOS major version (26 or 27):
lim xcode test . --model ipad --os-version 27iPhone and iPad run iOS 26 and 27, and iPhone Duo runs 27. Either flag makes the run create a fresh sandbox and simulator instead of reusing your last ones, because a reused simulator could be any device. Neither flag can be combined with --id, --build-only, or --no-create.
To keep warm build state across repeated runs on the same device, create the pair once and pass the Xcode sandbox's ID on each run:
lim ios create --model ipad --os-version 27 --xcode --no-open
lim xcode test . --id <xcode-instance-ID>Select tests
Pass xcodebuild's identifier format: Target[/Class[/method]] for XCTest, and Target/Suite[/test()] for Swift Testing. Repeat a flag for several entries; --only-testing and --skip-testing are mutually exclusive:
# Only one XCTest method
lim xcode test . --only-testing MyAppTests/LoginTests/testValidLogin
# One Swift Testing suite, including its nested suites
lim xcode test . --only-testing MyAppTests/CartSuite
# One Swift Testing function
lim xcode test . --only-testing 'MyAppTests/CartSuite/addsItem()'
# Everything except the UI target
lim xcode test . --skip-testing MyAppUITestsA Swift Testing function needs its parentheses, including argument labels for a parameterized test, such as MyAppTests/CartSuite/addsItems(count:). As in xcodebuild, a selection that matches no test runs zero cases and still passes, so check the counts in the summary when you gate CI on a selection.
A bare target name selects or skips that whole target. Targets named by no --only-testing entry are skipped entirely. An entry naming a target the build did not produce fails the run instead of silently running everything.
Launch settings and attachments
Limrun preserves the launch arguments and environment recorded in the .xctestrun manifest, and keeps test-host and UI app settings separate: CommandLineArguments and EnvironmentVariables configure the test runner, while UITargetAppCommandLineArguments and UITargetAppEnvironmentVariables configure the application under UI test. Arguments containing spaces stay single arguments.
Every enabled test plan configuration runs, including configurations that repeat the same test targets. XCTest activities are enabled, so tests can use XCTContext.runActivity and add XCTAttachment objects. The streamed results contain case outcomes and summaries. To get the attachments, save the result bundle.
Save a result bundle and coverage
To inspect a run in depth or pass it to your reporting tools, add --result-bundle with a local path that does not exist yet. The tests then run through Apple's xcodebuild, and the CLI downloads the .xcresult bundle to that path, also when tests fail. Add --coverage to build with code coverage and collect it into the bundle:
lim xcode test . --result-bundle Tests.xcresult --coverageThe per-case lines and the exit code stay the same. A test run with --coverage also needs --result-bundle, because the bundle is where the coverage data lives. Both flags need lim 0.53.0 or later.
The bundle holds each test's result, the attachments your tests add, and for a failed UI test the app's UI hierarchy. It does not hold a screen recording of a failed test.
Read the bundle with the tools that ship with Xcode 16.3 or later. On a Mac, run them on the downloaded bundle:
# Test counts and failures
xcrun xcresulttool get test-results summary --path Tests.xcresult
# Line coverage per target and file
xcrun xccov view --report --json Tests.xcresult
# Screenshots and files the tests attached
xcrun xcresulttool export attachments --path Tests.xcresult --output-path attachmentsThese tools exist only on macOS. On Linux, run them on the Xcode sandbox.
Get JSON results
--json streams the raw per-case events as NDJSON and ends with an exit record, for piping into your own tooling:
lim xcode test . --json > results.ndjsonEach line is one event:
{"type":"case","testClass":"MyAppTests.LoginTests","method":"testValidLogin","passed":true,"durationMs":12}
{"type":"case","testClass":"MyAppTests.LoginTests","method":"testExpiredToken","passed":false,"durationMs":31,"failureMessage":"XCTAssertEqual failed: ...","failureFile":"MyAppTests/LoginTests.swift","failureLine":42}
{"type":"summary","passed":1,"failed":1,"planFinished":true}
{"exitCode":1}A missing summary event means the run died partway. planFinished: false means the suite started but did not complete its plan. With --result-bundle, the exit record also carries resultBundle, the absolute local path of the downloaded bundle.
Build without running
--build-only compiles the test targets on a plain sandbox without acquiring a simulator. The products stay on the sandbox for a later run:
lim xcode test . --build-onlyWith --coverage, the build produces products with code coverage for a rerun without building.
Rerun tests without building
--without-building reruns the tests the Xcode sandbox last built for testing, and needs lim 0.53.0 or later. It skips the sync and the build, so it takes no project path and rejects the build, sync, and simulator flags. It accepts the test selection, --result-bundle, and --coverage. Use it to rerun a failed test, or to split a suite into one run and one result bundle per test class:
# Build once with coverage and run the first class
lim xcode test . --only-testing MyAppUITests/LoginTests --result-bundle Login.xcresult --coverage
# Rerun the same products for the next class
lim xcode test --without-building --only-testing MyAppUITests/CartTests --result-bundle Cart.xcresult --coverageA rerun never creates a sandbox. It runs on your last Xcode sandbox, or on the one --id names, with the Xcode version that built the tests. The tests still need a simulator: without --id, the rerun attaches a new one when your last sandbox has none, and with --id the sandbox must already have one attached.
A rerun fails when the sandbox has no test products to rerun: nothing is built for testing yet, or a later build, another synced project, or another Xcode version discarded them. --coverage on a rerun needs products built with --coverage.
One run, build included, can take up to one hour. Split a suite that needs longer into several reruns.
To get one report for the whole suite, merge the per-class bundles. On a Mac, merge the downloaded bundles:
xcrun xcresulttool merge Login.xcresult Cart.xcresult --output-path All.xcresultRead result bundles without a Mac
xcresulttool and xccov exist only on macOS. On Linux, run them on the Xcode sandbox with lim xcode run. That command first syncs the current directory to the sandbox. So save the bundle inside your project directory, and the tool finds it on the sandbox at the same relative path:
lim xcode test . --result-bundle Tests.xcresult --coverage
# Test counts and failures
lim xcode run --quiet --include '\.xcresult' -- \
xcrun xcresulttool get test-results summary --path Tests.xcresult > summary.json
# Line coverage per target and file
lim xcode run --quiet --include '\.xcresult' -- \
xcrun xccov view --report --json Tests.xcresult > coverage.jsonBoth flags matter here:
--quietkeeps the CLI's own progress lines out of stdout, so each file holds only the tool's output. The command exits with the tool's exit code.--include '\.xcresult'syncs the bundle even when a.gitignoreexcludes it, as*.xcresultdoes in many projects. Pass it on every call, because a sync without it removes the uploaded bundle from the sandbox again. When the bundle sits in an ignored directory, the pattern must match that directory too, such as--include '^out(/|$)'forout/Tests.xcresult.
On the sandbox, xccov also prints a CoreSimulatorService connection became invalid line to stderr. The report on stdout is complete, so ignore that line.
Merge per-class bundles on the sandbox the same way. A sync removes only files that your machine uploaded earlier, so the merged bundle, which the sandbox created, stays in place. Read it with --no-sync, because nothing new needs to sync:
lim xcode run --quiet --include '\.xcresult' -- \
'rm -rf All.xcresult && xcrun xcresulttool merge Login.xcresult Cart.xcresult --output-path All.xcresult'
lim xcode run --quiet --no-sync -- xcrun xccov view --report --json All.xcresult > coverage.jsonTo keep what a tool wrote on the sandbox, such as the merged bundle or the exported attachments, zip it there and download the zip with lim xcode pull-file:
# The merged bundle
lim xcode run --quiet --no-sync -- ditto -c -k All.xcresult All.xcresult.zip
lim xcode pull-file All.xcresult.zip
unzip -q All.xcresult.zip -d All.xcresult
# Screenshots and files the tests attached
lim xcode run --quiet --include '\.xcresult' -- \
'rm -rf test-attachments && xcrun xcresulttool export attachments --path Tests.xcresult --output-path test-attachments && ditto -c -k test-attachments test-attachments.zip'
lim xcode pull-file test-attachments.zip
unzip -q test-attachments.zip -d test-attachmentsWith the TypeScript SDK, the upload is not needed: unpack the sandbox's own copy of the bundle.
Run tests from the TypeScript SDK
The TypeScript SDK runs tests through xcodebuild with action: 'build-for-testing'. It does not create a simulator: the tests run only when a simulator is attached to the Xcode sandbox, and without one the call only compiles them. Attach one first, as in Install on a simulator. Per-case events stream through onXctestEvent, and the accumulated cases and summary land on the result:
await xcode.attachNewSimulator(); // or xcode.attachSimulator(iosInstance)
const result = await xcode.xcodebuild(
{ scheme: 'MyApp', sdk: 'iphonesimulator', action: 'build-for-testing' },
{ onXctestEvent: (event) => console.log(event) },
);
console.log(result.xctest?.summary); // { type: 'summary', passed, failed, planFinished }
console.log(result.exitCode); // non-zero when tests failonlyTesting and skipTesting go on the same settings object as scheme. runTests: false compiles the test targets without running them, like --build-only. xcode is the Xcode sandbox client from Build with Xcode.
resultBundle: true and coverage: true work like --result-bundle and --coverage, and need an attached simulator. The result then names the zipped bundle on the sandbox, and pullResultBundle downloads it and unpacks it into a local .xcresult directory that must not exist yet. The sandbox keeps only the bundle of its latest run, so pull it before you start the next one:
const result = await xcode.xcodebuild({
scheme: 'MyApp',
sdk: 'iphonesimulator',
action: 'build-for-testing',
resultBundle: true,
coverage: true,
});
if (result.xctest?.resultBundle) {
await xcode.pullResultBundle(result.xctest.resultBundle.path, './Tests.xcresult');
}action: 'test-without-building' reruns the tests the sandbox last built, like --without-building. It accepts only onlyTesting, skipTesting, resultBundle, and coverage:
const rerun = await xcode.xcodebuild({
action: 'test-without-building',
onlyTesting: ['MyAppUITests/CartTests'],
resultBundle: true,
coverage: true,
});To read a bundle without a Mac, skip the download: unpack the zip on the sandbox and run the Xcode tools there with run. This example unpacks the bundle into the synced workspace and prints the line coverage of each target:
const bundle = result.xctest?.resultBundle;
if (bundle) {
await xcode.run(`rm -rf Tests.xcresult && ditto -x -k '${bundle.path}' Tests.xcresult`);
const report = xcode.run('xcrun xccov view --report --json Tests.xcresult');
let json = '';
report.stdout.on('data', (line) => (json += line));
const { exitCode } = await report;
if (exitCode === 0) {
const coverage = JSON.parse(json);
for (const target of coverage.targets) {
console.log(target.name, target.lineCoverage); // 0 to 1
}
}
}stdout emits one line at a time without its newline, which JSON parsing does not need. The unpacked bundle stays in the workspace through later syncs and runs. To merge per-class bundles, unpack each rerun's bundle under its own name before the next run replaces it, then run xcresulttool merge on them as in Read result bundles without a Mac.
Result bundles and reruns need @limrun/api 0.70.1 or later.
Run in CI
- The exit code is the gate:
0when every selected test passes, non-zero otherwise, solim xcode test .works as a CI step with no output parsing. - To keep the evidence of a failed run, pass
--result-bundleand upload the bundle as a job artifact. The CLI writes it before it exits non-zero. - For reproducible runs on fresh instances, pass
--inactivity-timeout 30m. The run then creates its own sandbox and simulator instead of reusing yours, and they delete themselves once idle. This flag cannot be combined with--id.
GitHub Actions recipes shows how to set up the rest of a Linux CI job: install the CLI and pass LIM_API_KEY from a secret.
Next steps
Gate CI on your unit and UI tests by running them on a cloud iOS simulator, with a result line per test case and an optional result bundle.
Was this guide helpful?