Skip to content

Commit 41c3aa6

Browse files
committed
Add LLDB debugger support to swift-test command
Introduces interactive debugging capabilities to `swift test` via a `--debugger` flag enabling developers to failing tests directly within LLDB. This adds debugging parity with `swift run --debugger` which allows users to debug their executables directly. When launching lldb this implenentation uses `execv()` to replace the `swift-test` process with LLDB. This approach avoids process hierarchy complications and ensures LLDB has full terminal control for interactive debugging features. When there is only one type of tests the debugger creates a single LLDB target configured specifically for that framework. For XCTest, this means targeting the test bundle with xctest as the executable, while Swift Testing targets the test binary directly with appropriate command-line arguments. When both testing frameworks have tests available we create multiple LLDB targets within a single debugging session. A Python script automatically switches targets as the executable exits, which lets users debug both types of tests spread across two executables in the same session. The Python script also maintains breakpoint persistence across target switches, allowing you to set a breakpoint for either test type no matter the active target. Finally, we add a `failbreak` command alias that sets breakpoint(s) that break on test failure, allowing users to automatically stop on failed tests. Issue: #8129
1 parent 06fcc03 commit 41c3aa6

File tree

12 files changed

+1209
-32
lines changed

12 files changed

+1209
-32
lines changed
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// swift-tools-version: 6.0
2+
import PackageDescription
3+
4+
let package = Package(
5+
name: "TestDebugging",
6+
targets: [
7+
.target(name: "TestDebugging"),
8+
.testTarget(name: "TestDebuggingTests", dependencies: ["TestDebugging"]),
9+
]
10+
)
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
public struct Calculator {
2+
public init() {}
3+
4+
public func add(_ a: Int, _ b: Int) -> Int {
5+
return a + b
6+
}
7+
8+
public func subtract(_ a: Int, _ b: Int) -> Int {
9+
return a - b
10+
}
11+
12+
public func multiply(_ a: Int, _ b: Int) -> Int {
13+
return a * b
14+
}
15+
16+
public func divide(_ a: Int, _ b: Int) -> Int {
17+
return a / b
18+
}
19+
20+
public func purposelyFail() -> Bool {
21+
return false
22+
}
23+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import XCTest
2+
import Testing
3+
@testable import TestDebugging
4+
5+
// MARK: - XCTest Suite
6+
final class XCTestCalculatorTests: XCTestCase {
7+
8+
func testAdditionPasses() {
9+
let calculator = Calculator()
10+
let result = calculator.add(2, 3)
11+
XCTAssertEqual(result, 5, "Addition should return 5 for 2 + 3")
12+
}
13+
14+
func testSubtractionFails() {
15+
let calculator = Calculator()
16+
let result = calculator.subtract(5, 3)
17+
XCTAssertEqual(result, 3, "This test is designed to fail - subtraction 5 - 3 should equal 2, not 3")
18+
}
19+
}
20+
21+
// MARK: - Swift Testing Suite
22+
@Test("Calculator Addition Works Correctly")
23+
func calculatorAdditionPasses() {
24+
let calculator = Calculator()
25+
let result = calculator.add(4, 6)
26+
#expect(result == 10, "Addition should return 10 for 4 + 6")
27+
}
28+
29+
@Test("Calculator Boolean Check Fails")
30+
func calculatorBooleanFails() {
31+
let calculator = Calculator()
32+
let result = calculator.purposelyFail()
33+
#expect(result == true, "This test is designed to fail - purposelyFail() should return false, not true")
34+
}

Sources/Commands/SwiftTestCommand.swift

Lines changed: 235 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,11 @@ struct TestCommandOptions: ParsableArguments {
216216
help: "Enable code coverage.")
217217
var enableCodeCoverage: Bool = false
218218

219+
/// Launch tests under LLDB debugger.
220+
@Flag(name: .customLong("debugger"),
221+
help: "Launch tests under LLDB debugger.")
222+
var shouldLaunchInLLDB: Bool = false
223+
219224
/// Configure test output.
220225
@Option(help: ArgumentHelp("", visibility: .hidden))
221226
public var testOutput: TestOutput = .default
@@ -280,8 +285,17 @@ public struct SwiftTestCommand: AsyncSwiftCommand {
280285

281286
var results = [TestRunner.Result]()
282287

288+
if options.shouldLaunchInLLDB {
289+
let result = try await runTestProductsWithLLDB(
290+
testProducts,
291+
productsBuildParameters: buildParameters,
292+
swiftCommandState: swiftCommandState
293+
)
294+
results.append(result)
295+
}
296+
283297
// Run XCTest.
284-
if options.testLibraryOptions.isEnabled(.xctest, swiftCommandState: swiftCommandState) {
298+
if !options.shouldLaunchInLLDB && options.testLibraryOptions.isEnabled(.xctest, swiftCommandState: swiftCommandState) {
285299
// Validate XCTest is available on Darwin-based systems. If it's not available and we're hitting this code
286300
// path, that means the developer must have explicitly passed --enable-xctest (or the toolchain is
287301
// corrupt, I suppose.)
@@ -351,7 +365,7 @@ public struct SwiftTestCommand: AsyncSwiftCommand {
351365
}
352366

353367
// Run Swift Testing (parallel or not, it has a single entry point.)
354-
if options.testLibraryOptions.isEnabled(.swiftTesting, swiftCommandState: swiftCommandState) {
368+
if !options.shouldLaunchInLLDB && options.testLibraryOptions.isEnabled(.swiftTesting, swiftCommandState: swiftCommandState) {
355369
lazy var testEntryPointPath = testProducts.lazy.compactMap(\.testEntryPointPath).first
356370
if options.testLibraryOptions.isExplicitlyEnabled(.swiftTesting, swiftCommandState: swiftCommandState) || testEntryPointPath == nil {
357371
results.append(
@@ -474,27 +488,200 @@ public struct SwiftTestCommand: AsyncSwiftCommand {
474488
}
475489
}
476490

491+
/// Runs test products under LLDB debugger for interactive debugging.
492+
///
493+
/// This method handles debugging for enabled testing libraries:
494+
/// 1. If both XCTest and Swift Testing are enabled, prompts user to choose or runs both in separate sessions
495+
/// 2. Validates that exactly one test product is available for debugging
496+
/// 3. Creates a DebugTestRunner and launches LLDB with the test binary
497+
///
498+
/// - Parameters:
499+
/// - testProducts: The built test products
500+
/// - productsBuildParameters: Build parameters for the products
501+
/// - swiftCommandState: The Swift command state
502+
/// - Returns: The test result (typically .success since LLDB takes over)
503+
private func runTestProductsWithLLDB(
504+
_ testProducts: [BuiltTestProduct],
505+
productsBuildParameters: BuildParameters,
506+
swiftCommandState: SwiftCommandState
507+
) async throws -> TestRunner.Result {
508+
// Validate that we have exactly one test product for debugging
509+
guard testProducts.count == 1 else {
510+
if testProducts.isEmpty {
511+
throw StringError("No test products found for debugging")
512+
} else {
513+
let productNames = testProducts.map { $0.productName }.joined(separator: ", ")
514+
throw StringError("Multiple test products found (\(productNames)). Specify a single target with --filter when using --debugger")
515+
}
516+
}
517+
518+
let testProduct = testProducts[0]
519+
let toolchain = try swiftCommandState.getTargetToolchain()
520+
521+
// Determine which testing libraries are enabled
522+
let xctestEnabled = options.testLibraryOptions.isEnabled(.xctest, swiftCommandState: swiftCommandState)
523+
let swiftTestingEnabled = options.testLibraryOptions.isEnabled(.swiftTesting, swiftCommandState: swiftCommandState) &&
524+
(options.testLibraryOptions.isExplicitlyEnabled(.swiftTesting, swiftCommandState: swiftCommandState) ||
525+
testProduct.testEntryPointPath == nil)
526+
527+
// Create a list of testing libraries to run in sequence, checking for actual tests
528+
var librariesToRun: [TestingLibrary] = []
529+
var skippedLibraries: [(TestingLibrary, String)] = []
530+
531+
// Only add XCTest if it's enabled AND has tests to run
532+
if xctestEnabled {
533+
// Always check for XCTest tests by getting test suites
534+
let testSuites = try TestingSupport.getTestSuites(
535+
in: testProducts,
536+
swiftCommandState: swiftCommandState,
537+
enableCodeCoverage: options.enableCodeCoverage,
538+
shouldSkipBuilding: options.sharedOptions.shouldSkipBuilding,
539+
experimentalTestOutput: options.enableExperimentalTestOutput,
540+
sanitizers: globalOptions.build.sanitizers
541+
)
542+
let filteredTests = try testSuites
543+
.filteredTests(specifier: options.testCaseSpecifier)
544+
.skippedTests(specifier: options.skippedTests(fileSystem: swiftCommandState.fileSystem))
545+
546+
if !filteredTests.isEmpty {
547+
librariesToRun.append(.xctest)
548+
} else {
549+
skippedLibraries.append((.xctest, "no XCTest tests found"))
550+
}
551+
}
552+
553+
if swiftTestingEnabled {
554+
librariesToRun.append(.swiftTesting)
555+
}
556+
557+
// Ensure we have at least one library to run
558+
guard !librariesToRun.isEmpty else {
559+
if !skippedLibraries.isEmpty {
560+
let skippedMessages = skippedLibraries.map { library, reason in
561+
let libraryName = library == .xctest ? "XCTest" : "Swift Testing"
562+
return "\(libraryName): \(reason)"
563+
}
564+
throw StringError("No testing libraries have tests to debug. Skipped: \(skippedMessages.joined(separator: ", "))")
565+
}
566+
throw StringError("No testing libraries are enabled for debugging")
567+
}
568+
569+
try await runTestLibrariesWithLLDB(
570+
testProduct: testProduct,
571+
target: DebuggableTestSession(
572+
targets: librariesToRun.map {
573+
DebuggableTestSession.Target(
574+
library: $0,
575+
additionalArgs: try additionalLLDBArguments(for: $0, testProducts: testProducts, swiftCommandState: swiftCommandState),
576+
bundlePath: testBundlePath(for: $0, testProduct: testProduct)
577+
)
578+
}
579+
),
580+
testProducts: testProducts,
581+
productsBuildParameters: productsBuildParameters,
582+
swiftCommandState: swiftCommandState,
583+
toolchain: toolchain
584+
)
585+
586+
// Clean up Python script file after all sessions complete
587+
// (Breakpoint file cleanup is handled by DebugTestRunner based on SessionState)
588+
if librariesToRun.count > 1 {
589+
let tempDir = try swiftCommandState.fileSystem.tempDirectory
590+
let pythonScriptFile = tempDir.appending("save_breakpoints.py")
591+
592+
if swiftCommandState.fileSystem.exists(pythonScriptFile) {
593+
try? swiftCommandState.fileSystem.removeFileTree(pythonScriptFile)
594+
}
595+
}
596+
597+
return .success
598+
}
599+
600+
private func additionalLLDBArguments(for library: TestingLibrary, testProducts: [BuiltTestProduct], swiftCommandState: SwiftCommandState) throws -> [String] {
601+
// Determine test binary path and arguments based on the testing library
602+
switch library {
603+
case .xctest:
604+
let (xctestArgs, _) = try xctestArgs(for: testProducts, swiftCommandState: swiftCommandState)
605+
return xctestArgs
606+
607+
case .swiftTesting:
608+
let commandLineArguments = CommandLine.arguments.dropFirst()
609+
var swiftTestingArgs = ["--testing-library", "swift-testing", "--enable-swift-testing"]
610+
611+
if let separatorIndex = commandLineArguments.firstIndex(of: "--") {
612+
// Only pass arguments after the "--" separator
613+
swiftTestingArgs += Array(commandLineArguments.dropFirst(separatorIndex + 1))
614+
}
615+
return swiftTestingArgs
616+
}
617+
}
618+
619+
private func testBundlePath(for library: TestingLibrary, testProduct: BuiltTestProduct) -> AbsolutePath {
620+
switch library {
621+
case .xctest:
622+
testProduct.bundlePath
623+
case .swiftTesting:
624+
testProduct.binaryPath
625+
}
626+
}
627+
628+
/// Runs a single testing library under LLDB debugger.
629+
///
630+
/// - Parameters:
631+
/// - testProduct: The test product to debug
632+
/// - library: The testing library to run
633+
/// - testProducts: All built test products (for XCTest args generation)
634+
/// - productsBuildParameters: Build parameters for the products
635+
/// - swiftCommandState: The Swift command state
636+
/// - toolchain: The toolchain to use
637+
/// - sessionState: The debugging session state for breakpoint persistence
638+
private func runTestLibrariesWithLLDB(
639+
testProduct: BuiltTestProduct,
640+
target: DebuggableTestSession,
641+
testProducts: [BuiltTestProduct],
642+
productsBuildParameters: BuildParameters,
643+
swiftCommandState: SwiftCommandState,
644+
toolchain: UserToolchain
645+
) async throws {
646+
// Create and launch the debug test runner
647+
let debugRunner = DebugTestRunner(
648+
target: target,
649+
buildParameters: productsBuildParameters,
650+
toolchain: toolchain,
651+
testEnv: try TestingSupport.constructTestEnvironment(
652+
toolchain: toolchain,
653+
destinationBuildParameters: productsBuildParameters,
654+
sanitizers: globalOptions.build.sanitizers,
655+
library: .xctest // TODO
656+
),
657+
cancellator: swiftCommandState.cancellator,
658+
fileSystem: swiftCommandState.fileSystem,
659+
observabilityScope: swiftCommandState.observabilityScope,
660+
verbose: globalOptions.logging.verbose
661+
)
662+
663+
// Launch LLDB using AsyncProcess with proper input/output forwarding
664+
try debugRunner.run()
665+
}
666+
477667
private func runTestProducts(
478668
_ testProducts: [BuiltTestProduct],
479669
additionalArguments: [String],
480670
productsBuildParameters: BuildParameters,
481671
swiftCommandState: SwiftCommandState,
482672
library: TestingLibrary
483673
) async throws -> TestRunner.Result {
484-
// Pass through all arguments from the command line to Swift Testing.
674+
// Pass through arguments that come after "--" to Swift Testing.
485675
var additionalArguments = additionalArguments
486676
if library == .swiftTesting {
487-
// Reconstruct the arguments list. If an xUnit path was specified, remove it.
488-
var commandLineArguments = [String]()
489-
var originalCommandLineArguments = CommandLine.arguments.dropFirst().makeIterator()
490-
while let arg = originalCommandLineArguments.next() {
491-
if arg == "--xunit-output" {
492-
_ = originalCommandLineArguments.next()
493-
} else {
494-
commandLineArguments.append(arg)
495-
}
677+
// Only pass arguments that come after the "--" separator to Swift Testing
678+
let allCommandLineArguments = CommandLine.arguments.dropFirst()
679+
680+
if let separatorIndex = allCommandLineArguments.firstIndex(of: "--") {
681+
// Only pass arguments after the "--" separator
682+
let testArguments = Array(allCommandLineArguments.dropFirst(separatorIndex + 1))
683+
additionalArguments += testArguments
496684
}
497-
additionalArguments += commandLineArguments
498685

499686
if var xunitPath = options.xUnitOutput {
500687
if options.testLibraryOptions.isEnabled(.xctest, swiftCommandState: swiftCommandState) {
@@ -667,6 +854,11 @@ public struct SwiftTestCommand: AsyncSwiftCommand {
667854
///
668855
/// - Throws: if a command argument is invalid
669856
private func validateArguments(swiftCommandState: SwiftCommandState) throws {
857+
// Validation for --debugger first, since it affects other validations.
858+
if options.shouldLaunchInLLDB {
859+
try validateLLDBCompatibility(swiftCommandState: swiftCommandState)
860+
}
861+
670862
// Validation for --num-workers.
671863
if let workers = options.numberOfWorkers {
672864
// The --num-worker option should be called with --parallel. Since
@@ -690,6 +882,36 @@ public struct SwiftTestCommand: AsyncSwiftCommand {
690882
}
691883
}
692884

885+
/// Validates that --debugger is compatible with other provided arguments
886+
///
887+
/// - Throws: if --debugger is used with incompatible flags
888+
private func validateLLDBCompatibility(swiftCommandState: SwiftCommandState) throws {
889+
// --debugger cannot be used with release configuration
890+
let configuration = options.globalOptions.build.configuration ?? swiftCommandState.preferredBuildConfiguration
891+
if configuration == .release {
892+
throw StringError("--debugger cannot be used with release configuration (debugging requires debug symbols)")
893+
}
894+
895+
// --debugger cannot be used with parallel testing
896+
if options.shouldRunInParallel {
897+
throw StringError("--debugger cannot be used with --parallel (debugging requires sequential execution)")
898+
}
899+
900+
// --debugger cannot be used with --num-workers (which requires --parallel anyway)
901+
if options.numberOfWorkers != nil {
902+
throw StringError("--debugger cannot be used with --num-workers (debugging requires sequential execution)")
903+
}
904+
905+
// --debugger cannot be used with information-only modes that exit early
906+
if options._deprecated_shouldListTests {
907+
throw StringError("--debugger cannot be used with --list-tests (use 'swift test list' for listing tests)")
908+
}
909+
910+
if options.shouldPrintCodeCovPath {
911+
throw StringError("--debugger cannot be used with --show-codecov-path (debugging session cannot show paths)")
912+
}
913+
}
914+
693915
public init() {}
694916
}
695917

0 commit comments

Comments
 (0)