Engineering

iosTest needs a booted simulator, and CI does not have one by default

The native test binary runs on a device, so a missing simulator reads like a toolchain problem.

MobileByteSensei 8 min read
kmptestingci

TL;DR

jvmTest runs in the Gradle daemon's JVM. iosSimulatorArm64Test compiles a native executable and runs it on a booted simulator through xcrun simctl. If no simulator matching the configured device is booted, the task fails — and the message talks about the toolchain rather than about a missing runtime.

So the shared tests you wrote to get multi-target coverage silently do not run on iOS in CI, which is the one target they were most needed for. The task is often skipped rather than failed, so the build is green.

Pin the device name, boot it explicitly, and assert that the iOS test task actually ran rather than trusting a green build.

The case this post is about

A shared module with real commonTest coverage, and CI configured the obvious way:

# .github/workflows/ci.yml
- run: ./gradlew allTests
// build.gradle.kts
kotlin {
    iosArm64()
    iosSimulatorArm64()
    jvm("desktop")
    androidTarget()
}

Green builds for weeks. Then a Float.toString() difference reached production on iOS — the exact class of bug commonTest on multiple targets exists to catch, from a test suite that had been green the whole time.

The symptom

Three findings, and the first one is the reason this went unnoticed.

The iOS test task was being skipped, not failed. In the build output, between hundreds of lines:

> Task :shared:iosSimulatorArm64Test SKIPPED
> Task :shared:iosArm64Test SKIPPED

allTests completed, exit code 0, and two of four targets never executed a single test. A skipped task is not a failure, and nothing summarised the difference.

When it did run, the failure named the wrong thing. On a machine with Xcode but no booted device:

> Task :shared:iosSimulatorArm64Test FAILED
  Could not find a simulator with name 'iPhone 15'
  …
  Execution failed: xcrun simctl spawn … exited with code 164

Exit code 164 and an xcrun invocation reads as a broken Xcode install. Two engineers reinstalled command-line tools before anyone checked simctl list.

And iosArm64Test cannot run at all. It targets a physical device. On CI there is no device, so the task has nothing to run on — which is correct behaviour and looks identical to the simulator problem in the log.

Why the obvious fix failed

"allTests runs all the tests." It runs all the test tasks that are runnable. A task with no available device is skipped, and allTests reports success. The name is doing a lot of work here.

"Install Xcode on the runner." Already installed — macos-14 runners ship it. The missing piece was a booted simulator, which is a separate thing from the toolchain.

"Reinstall the command-line tools." The 164 exit code and xcrun in the message make this irresistible. It changed nothing, twice.

"Add --continue so a failure does not stop the build." Makes it worse: now genuine iOS test failures also do not fail CI.

"Skip iOS tests in CI and run them locally." What we did for a week. Local runs happen when someone remembers, which is never for the target nobody develops on.

The actual mechanism

A Kotlin/Native test task is a three-step pipeline, and only the first step resembles a JVM test run.

Compile a test executable. linkDebugTestIosSimulatorArm64 produces a real native binary containing your tests and a test runner.

Find a device. The task resolves a simulator by name — from kotlin.native.cocoapods settings, or the Kotlin plugin's default, or an explicit device configuration. The name must match a device in simctl list exactly.

Spawn the binary on it. xcrun simctl spawn <udid> <binary> runs the executable inside the simulator runtime and parses its output.

Steps two and three are why the failure modes look odd:

No matching device → the task cannot run. Depending on configuration, that surfaces as a SKIPPED task or a failure referencing xcrun. Neither says "boot a simulator".

iosArm64Test targets hardware, so it has no simulator to fall back to and is skipped unconditionally in CI. That is correct and should be stated rather than discovered.

A Kotlin Native test task as three steps — link a native test executable, resolve a simulator by exact device name, and spawn the binary inside the simulator runtime via simctl — contrasted with a JVM test task that runs inside the Gradle daemon; with the note that an unmatched device name produces a skipped task and a green build rather than a failure

A native test task needs a runtime host, and a missing host is not a compile error. The default is to skip, so the absence of coverage is reported as success.

The sub-case that brings the bug back months after you fix it: the device name is matched exactly, and Xcode changes the device list. Pin "iPhone 15", take an Xcode update that ships the iOS 18.2 runtime, and the installed set becomes:

$ xcrun simctl list devices available | grep iPhone
    iPhone 16 (B2C1…) (Shutdown)
    iPhone 16 Pro (9F0A…) (Shutdown)

No iPhone 15. The build does not fail — step two finds no match, and you are back to a skipped task and a green tick, with the added cruelty that the configuration was correct when it was written and nobody changed it.

The defence is not a better name. It is to resolve the device at runtime and fail when the resolution comes back empty:

UDID=$(xcrun simctl list devices available -j \
  | jq -r '[.devices[][] | select(.name|startswith("iPhone"))][0].udid')
[ -n "$UDID" ] || { echo "no iPhone simulator available"; exit 1; }

Which is the same lesson as the main fix, applied to the thing doing the fixing: any step that can resolve to "nothing to do" has to distinguish that from "done", or it will eventually report the first as the second.

The fix

Pin the device, boot it, and assert the tasks ran.

Pin the simulator name in Gradle, so it is not an environment accident:

// build.gradle.kts
kotlin {
    iosSimulatorArm64 {
        testRuns.configureEach {
            // Must match `xcrun simctl list devices` exactly.
            deviceId = providers.gradleProperty("iosTestDevice").getOrElse("iPhone 16")
        }
    }
}

Boot it in CI before the test task, and wait for it to actually be ready:

- name: Boot a simulator for native tests
  run: |
    set -euo pipefail
    DEVICE="iPhone 16"
    UDID=$(xcrun simctl list devices available -j \
           | python3 -c "import json,sys;d=json.load(sys.stdin)['devices'];
print([x['udid'] for v in d.values() for x in v if x['name']=='$DEVICE'][0])")
    xcrun simctl boot "$UDID" || true          # already-booted is fine
    xcrun simctl bootstatus "$UDID" -b         # BLOCK until the runtime is ready
    xcrun simctl list devices booted

bootstatus -b is the line that matters. simctl boot returns immediately and the runtime takes several seconds; without the wait, the test task races the boot and fails intermittently — which is how a fixed CI job becomes a flaky one.

Then assert the tasks ran, rather than trusting the exit code. This is the actual fix for the original bug:

- name: Run tests and verify every target executed
  run: |
    ./gradlew allTests --continue | tee build-log.txt || true
    for t in iosSimulatorArm64Test jvmTest testDebugUnitTest; do
      if grep -qE "Task :shared:$t (SKIPPED|NO-SOURCE)" build-log.txt; then
        echo "❌ $t did not run"; exit 1
      fi
    done

A test suite that did not execute is now a red build. That single check is worth more than the simulator configuration, because it catches every future variant of "the tests quietly stopped running".

And be explicit about iosArm64Test, which genuinely cannot run without hardware:

// build.gradle.kts — the device target compiles; it does not test on CI.
// Simulator coverage is the CI contract; iosArm64 is covered by compilation only.

Documenting that distinction stops the next person from "fixing" a skip that is correct.

A better per-target summary also helps, because the default output buries this:

tasks.withType<AbstractTestTask>().configureEach {
    testLogging { events("skipped", "failed") }
    afterSuite(KotlinClosure2({ desc: TestDescriptor, r: TestResult ->
        if (desc.parent == null) println("${project.name}:${name} → ${r.testCount} tests, ${r.resultType}")
    }))
}

How we proved it

That tests were not running before. Reading the task outcome rather than the exit code:

$ ./gradlew allTests | grep -E 'Test (SKIPPED|FAILED)|Test$'
> Task :shared:iosSimulatorArm64Test SKIPPED
> Task :shared:iosArm64Test SKIPPED
$ echo $?
0

Exit code 0 with two targets skipped is the whole bug, in four lines.

That they run now, with a count:

$ ./gradlew allTests
shared:jvmTest                → 48 tests, SUCCESS
shared:testDebugUnitTest      → 48 tests, SUCCESS
shared:iosSimulatorArm64Test  → 48 tests, SUCCESS

Three suites of the same 48 shared tests. Before the fix, that third line did not exist.

That the guard catches a regression. Verified by removing the boot step:

$ (no simulator booted) ./gradlew allTests --continue | tee build-log.txt
> Task :shared:iosSimulatorArm64Test SKIPPED
$ ./check-tasks-ran.sh
❌ iosSimulatorArm64Test did not run
exit 1

And that the boot wait removes the flakiness — because the first version of this fix was itself intermittent:

without bootstatus -b:  20 CI runs → 6 failures ("Could not find a booted simulator")
with bootstatus -b:     20 CI runs → 0 failures

That table is the reason the wait is in the snippet. A fix that works most of the time reintroduces exactly the trust problem it was meant to solve.

One limit: this gives simulator coverage, not device coverage. iosArm64Test still does not run in CI, so anything that differs between simulator and hardware — and some things do — is covered by compilation only. That is a stated gap rather than a closed one.

What this generalizes to

A skipped check reports as success. This is the most dangerous default in any test system. Whenever coverage depends on an available resource — a device, a database, a browser, a GPU — the absence of that resource must fail, not skip. Assert that the check ran.

"Run all tests" commands run all runnable tests. The aggregate task is a convenience, not a contract. If you depend on specific coverage, name the tasks and verify them individually.

An error message naming the plumbing will send you to the plumbing. xcrun and exit code 164 cost two reinstalls. When a message points at a tool, check the tool's own view of the world — simctl list — before rebuilding it.

A fix that is intermittently correct is not a fix. Booting without waiting looked like it worked and failed 30% of the time, which would have re-taught the team to ignore iOS test failures. Measure a fix across enough runs to see its tail.

Keep reading

This is part of 100 Days of Kotlin Multiplatform — the boundaries, the sharp edges, and the things that only show up on the second target. Follow MobileByteSensei for the rest.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS