Multi-Scheme Test Execution#
tuist test runs one or more schemes sequentially through TestService. The core entry point is TestService.run(...), which generates the project, collects testable schemes, resolves a result bundle path, then dispatches execution to testSchemes(_:) β which iterates and calls testScheme(scheme:...) for each individual scheme.
Scheme Selection#
When no --scheme flag is passed, defaultSchemes(...) decides which schemes to run:
- Normal path β returns all workspace schemes collected via
BuildGraphInspector.workspaceSchemes. - Mixed-test split path β if any workspace scheme contains both hosted and host-less unit tests, the workspace aggregate is replaced by the individual generated project schemes that cover the same targets .
The mixing condition is detected by containsMixedHostedAndHostlessUnitTests(...). It walks each scheme's test targets and checks their graph dependencies: a target whose dependency canHostTests() (i.e., an app) is "hosted"; a .unitTests target with other dependencies but no host-capable parent is "host-less" .
An explicit tuist test --scheme <name> bypasses this logic entirely and always uses the named scheme as-is .
Why the Split Matters β xctest Bootstrap Crash#
Running a mixed aggregate scheme in Xcode 26.5+ causes the host-less bundle to crash at bootstrap:
"Early unexpected exit, operation never finished bootstrapping β no restart will be attempted."
The fix (PR fix(cli): isolate hostless tests in workspace schemes) confirmed that running each generated project scheme separately (App, then Feature) reliably passes where the aggregate Sample-Workspace scheme fails .
When the split path is active, --test-targets / --skip-test-targets are filtered per-scheme so a request for FeatureTests does not accidentally also run AppTests .
Result Bundle Path#
resultBundlePath(...) resolves which .xcresult path is used:
- No
fullHandleconfigured β uses whatever--result-bundle-paththe user passed (may benil). fullHandlepresent (cloud project) β uses the user-passed path, or falls back to a Tuist-managed path inside the run-scoped cache directory:<cacheDir>/runs/<runId>/ResultBundle.
After all schemes finish, copyResultBundlePathIfNeeded(...) copies the .xcresult file from the run-scoped path into the user-supplied destination (if they differ), ensuring the result bundle is always accessible at the expected location .
Because multiple scheme invocations share the same resultBundlePath within one tuist test run, subsequent invocations overwrite the previous .xcresult. This is by design β the final bundle reflects the last scheme executed.
Per-Scheme Filtering in testSchemes#
Inside testSchemes(_:), before calling testScheme, each scheme's test-action target names are extracted and used to narrow the testTargets list. A scheme with no overlap against --test-targets is skipped entirely . This prevents redundant xcodebuild invocations when running with a filtered target list across many schemes.
Key Files & References#
| File | Role |
|---|---|
TestService.swift | All multi-scheme orchestration, scheme selection, result bundle management |
| PR #11225 | Fix: isolate host-less tests from hosted tests via project-scheme splitting |
defaultSchemes(...) | Chooses workspace vs. project schemes based on mixed-test detection |
containsMixedHostedAndHostlessUnitTests(...) | Detects hosted/host-less mix in a scheme |
resultBundlePath(...) | Resolves the active result bundle path for a run |
copyResultBundlePathIfNeeded(...) | Copies result bundle to user-supplied path after run |