An idb-based test runner for rules_apple iOS tests.
The stock rules_apple runner drives hosted (app-hosted) XCTest bundles with
xcodebuild test-without-building. xcodebuild is memory hungry and grows
quickly during a run, which limits how many simulators a CI host can drive
concurrently. On top of that, the stock runner reuses a single simulator per
(device, OS) pair, so running tests concurrently with --local_test_jobs=N
requires every team to hand-roll a simulator_creator that file-locks
simulators and cleans the lock up in shell traps.
rules_idb replaces only the run phase: bundling, code signing, and
providers all still come from rules_apple. It is a drop-in runner for
ios_unit_test and ios_ui_test, and it is fully self-contained: the idb
client ships vendored (on a hermetic python toolchain) and the prebuilt
idb_companion is fetched from this repository's releases. The only host
requirement is Xcode.
# MODULE.bazel
bazel_dep(name = "rules_idb", version = "0.2.1")
git_override( # until rules_idb is published to the Bazel Central Registry
module_name = "rules_idb",
remote = "https://github.com/erneestoc/rules_idb.git",
tag = "v0.2.1",
)# BUILD.bazel
load("@rules_idb//idb:idb_test_runner.bzl", "ios_idb_test_runner")
ios_idb_test_runner(
name = "idb_runner",
device_type = "iPhone 17 Pro", # optional; defaults to newest iPhone
os_version = "", # optional; defaults to newest iOS runtime
)
ios_unit_test(
name = "HostedTests",
minimum_os_version = "16.0",
runner = ":idb_runner",
test_host = ":HostApp",
deps = [":HostedTestsLib"],
)Or use the predefined @rules_idb//idb:default_runner. For coverage, add
coverage --experimental_use_llvm_covmap to your .bazelrc.
- Much lower harness memory than xcodebuild. Tests are installed and run
through
idb/idb_companion(built on FBSimulatorControl) instead ofxcodebuild test-without-building. Each test action runs its ownidb_companionon a private unix socket, so harness memory is bounded, isolated, and fully reclaimed when the action ends. See benchmark/RESULTS.md for measurements on this repository's example suite. --local_test_jobs=Nout of the box. Every concurrently running test action acquires its own simulator from a per-(device, OS) pool. Slots are guarded by kernelflock()locks held on a file descriptor owned by the test process, so the kernel releases them automatically when the test exits — even onSIGKILL. No lock files to clean up, no traps, no customsimulator_creator.- Warm simulator reuse. Pool simulators stay booted between runs (named
rules_idb.<pool>.<slot>), so repeat runs skip simulator boot entirely. Setshutdown_simulator_after_test = True(orRULES_IDB_SHUTDOWN_SIMULATOR=1) to trade speed for idle memory. - Bazel-native reporting. The runner parses idb's structured output,
fails the action on test failures (idb itself exits 0), writes a JUnit
XML_OUTPUT_FILEfor Bazel, honors--test_filter(Class/testMethod,-Class/testToSkip), forwards--test_envvariables into the hosted process, and fails when zero tests ran. - UI tests.
ios_ui_testtargets work: the runner assembles an XCTRunner host app from Xcode's agent template and drives it throughidb xctest run ui. - Coverage.
bazel coverageproduces standard lcov output (idb pulls the raw.profrawfiles; the runner merges and exports them withllvm-cov, same as the stock runner). Requirescoverage --experimental_use_llvm_covmapin your.bazelrc. - Random test ordering via
random = True(XCTestConfiguration.testExecutionOrdering, like-test-iterations' era xctestrun key; requires a test host). - Sanitizers.
--features=asan(and friends) work: sanitizer runtimes found in the test bundle are appended to the test host'sDYLD_INSERT_LIBRARIESalongside idb's test shim. pre_action/post_actionhooks with the same environment contract as the stock runner (SIMULATOR_UDID,TEST_EXIT_CODE,TEST_LOG_FILE, andpost_action_determines_exit_code).- Pluggable simulator provisioning. Teams with an existing
simulator_creatorcan plug it in unchanged viacreate_simulator_action/clean_up_simulator_action(same env contract as rules_apple); the built-in pool remains the default.
Just Xcode. Nothing to brew install, no python setup, no PATH plumbing:
- the idb python client is vendored in this repository
(
third_party/idb_client, pinned facebook/idb commit) and runs on a hermetic python 3.12 toolchain via rules_python; - the
idb_companionbinary (plus simulator shims) is prebuilt by this repository's release pipeline from the same pinned commit + patches/, and downloaded sha256-verified by a module extension. Each release's notes record the idb commit, patch, and Xcode version it was built and verified with (see RELEASING.md).
Why not upstream binaries? The 2022-era releases are unusable on modern
machines (the bottled companion misdetects Apple Silicon simulators as
x86_64; the PyPI client crashes a current companion's install RPC), and
Meta's open-source build of main needed the fixes carried in
patches/. docs/BUILDING_IDB.md
documents building the toolchain yourself; point
RULES_IDB_IDB_PATH/RULES_IDB_COMPANION_PATH at the result to override
the bundled binaries.
| Attribute | Default | Description |
|---|---|---|
device_type |
newest iPhone | xcrun simctl list devicetypes name |
os_version |
newest iOS | xcrun simctl list runtimes version |
pool_size |
0 (on demand) |
max simulators per pool; concurrency already bounded by --local_test_jobs. Idle residue bounded by RULES_IDB_WARM_POOL_SIZE (default: half the CPU cores, floor 4, stay booted; each ≈ 60-100 processes). Keep it ≥ your --local_test_jobs: a lower cap makes sustained wide runs re-boot trimmed simulators every pass (measured: 2-4× slower and flaky) |
max_concurrent_boots |
0 = auto (ncpu/2) |
machine-wide cap on simultaneous simulator creates/boots |
random |
False |
run tests in random order (requires test host) |
shutdown_simulator_after_test |
False |
shut simulator down after each test |
idb_path |
idb |
path to the idb client |
pre_action / post_action |
none | hook binaries around test execution |
post_action_determines_exit_code |
False |
post_action exit code wins |
create_simulator_action / clean_up_simulator_action |
built-in pool | custom simulator provisioning binaries |
Runtime environment overrides: RULES_IDB_IDB_PATH,
RULES_IDB_COMPANION_PATH, RULES_IDB_POOL_DIR, RULES_IDB_POOL_SIZE,
RULES_IDB_SHUTDOWN_SIMULATOR, RULES_IDB_COLLECT_LOGS,
DEBUG_IDB_TEST_RUNNER.
$(getconf DARWIN_USER_TEMP_DIR)rules_idb_pool/<device>_<os>/slot-N.lock
(The Darwin per-user temp dir is used instead of $HOME because Bazel gives
every test action a private $HOME; the pool must be shared across actions.)
- A test action opens
slot-0.lockand tries a non-blocking exclusiveflock(). If the lock is held (another test is using slot 0), it moves on toslot-1, and so on. Withpool_size > 0it wraps around and retries instead of growing past the cap. - The slot maps to a simulator named
rules_idb.<pool>.<slot>, created on first use and booted withsimctl bootstatus -b. - The
flock()is held by the test runner's shell process for the entire run. When the process exits — success, failure, timeout, orSIGKILL— the kernel drops the lock and the slot (and its warm simulator) is immediately reusable. - A dedicated
idb_companionfor the acquired simulator is started on a private unix socket and torn down when the action finishes. Long-lived shared companions are deliberately avoided: they cache installed test bundle descriptors by bundle id (stale across Bazel's ephemeral staging dirs, and colliding when targets share a bundle id), and concurrent client-managed spawns race on idb's shared companion registry.
Because Bazel caps concurrent test actions at --local_test_jobs, the pool
never grows beyond that number of simulators.
Simulator creation and boots (and only those — warm simulators are
unaffected) are gated
machine-wide to 4 concurrent by default; tune with the
max_concurrent_boots attribute or RULES_IDB_MAX_CONCURRENT_BOOTS. The
optimum is machine-dependent — on an M4 Max, booting 4 simulators took 13s
at cap 4 vs 31s serialized, so cap boots only as hard as your hardware
requires (low-memory CI agents may want 2).
To start warm — e.g. at CI-agent startup or before a big local run:
bazel run @rules_idb//tools:preboot -- 4 # default pool
bazel run @rules_idb//tools:preboot -- 4 --device "iPhone 17 Pro" # named poolpreboot is a desired-state command: it creates/boots the N pool
simulators that are missing (verifying that an existing simulator's
runtime and device type actually match the request, recreating it if not),
skips ones already booted, and shuts down any booted rules_idb.*
simulator outside the requested set, so you end with exactly N booted. It
never touches simulators it didn't name, and it is safe to run next to
live tests: any simulator whose pool slot is claimed by a running test
action is skipped, never yanked. Use --no-reconcile to only boot.
Pool simulators are created on demand (one per concurrency slot actually used, per device/OS pool) and then reused indefinitely — repeat runs never create more. They are intentionally left booted so warm runs skip the ~30s boot; that idle RAM is the trade. Your options:
tools/clean_simulators.sh— shut allrules_idb.*simulators down (keeps them for warm reuse);--deleteremoves them entirely.shutdown_simulator_after_test = Trueon the runner (orRULES_IDB_SHUTDOWN_SIMULATOR=1at test time) — every run shuts its simulator down afterwards; right choice for RAM-constrained laptops, costs a boot per cold run.pool_size = N— hard-cap how many simulators a pool may create.
./benchmark/run_benchmark.shRuns the identical hosted test suite through both runners, single and with
--local_test_jobs=4, sampling host-side harness RSS (xcodebuild /
XCBBuildService vs idb / idb_companion) every 0.5s. See
benchmark/RESULTS.md.
Add to your .bazelrc:
build --@rules_apple//apple/build_settings:use_tree_artifacts_outputs
This makes rules_apple output bundles as directories instead of archives,
so the runner stages them with APFS clonefile (copy-on-write) instead of
unzipping — measured ~1.2s faster per test action with 800 MB of bundles
on NVMe, and the gap grows with compressible many-file bundles and slower
CI disks. The runner prints a hint when it detects archive staging. Also
see the boot-concurrency and preboot sections above; simulator installs
are already copy-on-write, so bundle size otherwise barely affects the
run phase.
- Device (non-simulator) testing is out of scope.
- xcresult bundles are not produced; the runner emits JUnit XML and idb log output instead.
- x86_64 test bundles on arm64 hosts (Rosetta) are untested, and the bundled companion is arm64-only (the runner fails fast with instructions on x86_64 hosts).
XCTSkip-ped tests do not fail the run, but are reported as passed rather than skipped (the idb protocol has no skipped status yet).shard_countis supported for hosted and logic XCTest bundles (the runner lists the bundle's tests through the companion and runs each shard's deterministic slice;--test_filtercomposes). UI tests cannot be sharded, and bundles containing Swift Testing (@Test) tests are rejected with an error: those tests cannot be enumerated for partitioning, so every shard would silently skip them. The same applies to--test_filteron a bundle that mixes XCTest and Swift Testing: filters only match the XCTest tests.
Swift Testing (@Test) is supported: swift-testing tests execute, report
per-test results, and their failures fail the target (validated
continuously by tests/validate.sh).