Skip to content

Commit 7bcbae4

Browse files
committed
Fully specify test IDs if they are not unique
It is possible for swift-testing test IDs to be identical in certain circumstances. For instance if there are two parameterized tests where only the type of the parameter differs. Another example is having two identical @test definitions marked private in two different files in the same test target. To overcome this we do the same thing that SwiftPM does when running `swift test list` in this situation, which is to fully qualify the duplicate test IDs by appending their filename:line:column. Issue: #1661
1 parent bf8ff8b commit 7bcbae4

File tree

3 files changed

+117
-2
lines changed

3 files changed

+117
-2
lines changed

Sources/SourceKitLSP/TestDiscovery.swift

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,7 @@ fileprivate extension Array<AnnotatedTestItem> {
433433
/// A node's parent is identified by the node's ID with the last component dropped.
434434
func mergingTestsInExtensions() -> [TestItem] {
435435
var itemDict: [String: AnnotatedTestItem] = [:]
436+
var duplicatedIds: Set<String> = []
436437
for item in self {
437438
let id = item.testItem.id
438439
if var rootItem = itemDict[id] {
@@ -443,6 +444,10 @@ fileprivate extension Array<AnnotatedTestItem> {
443444
var newItem = item
444445
newItem.testItem.children += rootItem.testItem.children
445446
rootItem = newItem
447+
} else if (rootItem.testItem.children.isEmpty && item.testItem.children.isEmpty) {
448+
duplicatedIds.insert(item.testItem.id)
449+
itemDict[item.testItem.fullyQualifiedTestId] = item
450+
continue
446451
} else {
447452
rootItem.testItem.children += item.testItem.children
448453
}
@@ -475,10 +480,26 @@ fileprivate extension Array<AnnotatedTestItem> {
475480
.sorted { ($0.isExtension != $1.isExtension) ? !$0.isExtension : ($0.testItem.location < $1.testItem.location) }
476481

477482
let result = sortedItems.map {
483+
var newItem = $0.testItem
484+
485+
// If multiple testItems share the same ID we add more context to make it unique.
486+
// Two tests can share the same ID when two swift testing tests accept
487+
// arguments of different types, i.e:
488+
// @Test(arguments: [1,2,3]) func foo(_ x: Int) {}
489+
// @Test(arguments: ["a", "b", "c"]) func foo(_ x: String) {}
490+
// or when tests are in separate files but don't conflict because they are marked
491+
// private, i.e:
492+
// File1.swift: @Test private func foo() {}
493+
// File2.swift: @Test private func foo() {}
494+
// If we encounter one of these cases, we need to deduplicate the ID
495+
// by appending /filename:filename:lineNumber.
496+
if duplicatedIds.contains(newItem.id) {
497+
newItem.id = newItem.fullyQualifiedTestId
498+
}
499+
478500
guard !$0.testItem.children.isEmpty, mergedIds.contains($0.testItem.id) else {
479-
return $0.testItem
501+
return newItem
480502
}
481-
var newItem = $0.testItem
482503
newItem.children = newItem.children
483504
.map { AnnotatedTestItem(testItem: $0, isExtension: false) }
484505
.mergingTestsInExtensions()
@@ -498,6 +519,20 @@ fileprivate extension Array<AnnotatedTestItem> {
498519
}
499520

500521
extension TestItem {
522+
fileprivate var fullyQualifiedTestId: String {
523+
return "\(self.id)/\(self.sourceLocation)"
524+
}
525+
526+
private var sourceLocation: String {
527+
let filename = self.location.uri.arbitrarySchemeURL.lastPathComponent
528+
let position = location.range.lowerBound
529+
// Lines and columns start at 1.
530+
// swift-testing tests start from _after_ the @ symbol in @Test, so we need to add an extra column.
531+
// see https://github.com/swiftlang/swift-testing/blob/cca6de2be617aded98ecdecb0b3b3a81eec013f3/Sources/TestingMacros/Support/AttributeDiscovery.swift#L153
532+
let columnOffset = self.style == TestStyle.swiftTesting ? 2 : 1
533+
return "\(filename):\(position.line + 1):\(position.utf16index + columnOffset)"
534+
}
535+
501536
fileprivate func prefixIDWithModuleName(workspace: Workspace) async -> TestItem {
502537
guard let canonicalTarget = await workspace.buildSystemManager.canonicalTarget(for: self.location.uri),
503538
let moduleName = await workspace.buildSystemManager.moduleName(for: self.location.uri, in: canonicalTarget)

Tests/SourceKitLSPTests/DocumentTestDiscoveryTests.swift

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,43 @@ final class DocumentTestDiscoveryTests: XCTestCase {
418418
)
419419
}
420420

421+
func testSwiftTestingTestsWithDuplicateFunctionIdentifiers() async throws {
422+
let testClient = try await TestSourceKitLSPClient()
423+
let uri = DocumentURI(for: .swift)
424+
425+
let positions = testClient.openDocument(
426+
"""
427+
import Testing
428+
429+
1️⃣@Test(arguments: [1, 2, 3])
430+
func foo(_ x: Int) {}2️⃣
431+
3️⃣@Test(arguments: ["a", "b", "c"])
432+
func foo(_ x: String) {}4️⃣
433+
""",
434+
uri: uri
435+
)
436+
437+
let filename = uri.fileURL?.lastPathComponent ?? ""
438+
let tests = try await testClient.send(DocumentTestsRequest(textDocument: TextDocumentIdentifier(uri)))
439+
XCTAssertEqual(
440+
tests,
441+
[
442+
TestItem(
443+
id: "foo(_:)/\(filename):\(positions["1️⃣"].line + 1):\(positions["1️⃣"].utf16index + 2)",
444+
label: "foo(_:)",
445+
style: TestStyle.swiftTesting,
446+
location: Location(uri: uri, range: positions["1️⃣"]..<positions["2️⃣"])
447+
),
448+
TestItem(
449+
id: "foo(_:)/\(filename):\(positions["3️⃣"].line + 1):\(positions["3️⃣"].utf16index + 2)",
450+
label: "foo(_:)",
451+
style: TestStyle.swiftTesting,
452+
location: Location(uri: uri, range: positions["3️⃣"]..<positions["4️⃣"])
453+
),
454+
]
455+
)
456+
}
457+
421458
func testSwiftTestingSuiteWithNoTests() async throws {
422459
let testClient = try await TestSourceKitLSPClient()
423460
let uri = DocumentURI(for: .swift)

Tests/SourceKitLSPTests/WorkspaceTestDiscoveryTests.swift

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,49 @@ final class WorkspaceTestDiscoveryTests: XCTestCase {
245245
)
246246
}
247247

248+
func testSwiftTestingTestsWithDuplicateFunctionIdentifiersAcrossDocuments() async throws {
249+
let project = try await SwiftPMTestProject(
250+
files: [
251+
"Tests/MyLibraryTests/MyTests1.swift": """
252+
import Testing
253+
254+
1️⃣@Test(arguments: [1, 2, 3])
255+
private func foo(_ x: Int) {}2️⃣
256+
""",
257+
"Tests/MyLibraryTests/MyTests2.swift": """
258+
import Testing
259+
260+
3️⃣@Test(arguments: [1, 2, 3])
261+
private func foo(_ x: Int) {}4️⃣
262+
""",
263+
],
264+
manifest: packageManifestWithTestTarget
265+
)
266+
267+
let test1Position = try project.position(of: "1️⃣", in: "MyTests1.swift")
268+
let test2Position = try project.position(of: "3️⃣", in: "MyTests2.swift")
269+
270+
let tests = try await project.testClient.send(WorkspaceTestsRequest())
271+
272+
XCTAssertEqual(
273+
tests,
274+
[
275+
TestItem(
276+
id: "MyLibraryTests.foo(_:)/MyTests1.swift:\(test1Position.line + 1):\(test1Position.utf16index + 2)",
277+
label: "foo(_:)",
278+
style: TestStyle.swiftTesting,
279+
location: try project.location(from: "1️⃣", to: "2️⃣", in: "MyTests1.swift")
280+
),
281+
TestItem(
282+
id: "MyLibraryTests.foo(_:)/MyTests2.swift:\(test2Position.line + 1):\(test2Position.utf16index + 2)",
283+
label: "foo(_:)",
284+
style: TestStyle.swiftTesting,
285+
location: try project.location(from: "3️⃣", to: "4️⃣", in: "MyTests2.swift")
286+
),
287+
]
288+
)
289+
}
290+
248291
func testSwiftTestingAndXCTestInTheSameFile() async throws {
249292
try SkipUnless.longTestsEnabled()
250293

0 commit comments

Comments
 (0)