- Proposal: SE-NNNN
- Authors: Paul LeMarquand
- Review Manager: TBD
- Status: Pitch
- Implementation: Add LLDB debugger support to swift-test command by plemarquand · Pull Request #9085 · swiftlang/swift-package-manager · GitHub
Hello,
swift run supports interactive debugging via the --debugger flag, which builds the executable and launches it inside the LLDB that is bundled with the Swift Toolchain.
However, there is no equivalent flag for swift test . Developers who want to step through a failing test today have to figure out where the test binary is built, invoke LLDB by hand, and pass the right arguments to the built testing binary. They then have to repeat the process for each testing library and each test product in their package.
This pitch proposes a --debugger flag for swift test that builds the test products and drops the developer into an LLDB session pre-configured for both XCTest and Swift Testing, with target switching for multi-product and multi-test-library packages.
Motivation
Debugging a failing Swift test from the command line is currently a manual, error-prone exercise. The user must:
- Build the test product (
swift build --build-tests). - Locate the correct test bundle or test executable inside
.build/. - Construct the right argument list:
--test-bundle-path,--testing-library swift-testing,--enable-swift-testing, XCTest filter selectors, etc. - Launch LLDB, set breakpoints, and finally
run.
This process is difficult and poorly documented. Editors like Xcode and VS Code paper over this for IDE-driven debugging, but there is no simple method for debugging on the command line.
swift run already solved this problem for executables with swift run --debugger , which builds the product and exec s LLDB attached to it. Bringing the same ability to swift test is the natural extension, and unblocks a workflow that today requires either an IDE or a deep understanding of SwiftPM internals.
Proposed solution
We propose adding a new --debugger flag to swift test . When set, SwiftPM builds the test products as it normally would, then exec s LLDB instead of running the test process. The LLDB session is pre-configured with one target per (test product, testing library) pair, with the right executable and run-arguments wired up for each. In addition a failbreak command alias is registered that sets breakpoints on the symbols called for XCTest and/or Swift Testing test failures.
$ swift test --debugger
# ... build output ...
(lldb) failbreak
(lldb) run
The flag composes with existing test selection options: --filter , --skip , --enable-swift-testing , --enable-xctest , etc. all narrow which targets are registered in the LLDB session, so a developer who runs swift test --debugger --filter MyFailingSuite only sees the relevant binary in LLDB.
Detailed design
Configuring LLDB targets
A swift test --debugger session is described by a DebuggableTestSession containing one entry per (test product, testing library) pair that survives the user's --filter and --skip selectors. Before exec ing LLDB, DebugTestRunner writes a temporary command file that, for each entry, issues:
target create -l "<productName> (<library>)" "<executable>"— registers a new LLDB target and gives it a human-readable label so the user can tellLibATests (XCTest)fromLibATests (Swift Testing)intarget listoutput.settings clear target.run-argsfollowed by onesettings append target.run-args "<arg>"per argument. This uses the same launch argumentsswift testwould have used to run that product (--test-bundle-path,--testing-library, XCTest selectors, etc.).target modules add "<binaryPath>". This explicitly loads the test binary's debug symbols. On macOS this matters because the active executable isxctestorswift-testing-helper, not the test binary itself, so symbols would otherwise only be picked up after the dynamic loader maps the bundle.
LLDB is then invoked with -s <command-file> , which replays these commands at startup. The user lands at an (lldb) prompt with every target already registered, run-args set, and symbols loaded — ready to failbreak and run .
All path components and product names are escaped before being interpolated into the quoted LLDB commands so that Windows paths, names containing spaces, and names containing \ or " cannot break the command file or inject additional commands.
Target switching
When a session contains more than one target, a Python helper script (target_switcher.py) is written to the same scratch directory and loaded into the LLDB session via command script import . The script:
- Registers an event listener on each target's process and watches for exit events. When a target's process exits because test run completed naturally the script advances the active target via
target select <next>so the nextrundrives the next test binary. - Mirrors user-set breakpoints across every registered target. A breakpoint set against
MyTests.swift:42while the XCTest target is active is replayed against the Swift Testing target before its first run, and vice versa, so the user does not have to remember which target is active when setting breakpoints. The script de-duplicates by file/line and by symbol name so re-syncing is idempotent.
For single-target sessions the script is not loaded.
Per-platform executable selection
The executable handed to LLDB depends on both the testing library and the host platform:
| Library | macOS | Linux | Windows |
|---|---|---|---|
| XCTest | xctest <bundle> |
test binary | test binary |
| Swift Testing | swift-testing-helper --test-bundle-path <binary> |
test binary | test binary |
These are the same launch shapes that swift test uses today when running tests normally.
Multi-target sessions
A package can produce multiple test products (e.g. LibATests and LibBTests ), and a single product can host both XCTest and Swift Testing tests, which build into separate executables. To support all of these in one debugging session, DebugTestRunner registers each (product, library) pair as a distinct LLDB target and then loads a small Python helper script into the LLDB session. The script:
- Listens for the active target's process to exit (e.g. when a test run ends or the user types
exit). - Switches LLDB to the next registered target so the user can drive the next batch of tests without leaving the session.
- Synchronizes user-set breakpoints across targets so that a breakpoint set while debugging the XCTest target also fires when the Swift Testing target runs, and vice versa.
For single-target sessions (the common case) the Python script is not loaded, keeping startup minimal.
Failure breakpoints
A failbreak command alias is registered in the LLDB session that sets breakpoints on the appropriate failure entry points for whichever testing libraries are present:
| Platform | XCTest | Swift Testing |
|---|---|---|
| macOS | _XCTFailureBreakpoint |
Testing.failureBreakpoint() |
| Linux | XCTest.XCTestCase.recordFailure |
Testing.failureBreakpoint |
| Windows | XCTest.XCTestCase.recordFailure |
Testing.failureBreakpoint() in Testing.dll |
If the session contains both libraries, failbreak sets both. The user must run failbreak first themselves then run, and LLDB will pause when test assertion fails. The debugger will pause on the symbol defined in the table above, and the user can then display the stack trace and walk up to their failing assertion.
Process replacement
DebugTestRunner.run replaces the current swift-test process with LLDB via execv . LLDB inherits the test environment SwiftPM would have used to run the tests directly. Using execv means that the SwiftPM process doesn't have to act as an orchestrator that forwards signals and stdin/out/err back and forth between LLDB and the user.
Argument validation
--debugger is incompatible with several other flags. These are validated up-front so the user gets a clear error before any build work happens:
--configuration release: debugging requires debug symbols.--paralleland--num-workers: debugging requires a single process to attach to, and--parallelwill spawn--num-workersprocesses.--list-tests:swift test listalready exists for this.--show-codecov-path: coverage path printing is incompatible with an interactive session.
The validation logic is extracted as a static validateLLDBCompatibility function so it can be exercised directly by tests without spinning up a full command pipeline.
Impact on existing packages
This proposal is purely additive. Behaviour of swift test without --debugger is unchanged. No existing flags, subcommands, or output formats are modified. Packages that do not opt in see no difference. The flag has no effect on Package.swift authoring or on how tests are written.
Alternatives considered
A subcommand (swift test debug)
A subcommand was considered, mirroring swift test list . We chose a flag for parity with swift run --debugger.
Spawning LLDB as a child process
Rather than exec ing LLDB, SwiftPM could spawn it as a child process. This was rejected for the same reasons as swift run --debugger : child-process LLDB sessions have second-class terminal control, awkward signal handling, and a confusing process tree. exec matches the existing precedent of swift run and gives LLDB full ownership of the TTY.
Try it out
You can try the in progress implementation:
- Check out the PR here: Add LLDB debugger support to swift-test command by plemarquand · Pull Request #9085 · swiftlang/swift-package-manager · GitHub
- Build swift-package-manager with
swift build - Open the project whose tests you want to debug in your terminal
- Point to the built swift-test binary and run it with the
--debuggerflag~/work/swift-package-manager/.build/out/Products/Debug/swift-test --debugger
Future directions
Pre-set breakpoints
Today the user can type failbreak then run . A future iteration could modify the debugger flag to be an option that allows for failure breakpoints to be automatically attached, i.e --debugger break-on-failure (or similar). Additionally we could walk up the stack to user code automatically instead of pausing on the internal XCTest/Swift Testing symbols.
Test selection inside LLDB
Once the session is open, the user is on their own to re-issue run with different target.run-args. A small set of LLDB command aliases for filtering or re-running individual tests would smooth this out, e.g. a runtest <name> alias that rewrites target.run-args for the active target.