Skip to content

Commit d09caf5

Browse files
authored
Merge pull request #1662 from plemarquand/fully-specified-test-ids
Fully specify test IDs if they are not unique
2 parents c80e7e5 + 4ead088 commit d09caf5

File tree

3 files changed

+234
-1
lines changed

3 files changed

+234
-1
lines changed

Sources/SourceKitLSP/TestDiscovery.swift

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,7 @@ extension SourceKitLSPServer {
278278
.flatMap { $0 }
279279
.sorted { $0.testItem.location < $1.testItem.location }
280280
.mergingTestsInExtensions()
281+
.deduplicatingIds()
281282
}
282283

283284
func documentTests(
@@ -288,6 +289,7 @@ extension SourceKitLSPServer {
288289
return try await documentTestsWithoutMergingExtensions(req, workspace: workspace, languageService: languageService)
289290
.prefixTestsWithModuleName(workspace: workspace)
290291
.mergingTestsInExtensions()
292+
.deduplicatingIds()
291293
}
292294

293295
private func documentTestsWithoutMergingExtensions(
@@ -452,7 +454,14 @@ fileprivate extension Array<AnnotatedTestItem> {
452454
rootItem.testItem.children += item.testItem.children
453455
}
454456

455-
itemDict[id] = rootItem
457+
// If this item shares an ID with a sibling and both are leaf
458+
// test items, store it by its disambiguated id to ensure we
459+
// don't overwrite the existing element.
460+
if rootItem.testItem.children.isEmpty && item.testItem.children.isEmpty {
461+
itemDict[item.testItem.ambiguousTestDifferentiator] = item
462+
} else {
463+
itemDict[id] = rootItem
464+
}
456465
} else {
457466
itemDict[id] = item
458467
}
@@ -502,7 +511,55 @@ fileprivate extension Array<AnnotatedTestItem> {
502511
}
503512
}
504513

514+
fileprivate extension Array<TestItem> {
515+
/// If multiple testItems share the same ID we add more context to make it unique.
516+
/// Two tests can share the same ID when two swift testing tests accept
517+
/// arguments of different types, i.e:
518+
/// ```
519+
/// @Test(arguments: [1,2,3]) func foo(_ x: Int) {}
520+
/// @Test(arguments: ["a", "b", "c"]) func foo(_ x: String) {}
521+
/// ```
522+
///
523+
/// or when tests are in separate files but don't conflict because they are marked
524+
/// private, i.e:
525+
/// ```
526+
/// File1.swift: @Test private func foo() {}
527+
/// File2.swift: @Test private func foo() {}
528+
/// ```
529+
///
530+
/// If we encounter one of these cases, we need to deduplicate the ID
531+
/// by appending `/filename:filename:lineNumber`.
532+
func deduplicatingIds() -> [TestItem] {
533+
var idCounts: [String: Int] = [:]
534+
for element in self where element.children.isEmpty {
535+
idCounts[element.id, default: 0] += 1
536+
}
537+
538+
return self.map {
539+
var newItem = $0
540+
newItem.children = newItem.children.deduplicatingIds()
541+
if idCounts[newItem.id, default: 0] > 1 {
542+
newItem.id = newItem.ambiguousTestDifferentiator
543+
}
544+
return newItem
545+
}
546+
}
547+
}
548+
505549
extension TestItem {
550+
/// A fully qualified name to disambiguate identical TestItem IDs.
551+
/// This matches the IDs produced by `swift test list` when there are
552+
/// tests that cannot be disambiguated by their simple ID.
553+
fileprivate var ambiguousTestDifferentiator: String {
554+
let filename = self.location.uri.arbitrarySchemeURL.lastPathComponent
555+
let position = location.range.lowerBound
556+
// Lines and columns start at 1.
557+
// swift-testing tests start from _after_ the @ symbol in @Test, so we need to add an extra column.
558+
// see https://github.com/swiftlang/swift-testing/blob/cca6de2be617aded98ecdecb0b3b3a81eec013f3/Sources/TestingMacros/Support/AttributeDiscovery.swift#L153
559+
let columnOffset = self.style == TestStyle.swiftTesting ? 2 : 1
560+
return "\(self.id)/\(filename):\(position.line + 1):\(position.utf16index + columnOffset)"
561+
}
562+
506563
fileprivate func prefixIDWithModuleName(workspace: Workspace) async -> TestItem {
507564
guard let canonicalTarget = await workspace.buildSystemManager.canonicalTarget(for: self.location.uri),
508565
let moduleName = await workspace.buildSystemManager.moduleName(for: self.location.uri, in: canonicalTarget)

Tests/SourceKitLSPTests/DocumentTestDiscoveryTests.swift

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,139 @@ 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+
458+
func testSwiftTestingTestsWithDuplicateFunctionIdentifiersInSuite() async throws {
459+
let testClient = try await TestSourceKitLSPClient()
460+
let uri = DocumentURI(for: .swift)
461+
462+
let positions = testClient.openDocument(
463+
"""
464+
import Testing
465+
466+
1️⃣struct MySuite {
467+
3️⃣@Test(arguments: [1, 2, 3])
468+
func foo(_ x: Int) {}4️⃣
469+
5️⃣@Test(arguments: ["a", "b", "c"])
470+
func foo(_ x: String) {}6️⃣
471+
}2️⃣
472+
""",
473+
uri: uri
474+
)
475+
476+
let filename = uri.fileURL?.lastPathComponent ?? ""
477+
let tests = try await testClient.send(DocumentTestsRequest(textDocument: TextDocumentIdentifier(uri)))
478+
XCTAssertEqual(
479+
tests,
480+
[
481+
TestItem(
482+
id: "MySuite",
483+
label: "MySuite",
484+
style: TestStyle.swiftTesting,
485+
location: Location(uri: uri, range: positions["1️⃣"]..<positions["2️⃣"]),
486+
children: [
487+
TestItem(
488+
id: "MySuite/foo(_:)/\(filename):\(positions["3️⃣"].line + 1):\(positions["3️⃣"].utf16index + 2)",
489+
label: "foo(_:)",
490+
style: TestStyle.swiftTesting,
491+
location: Location(uri: uri, range: positions["3️⃣"]..<positions["4️⃣"])
492+
),
493+
TestItem(
494+
id: "MySuite/foo(_:)/\(filename):\(positions["5️⃣"].line + 1):\(positions["5️⃣"].utf16index + 2)",
495+
label: "foo(_:)",
496+
style: TestStyle.swiftTesting,
497+
location: Location(uri: uri, range: positions["5️⃣"]..<positions["6️⃣"])
498+
),
499+
]
500+
)
501+
]
502+
)
503+
}
504+
505+
func testSwiftTestingTestsWithDuplicateFunctionIdentifiersInExtension() async throws {
506+
let testClient = try await TestSourceKitLSPClient()
507+
let uri = DocumentURI(for: .swift)
508+
509+
let positions = testClient.openDocument(
510+
"""
511+
import Testing
512+
1️⃣struct MySuite {
513+
3️⃣@Test(arguments: [1, 2, 3])
514+
func foo(_ x: Int) {}4️⃣
515+
}2️⃣
516+
517+
extension MySuite {
518+
5️⃣@Test(arguments: ["a", "b", "c"])
519+
func foo(_ x: String) {}6️⃣
520+
}
521+
""",
522+
uri: uri
523+
)
524+
525+
let filename = uri.fileURL?.lastPathComponent ?? ""
526+
let tests = try await testClient.send(DocumentTestsRequest(textDocument: TextDocumentIdentifier(uri)))
527+
XCTAssertEqual(
528+
tests,
529+
[
530+
TestItem(
531+
id: "MySuite",
532+
label: "MySuite",
533+
style: TestStyle.swiftTesting,
534+
location: Location(uri: uri, range: positions["1️⃣"]..<positions["2️⃣"]),
535+
children: [
536+
TestItem(
537+
id: "MySuite/foo(_:)/\(filename):\(positions["3️⃣"].line + 1):\(positions["3️⃣"].utf16index + 2)",
538+
label: "foo(_:)",
539+
style: TestStyle.swiftTesting,
540+
location: Location(uri: uri, range: positions["3️⃣"]..<positions["4️⃣"])
541+
),
542+
TestItem(
543+
id: "MySuite/foo(_:)/\(filename):\(positions["5️⃣"].line + 1):\(positions["5️⃣"].utf16index + 2)",
544+
label: "foo(_:)",
545+
style: TestStyle.swiftTesting,
546+
location: Location(uri: uri, range: positions["5️⃣"]..<positions["6️⃣"])
547+
),
548+
]
549+
)
550+
]
551+
)
552+
}
553+
421554
func testSwiftTestingSuiteWithNoTests() async throws {
422555
let testClient = try await TestSourceKitLSPClient()
423556
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)