Llim.run

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 MyApp

The 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 failed

Swift 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 27

iPhone 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 MyAppUITests

A 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 --coverage

The 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 attachments

These 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.ndjson

Each 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-only

With --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 --coverage

A 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.xcresult

Read 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.json

Both flags matter here:

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

To 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-attachments

With 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 fail

onlyTesting 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

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

hammer

Build with Xcode

Sync rules, schemes, and build flags shared with lim xcode test.

flask-conical

Maestro

Run YAML UI flows against the same cloud simulators.

git-pull-request

PR previews

Pair the test run with a preview link on every pull request.