Skip to content

Commit 6743d46

Browse files
committed
Build Swift Testing and XCTest content in a single product.
This PR refactors the previously-experimental Swift Testing support logic so that only a single build product is produced when using both XCTest and Swift Testing, and detection of Swift Testing usage is no longer needed at compile time. On macOS, Xcode 16 is responsible for hosting Swift Testing content, so additional changes may be needed in Xcode to support this refactoring. Such changes are beyond the purview of the Swift open source project.
1 parent 5e54c6b commit 6743d46

File tree

14 files changed

+397
-492
lines changed

14 files changed

+397
-492
lines changed

Sources/Build/BuildManifest/LLBuildManifestBuilder.swift

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -112,9 +112,7 @@ public class LLBuildManifestBuilder {
112112
}
113113
}
114114

115-
if self.plan.destinationBuildParameters.testingParameters.library == .xctest {
116-
try self.addTestDiscoveryGenerationCommand()
117-
}
115+
try self.addTestDiscoveryGenerationCommand()
118116
try self.addTestEntryPointGenerationCommand()
119117

120118
// Create command for all products in the plan.
@@ -310,9 +308,7 @@ extension LLBuildManifestBuilder {
310308

311309
let outputs = testEntryPointTarget.target.sources.paths
312310

313-
let mainFileName = TestEntryPointTool.mainFileName(
314-
for: self.plan.destinationBuildParameters.testingParameters.library
315-
)
311+
let mainFileName = TestEntryPointTool.mainFileName
316312
guard let mainOutput = (outputs.first { $0.basename == mainFileName }) else {
317313
throw InternalError("main output (\(mainFileName)) not found")
318314
}

Sources/Build/BuildPlan/BuildPlan+Test.swift

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,16 @@ extension BuildPlan {
3434
_ fileSystem: FileSystem,
3535
_ observabilityScope: ObservabilityScope
3636
) throws -> [(product: ResolvedProduct, discoveryTargetBuildDescription: SwiftModuleBuildDescription?, entryPointTargetBuildDescription: SwiftModuleBuildDescription)] {
37-
guard destinationBuildParameters.testingParameters.testProductStyle.requiresAdditionalDerivedTestTargets,
38-
case .entryPointExecutable(let explicitlyEnabledDiscovery, let explicitlySpecifiedPath) =
39-
destinationBuildParameters.testingParameters.testProductStyle
40-
else {
37+
guard destinationBuildParameters.testingParameters.testProductStyle.requiresAdditionalDerivedTestTargets else {
4138
throw InternalError("makeTestManifestTargets should not be used for build plan which does not require additional derived test targets")
4239
}
4340

41+
var explicitlyEnabledDiscovery = false
42+
var explicitlySpecifiedPath: AbsolutePath?
43+
if case let .entryPointExecutable(caseExplicitlyEnabledDiscovery, caseExplicitlySpecifiedPath) = destinationBuildParameters.testingParameters.testProductStyle {
44+
explicitlyEnabledDiscovery = caseExplicitlyEnabledDiscovery
45+
explicitlySpecifiedPath = caseExplicitlySpecifiedPath
46+
}
4447
let isEntryPointPathSpecifiedExplicitly = explicitlySpecifiedPath != nil
4548

4649
var isDiscoveryEnabledRedundantly = explicitlyEnabledDiscovery && !isEntryPointPathSpecifiedExplicitly
@@ -116,7 +119,7 @@ extension BuildPlan {
116119
resolvedTargetDependencies: [ResolvedModule.Dependency]
117120
) throws -> SwiftModuleBuildDescription {
118121
let entryPointDerivedDir = destinationBuildParameters.buildPath.appending(components: "\(testProduct.name).derived")
119-
let entryPointMainFileName = TestEntryPointTool.mainFileName(for: destinationBuildParameters.testingParameters.library)
122+
let entryPointMainFileName = TestEntryPointTool.mainFileName
120123
let entryPointMainFile = entryPointDerivedDir.appending(component: entryPointMainFileName)
121124
let entryPointSources = Sources(paths: [entryPointMainFile], root: entryPointDerivedDir)
122125

@@ -153,16 +156,9 @@ extension BuildPlan {
153156
let swiftTargetDependencies: [Module.Dependency]
154157
let resolvedTargetDependencies: [ResolvedModule.Dependency]
155158

156-
switch destinationBuildParameters.testingParameters.library {
157-
case .xctest:
158-
discoveryTargets = try generateDiscoveryTargets()
159-
swiftTargetDependencies = [.module(discoveryTargets!.target, conditions: [])]
160-
resolvedTargetDependencies = [.module(discoveryTargets!.resolved, conditions: [])]
161-
case .swiftTesting:
162-
discoveryTargets = nil
163-
swiftTargetDependencies = testProduct.modules.map { .module($0.underlying, conditions: []) }
164-
resolvedTargetDependencies = testProduct.modules.map { .module($0, conditions: []) }
165-
}
159+
discoveryTargets = try generateDiscoveryTargets()
160+
swiftTargetDependencies = [.module(discoveryTargets!.target, conditions: [])]
161+
resolvedTargetDependencies = [.module(discoveryTargets!.resolved, conditions: [])]
166162

167163
if let entryPointResolvedTarget = testProduct.testEntryPointModule {
168164
if isEntryPointPathSpecifiedExplicitly || explicitlyEnabledDiscovery {

Sources/Build/LLBuildCommands.swift

Lines changed: 122 additions & 108 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,8 @@ extension IndexStore.TestCaseClass.TestMethod {
5050
}
5151

5252
extension TestEntryPointTool {
53-
public static func mainFileName(for library: BuildParameters.Testing.Library) -> String {
54-
"runner-\(library).swift"
53+
public static var mainFileName: String {
54+
"runner.swift"
5555
}
5656
}
5757

@@ -105,74 +105,76 @@ final class TestDiscoveryCommand: CustomLLBuildCommand, TestBuildCommand {
105105
private func execute(fileSystem: Basics.FileSystem, tool: TestDiscoveryTool) throws {
106106
let outputs = tool.outputs.compactMap { try? AbsolutePath(validating: $0.name) }
107107

108-
switch self.context.productsBuildParameters.testingParameters.library {
109-
case .swiftTesting:
108+
if case .loadableBundle = context.productsBuildParameters.testingParameters.testProductStyle {
109+
// When building an XCTest bundle, test discovery is handled by the
110+
// test harness process (i.e. this is the Darwin path.)
110111
for file in outputs {
111112
try fileSystem.writeIfChanged(path: file, string: "")
112113
}
113-
case .xctest:
114-
let index = self.context.productsBuildParameters.indexStore
115-
let api = try self.context.indexStoreAPI.get()
116-
let store = try IndexStore.open(store: TSCAbsolutePath(index), api: api)
117-
118-
// FIXME: We can speed this up by having one llbuild command per object file.
119-
let tests = try store
120-
.listTests(in: tool.inputs.map { try TSCAbsolutePath(AbsolutePath(validating: $0.name)) })
121-
122-
let testsByModule = Dictionary(grouping: tests, by: { $0.module.spm_mangledToC99ExtendedIdentifier() })
123-
124-
// Find the main file path.
125-
guard let mainFile = outputs.first(where: { path in
126-
path.basename == TestDiscoveryTool.mainFileName
127-
}) else {
128-
throw InternalError("main output (\(TestDiscoveryTool.mainFileName)) not found")
129-
}
114+
return
115+
}
130116

131-
// Write one file for each test module.
132-
//
133-
// We could write everything in one file but that can easily run into type conflicts due
134-
// in complex packages with large number of test modules.
135-
for file in outputs where file != mainFile {
136-
// FIXME: This is relying on implementation detail of the output but passing the
137-
// the context all the way through is not worth it right now.
138-
let module = file.basenameWithoutExt.spm_mangledToC99ExtendedIdentifier()
139-
140-
guard let tests = testsByModule[module] else {
141-
// This module has no tests so just write an empty file for it.
142-
try fileSystem.writeFileContents(file, bytes: "")
143-
continue
144-
}
145-
try write(
146-
tests: tests,
147-
forModule: module,
148-
fileSystem: fileSystem,
149-
path: file
150-
)
117+
let index = self.context.productsBuildParameters.indexStore
118+
let api = try self.context.indexStoreAPI.get()
119+
let store = try IndexStore.open(store: TSCAbsolutePath(index), api: api)
120+
121+
// FIXME: We can speed this up by having one llbuild command per object file.
122+
let tests = try store
123+
.listTests(in: tool.inputs.map { try TSCAbsolutePath(AbsolutePath(validating: $0.name)) })
124+
125+
let testsByModule = Dictionary(grouping: tests, by: { $0.module.spm_mangledToC99ExtendedIdentifier() })
126+
127+
// Find the main file path.
128+
guard let mainFile = outputs.first(where: { path in
129+
path.basename == TestDiscoveryTool.mainFileName
130+
}) else {
131+
throw InternalError("main output (\(TestDiscoveryTool.mainFileName)) not found")
132+
}
133+
134+
// Write one file for each test module.
135+
//
136+
// We could write everything in one file but that can easily run into type conflicts due
137+
// in complex packages with large number of test modules.
138+
for file in outputs where file != mainFile {
139+
// FIXME: This is relying on implementation detail of the output but passing the
140+
// the context all the way through is not worth it right now.
141+
let module = file.basenameWithoutExt.spm_mangledToC99ExtendedIdentifier()
142+
143+
guard let tests = testsByModule[module] else {
144+
// This module has no tests so just write an empty file for it.
145+
try fileSystem.writeFileContents(file, bytes: "")
146+
continue
151147
}
148+
try write(
149+
tests: tests,
150+
forModule: module,
151+
fileSystem: fileSystem,
152+
path: file
153+
)
154+
}
152155

153-
let testsKeyword = tests.isEmpty ? "let" : "var"
156+
let testsKeyword = tests.isEmpty ? "let" : "var"
154157

155-
// Write the main file.
156-
let stream = try LocalFileOutputByteStream(mainFile)
158+
// Write the main file.
159+
let stream = try LocalFileOutputByteStream(mainFile)
157160

158-
stream.send(
159-
#"""
160-
import XCTest
161+
stream.send(
162+
#"""
163+
import XCTest
161164
162-
@available(*, deprecated, message: "Not actually deprecated. Marked as deprecated to allow inclusion of deprecated tests (which test deprecated functionality) without warnings")
163-
@MainActor
164-
public func __allDiscoveredTests() -> [XCTestCaseEntry] {
165-
\#(testsKeyword) tests = [XCTestCaseEntry]()
165+
@available(*, deprecated, message: "Not actually deprecated. Marked as deprecated to allow inclusion of deprecated tests (which test deprecated functionality) without warnings")
166+
@MainActor
167+
public func __allDiscoveredTests() -> [XCTestCaseEntry] {
168+
\#(testsKeyword) tests = [XCTestCaseEntry]()
166169
167-
\#(testsByModule.keys.map { "tests += __\($0)__allTests()" }.joined(separator: "\n "))
170+
\#(testsByModule.keys.map { "tests += __\($0)__allTests()" }.joined(separator: "\n "))
168171

169-
return tests
170-
}
171-
"""#
172-
)
172+
return tests
173+
}
174+
"""#
175+
)
173176

174-
stream.flush()
175-
}
177+
stream.flush()
176178
}
177179

178180
override func execute(
@@ -201,9 +203,7 @@ final class TestEntryPointCommand: CustomLLBuildCommand, TestBuildCommand {
201203
let outputs = tool.outputs.compactMap { try? AbsolutePath(validating: $0.name) }
202204

203205
// Find the main output file
204-
let mainFileName = TestEntryPointTool.mainFileName(
205-
for: self.context.productsBuildParameters.testingParameters.library
206-
)
206+
let mainFileName = TestEntryPointTool.mainFileName
207207
guard let mainFile = outputs.first(where: { path in
208208
path.basename == mainFileName
209209
}) else {
@@ -213,62 +213,76 @@ final class TestEntryPointCommand: CustomLLBuildCommand, TestBuildCommand {
213213
// Write the main file.
214214
let stream = try LocalFileOutputByteStream(mainFile)
215215

216-
switch self.context.productsBuildParameters.testingParameters.library {
217-
case .swiftTesting:
218-
stream.send(
219-
#"""
220-
#if canImport(Testing)
221-
import Testing
222-
#endif
216+
// Find the inputs, which are the names of the test discovery module(s)
217+
let inputs = tool.inputs.compactMap { try? AbsolutePath(validating: $0.name) }
218+
let discoveryModuleNames = inputs.map(\.basenameWithoutExt)
223219

224-
@main struct Runner {
225-
static func main() async {
226-
#if canImport(Testing)
227-
await Testing.__swiftPMEntryPoint() as Never
228-
#endif
229-
}
230-
}
231-
"""#
232-
)
233-
case .xctest:
234-
// Find the inputs, which are the names of the test discovery module(s)
235-
let inputs = tool.inputs.compactMap { try? AbsolutePath(validating: $0.name) }
236-
let discoveryModuleNames = inputs.map(\.basenameWithoutExt)
237-
238-
let testObservabilitySetup: String
239-
let buildParameters = self.context.productsBuildParameters
240-
if buildParameters.testingParameters.experimentalTestOutput && buildParameters.triple.supportsTestSummary {
241-
testObservabilitySetup = "_ = SwiftPMXCTestObserver()\n"
242-
} else {
243-
testObservabilitySetup = ""
244-
}
220+
let testObservabilitySetup: String
221+
let buildParameters = self.context.productsBuildParameters
222+
if buildParameters.testingParameters.experimentalTestOutput && buildParameters.triple.supportsTestSummary {
223+
testObservabilitySetup = "_ = SwiftPMXCTestObserver()\n"
224+
} else {
225+
testObservabilitySetup = ""
226+
}
245227

246-
stream.send(
247-
#"""
248-
\#(generateTestObservationCode(buildParameters: buildParameters))
228+
let swiftTestingImportCondition = "canImport(Testing)"
229+
let xctestImportCondition: String = switch buildParameters.testingParameters.testProductStyle {
230+
case .entryPointExecutable:
231+
"canImport(XCTest)"
232+
case .loadableBundle:
233+
"false"
234+
}
249235

250-
import XCTest
251-
\#(discoveryModuleNames.map { "import \($0)" }.joined(separator: "\n"))
236+
stream.send(
237+
#"""
238+
#if \#(swiftTestingImportCondition)
239+
import Testing
240+
#endif
252241
253-
@main
254-
@available(*, deprecated, message: "Not actually deprecated. Marked as deprecated to allow inclusion of deprecated tests (which test deprecated functionality) without warnings")
255-
struct Runner {
256-
#if os(WASI)
257-
/// On WASI, we can't block the main thread, so XCTestMain is defined as async.
258-
static func main() async {
259-
\#(testObservabilitySetup)
260-
await XCTMain(__allDiscoveredTests()) as Never
242+
#if \#(xctestImportCondition)
243+
\#(generateTestObservationCode(buildParameters: buildParameters))
244+
245+
import XCTest
246+
\#(discoveryModuleNames.map { "import \($0)" }.joined(separator: "\n"))
247+
#endif
248+
249+
@main
250+
@available(*, deprecated, message: "Not actually deprecated. Marked as deprecated to allow inclusion of deprecated tests (which test deprecated functionality) without warnings")
251+
struct Runner {
252+
private static func testingLibrary() -> String {
253+
var iterator = CommandLine.arguments.makeIterator()
254+
while let argument = iterator.next() {
255+
if argument == "--testing-library", let libraryName = iterator.next() {
256+
return libraryName.lowercased()
257+
}
258+
}
259+
260+
// Fallback if not specified: run XCTest (legacy behavior)
261+
return "xctest"
262+
}
263+
264+
static func main() async {
265+
let testingLibrary = Self.testingLibrary()
266+
#if \#(swiftTestingImportCondition)
267+
if testingLibrary == "swift-testing" {
268+
await Testing.__swiftPMEntryPoint() as Never
261269
}
262-
#else
263-
static func main() {
270+
#endif
271+
#if \#(xctestImportCondition)
272+
if testingLibrary == "xctest" {
264273
\#(testObservabilitySetup)
274+
#if os(WASI)
275+
/// On WASI, we can't block the main thread, so XCTestMain is defined as async.
276+
await XCTMain(__allDiscoveredTests()) as Never
277+
#else
265278
XCTMain(__allDiscoveredTests()) as Never
279+
#endif
266280
}
267-
#endif
281+
#endif
268282
}
269-
"""#
270-
)
271-
}
283+
}
284+
"""#
285+
)
272286

273287
stream.flush()
274288
}

Sources/Build/LLBuildDescription.swift

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,8 +140,7 @@ public struct BuildDescription: Codable {
140140
try BuiltTestProduct(
141141
productName: desc.product.name,
142142
binaryPath: desc.binaryPath,
143-
packagePath: desc.package.path,
144-
library: desc.buildParameters.testingParameters.library
143+
packagePath: desc.package.path
145144
)
146145
}
147146
self.pluginDescriptions = pluginDescriptions

Sources/Commands/PackageCommands/Init.swift

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -54,20 +54,11 @@ extension SwiftPackageCommand {
5454
throw InternalError("Could not find the current working directory")
5555
}
5656

57-
// NOTE: Do not use testLibraryOptions.enabledTestingLibraries(swiftCommandState:) here
58-
// because the package doesn't exist yet, so there are no dependencies for it to query.
59-
var testingLibraries: Set<BuildParameters.Testing.Library> = []
60-
if testLibraryOptions.enableXCTestSupport {
61-
testingLibraries.insert(.xctest)
62-
}
63-
if testLibraryOptions.explicitlyEnableSwiftTestingLibrarySupport == true {
64-
testingLibraries.insert(.swiftTesting)
65-
}
6657
let packageName = self.packageName ?? cwd.basename
6758
let initPackage = try InitPackage(
6859
name: packageName,
6960
packageType: initMode,
70-
supportedTestingLibraries: testingLibraries,
61+
supportedTestingLibraries: Set(testLibraryOptions.enabledTestingLibraries),
7162
destinationPath: cwd,
7263
installedSwiftPMConfiguration: swiftCommandState.getHostToolchain().installedSwiftPMConfiguration,
7364
fileSystem: swiftCommandState.fileSystem

0 commit comments

Comments
 (0)