Skip to content

Commit 00eb790

Browse files
authored
Ensure tests representing containing types of suites are synthesized if necessary (#407)
Ensure that when a suite is nested in one or more containing suite types, and those outer suites do not have an explicit `@Suite` attribute, we synthesize `Test` instances representing all containing suites. ### Checklist: - [x] Code and documentation should follow the style of the [Style Guide](https://github.com/apple/swift-testing/blob/main/Documentation/StyleGuide.md). - [x] If public symbols are renamed or modified, DocC references should be updated. Resolves rdar://127770218
1 parent 4f602e0 commit 00eb790

File tree

6 files changed

+224
-129
lines changed

6 files changed

+224
-129
lines changed

Sources/Testing/Parameterization/TypeInfo.swift

Lines changed: 25 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,33 @@ public struct TypeInfo: Sendable {
4545
return nil
4646
}
4747

48-
init(fullyQualifiedName: String, unqualifiedName: String, mangledName: String?) {
48+
/// Initialize an instance of this type with the specified names.
49+
///
50+
/// - Parameters:
51+
/// - fullyQualifiedComponents: The fully-qualified name components of the
52+
/// type.
53+
/// - unqualified: The unqualified name of the type.
54+
/// - mangled: The mangled name of the type, if available.
55+
init(fullyQualifiedNameComponents: [String], unqualifiedName: String, mangledName: String? = nil) {
4956
_kind = .nameOnly(
50-
fullyQualifiedComponents: fullyQualifiedName.split(separator: ".").map(String.init),
57+
fullyQualifiedComponents: fullyQualifiedNameComponents,
5158
unqualified: unqualifiedName,
52-
mangled: mangledName
59+
mangled: mangledName,
60+
)
61+
}
62+
63+
/// Initialize an instance of this type with the specified names.
64+
///
65+
/// - Parameters:
66+
/// - fullyQualifiedName: The fully-qualified name of the type, with its
67+
/// components separated by a period character (`"."`).
68+
/// - unqualified: The unqualified name of the type.
69+
/// - mangled: The mangled name of the type, if available.
70+
init(fullyQualifiedName: String, unqualifiedName: String, mangledName: String?) {
71+
self.init(
72+
fullyQualifiedNameComponents: fullyQualifiedName.split(separator: ".").map(String.init),
73+
unqualifiedName: unqualifiedName,
74+
mangledName: mangledName
5375
)
5476
}
5577

@@ -246,44 +268,6 @@ func isClass(_ subclass: AnyClass, subclassOf superclass: AnyClass) -> Bool {
246268
}
247269
}
248270

249-
// MARK: - Containing types
250-
251-
extension TypeInfo {
252-
/// An instance of this type representing the type immediately containing the
253-
/// described type.
254-
///
255-
/// For instance, given the following declaration in the `Example` module:
256-
///
257-
/// ```swift
258-
/// struct A {
259-
/// struct B {}
260-
/// }
261-
/// ```
262-
///
263-
/// The value of this property for the type `A.B` would describe `A`, while
264-
/// the value for `A` would be `nil` because it has no enclosing type.
265-
var containingTypeInfo: Self? {
266-
let fqnComponents = fullyQualifiedNameComponents
267-
if fqnComponents.count > 2 { // the module is not a type
268-
let fqn = fqnComponents.dropLast().joined(separator: ".")
269-
#if false // currently non-functional
270-
if let type = _typeByName(fqn) {
271-
return Self(describing: type)
272-
}
273-
#endif
274-
let name = fqnComponents[fqnComponents.count - 2]
275-
return Self(fullyQualifiedName: fqn, unqualifiedName: name, mangledName: nil)
276-
}
277-
return nil
278-
}
279-
280-
/// A sequence of instances of this type representing the types that
281-
/// recursively contain it, starting with the immediate parent (if any.)
282-
var allContainingTypeInfo: some Sequence<Self> {
283-
sequence(first: self, next: \.containingTypeInfo).dropFirst()
284-
}
285-
}
286-
287271
// MARK: - CustomStringConvertible, CustomDebugStringConvertible, CustomTestStringConvertible
288272

289273
extension TypeInfo: CustomStringConvertible, CustomDebugStringConvertible {

Sources/Testing/Running/Runner.Plan.swift

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,52 @@ extension Runner.Plan {
154154
}
155155
}
156156

157+
/// Recursively synthesize test instances representing suites for all missing
158+
/// values in the specified test graph.
159+
///
160+
/// - Parameters:
161+
/// - graph: The graph in which suites should be synthesized.
162+
/// - nameComponents: The name components of the suite to synthesize, based
163+
/// on the key path from the root node of the test graph to `graph`.
164+
private static func _recursivelySynthesizeSuites(in graph: inout Graph<String, Test?>, nameComponents: [String] = []) {
165+
// The recursive function. This is a local function to simplify the initial
166+
// call which does not need to pass the `sourceLocation:` inout argument.
167+
func synthesizeSuites(in graph: inout Graph<String, Test?>, nameComponents: [String] = [], sourceLocation: inout SourceLocation?) {
168+
for (key, var childGraph) in graph.children {
169+
synthesizeSuites(in: &childGraph, nameComponents: nameComponents + [key], sourceLocation: &sourceLocation)
170+
graph.children[key] = childGraph
171+
}
172+
173+
if let test = graph.value {
174+
sourceLocation = test.sourceLocation
175+
} else if let unqualifiedName = nameComponents.last, let sourceLocation {
176+
// Don't synthesize suites representing modules.
177+
if nameComponents.count <= 1 {
178+
return
179+
}
180+
181+
// Don't synthesize suites for nodes in the graph which are the
182+
// immediate ancestor of a test function. That level of the hierarchy is
183+
// used to disambiguate test functions which have equal names but
184+
// different source locations.
185+
if let firstChildTest = graph.children.values.first?.value, !firstChildTest.isSuite {
186+
return
187+
}
188+
189+
let typeInfo = TypeInfo(fullyQualifiedNameComponents: nameComponents, unqualifiedName: unqualifiedName)
190+
191+
// Note: When a suite is synthesized, it does not have an accurate
192+
// source location, so we use the source location of a close descendant
193+
// test. We do this instead of falling back to some "unknown"
194+
// placeholder in an attempt to preserve the correct sort ordering.
195+
graph.value = Test(traits: [], sourceLocation: sourceLocation, containingTypeInfo: typeInfo, isSynthesized: true)
196+
}
197+
}
198+
199+
var sourceLocation: SourceLocation?
200+
synthesizeSuites(in: &graph, sourceLocation: &sourceLocation)
201+
}
202+
157203
/// Construct a graph of runner plan steps for the specified tests.
158204
///
159205
/// - Parameters:
@@ -198,6 +244,9 @@ extension Runner.Plan {
198244
// and that is already guarded earlier in the SwiftPM entry point.
199245
}
200246

247+
// Synthesize suites for nodes in the test graph for which they are missing.
248+
_recursivelySynthesizeSuites(in: &testGraph)
249+
201250
// Recursively apply all recursive suite traits to children.
202251
//
203252
// This must be done _before_ calling `prepare(for:)` on the traits below.

Sources/Testing/Test+Discovery.swift

Lines changed: 1 addition & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -28,24 +28,7 @@ extension Test {
2828
/// All available ``Test`` instances in the process, according to the runtime.
2929
///
3030
/// The order of values in this sequence is unspecified.
31-
static var all: some Sequence<Test> {
32-
get async {
33-
// Convert the raw sequence of tests to a dictionary keyed by ID.
34-
var result = await testsByID(_all)
35-
36-
// Ensure test suite types that don't have the @Suite attribute are still
37-
// represented in the result.
38-
_synthesizeSuiteTypes(into: &result)
39-
40-
return result.values
41-
}
42-
}
43-
44-
/// All available ``Test`` instances in the process, according to the runtime.
45-
///
46-
/// The order of values in this sequence is unspecified. This sequence may
47-
/// contain duplicates; callers should use ``all`` instead.
48-
private static var _all: some Sequence<Self> {
31+
static var all: some Sequence<Self> {
4932
get async {
5033
await withTaskGroup(of: [Self].self) { taskGroup in
5134
enumerateTypes(withNamesContaining: _testContainerTypeNameMagic) { _, type, _ in
@@ -60,53 +43,6 @@ extension Test {
6043
}
6144
}
6245
}
63-
64-
/// Create a dictionary mapping the IDs of a sequence of tests to those tests.
65-
///
66-
/// - Parameters:
67-
/// - tests: The sequence to convert to a dictionary.
68-
///
69-
/// - Returns: A dictionary containing `tests` keyed by those tests' IDs.
70-
static func testsByID(_ tests: some Sequence<Self>) -> [ID: Self] {
71-
[ID: Self](
72-
tests.lazy.map { ($0.id, $0) },
73-
uniquingKeysWith: { existing, _ in existing }
74-
)
75-
}
76-
77-
/// Synthesize any missing test suite types (that is, types containing test
78-
/// content that do not have the `@Suite` attribute) and add them to a
79-
/// dictionary of tests.
80-
///
81-
/// - Parameters:
82-
/// - tests: A dictionary of tests to amend.
83-
///
84-
/// - Returns: The number of key-value pairs added to `tests`.
85-
@discardableResult private static func _synthesizeSuiteTypes(into tests: inout [ID: Self]) -> Int {
86-
let originalCount = tests.count
87-
88-
// Find any instances of Test in the input that are *not* suites. We'll be
89-
// checking the containing types of each one.
90-
for test in tests.values where !test.isSuite {
91-
guard let suiteTypeInfo = test.containingTypeInfo else {
92-
continue
93-
}
94-
let suiteID = ID(typeInfo: suiteTypeInfo)
95-
if tests[suiteID] == nil {
96-
tests[suiteID] = Test(traits: [], sourceLocation: test.sourceLocation, containingTypeInfo: suiteTypeInfo, isSynthesized: true)
97-
98-
// Also synthesize any ancestral suites that don't have tests.
99-
for ancestralSuiteTypeInfo in suiteTypeInfo.allContainingTypeInfo {
100-
let ancestralSuiteID = ID(typeInfo: ancestralSuiteTypeInfo)
101-
if tests[ancestralSuiteID] == nil {
102-
tests[ancestralSuiteID] = Test(traits: [], sourceLocation: test.sourceLocation, containingTypeInfo: ancestralSuiteTypeInfo, isSynthesized: true)
103-
}
104-
}
105-
}
106-
}
107-
108-
return tests.count - originalCount
109-
}
11046
}
11147

11248
// MARK: -

Tests/TestingTests/MiscellaneousTests.swift

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -552,17 +552,6 @@ struct MiscellaneousTests {
552552
#expect(id.keyPathRepresentation == [""])
553553
}
554554

555-
@Test("Test.all deduping")
556-
func allTestDeduping() {
557-
let tests = [Test(name: "A") {}, Test(name: "B") {}, Test(name: "C") {}, Test(name: "D") {}, Test(name: "E") {}, Test(name: "F") {}, Test(name: "G") {},]
558-
var duplicatedTests = tests
559-
duplicatedTests += tests
560-
duplicatedTests.shuffle()
561-
let mappedTests = Test.testsByID(duplicatedTests)
562-
#expect(mappedTests.count == tests.count)
563-
#expect(mappedTests.values.allSatisfy { tests.contains($0) })
564-
}
565-
566555
@Test("failureBreakpoint() call")
567556
func failureBreakpointCall() {
568557
failureBreakpointValue = 1

Tests/TestingTests/PlanTests.swift

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,104 @@ struct PlanTests {
429429
#expect(moduleGraph.children.count == 1)
430430
}
431431

432+
@Suite("Containing suite types without @Suite are synthesized")
433+
struct ContainingSuiteSynthesis {
434+
@Test("A test function inside a top-level implicit suite")
435+
func oneImplicitParent() async throws {
436+
let plan = await Runner.Plan(selecting: ImplicitParentSuite_A.self)
437+
let testNames = plan.stepGraph.compactMap { $0.value }.map(\.test.name)
438+
#expect(testNames == [
439+
"ImplicitParentSuite_A",
440+
"example()",
441+
])
442+
443+
let testFunction = try #require(plan.steps.map(\.test).first { $0.name == "example()" })
444+
let implicitParentSuite_A = try #require(plan.steps.map(\.test).first { $0.name == "ImplicitParentSuite_A" })
445+
#expect(implicitParentSuite_A.sourceLocation == testFunction.sourceLocation)
446+
#expect(implicitParentSuite_A.isSynthesized)
447+
}
448+
449+
@Test("A test function in a type hierarchy where the nearest suite is explicit and outer ones are implicit", arguments: [
450+
ImplicitGrandparentSuite_B.self,
451+
ImplicitGrandparentSuite_B.ImplicitParentSuite_B.self,
452+
ImplicitGrandparentSuite_B.ImplicitParentSuite_B.ExplicitChildSuite_B.self,
453+
] as [Any.Type])
454+
func twoImplicitAncestorsButExplicitParent(suiteType: Any.Type) async throws {
455+
let plan = await Runner.Plan(selecting: suiteType)
456+
let testNames = plan.stepGraph.compactMap { $0.value }.map(\.test.name)
457+
#expect(testNames == [
458+
"ImplicitGrandparentSuite_B",
459+
"ImplicitParentSuite_B",
460+
"ExplicitChildSuite_B",
461+
"example()",
462+
])
463+
464+
let testFunction = try #require(plan.steps.map(\.test).first { $0.name == "example()" })
465+
let explicitChildSuite_B = try #require(plan.steps.map(\.test).first { $0.name == "ExplicitChildSuite_B" })
466+
#expect(explicitChildSuite_B.sourceLocation != testFunction.sourceLocation)
467+
#expect(!explicitChildSuite_B.isSynthesized)
468+
469+
let implicitParentSuite_B = try #require(plan.steps.map(\.test).first { $0.name == "ImplicitParentSuite_B" })
470+
#expect(implicitParentSuite_B.sourceLocation == explicitChildSuite_B.sourceLocation)
471+
#expect(implicitParentSuite_B.isSynthesized)
472+
473+
let implicitGrandparentSuite_B = try #require(plan.steps.map(\.test).first { $0.name == "ImplicitGrandparentSuite_B" })
474+
#expect(implicitGrandparentSuite_B.sourceLocation == implicitParentSuite_B.sourceLocation)
475+
#expect(implicitGrandparentSuite_B.isSynthesized)
476+
}
477+
478+
@Test("A test function in a type hierarchy with both explicit and implicit suites")
479+
func mixedAncestors() async throws {
480+
let plan = await Runner.Plan(selecting: ExplicitGrandparentSuite_C.self)
481+
let testNames = plan.stepGraph.compactMap { $0.value }.map(\.test.name)
482+
#expect(testNames == [
483+
"ExplicitGrandparentSuite_C",
484+
"ImplicitParentSuite_C",
485+
"ExplicitChildSuite_C",
486+
"example()",
487+
])
488+
489+
let testFunction = try #require(plan.steps.map(\.test).first { $0.name == "example()" })
490+
let explicitChildSuite_C = try #require(plan.steps.map(\.test).first { $0.name == "ExplicitChildSuite_C" })
491+
#expect(explicitChildSuite_C.sourceLocation != testFunction.sourceLocation)
492+
#expect(!explicitChildSuite_C.isSynthesized)
493+
494+
let implicitParentSuite_C = try #require(plan.steps.map(\.test).first { $0.name == "ImplicitParentSuite_C" })
495+
#expect(implicitParentSuite_C.sourceLocation == explicitChildSuite_C.sourceLocation)
496+
#expect(implicitParentSuite_C.isSynthesized)
497+
498+
let explicitGrandparentSuite_C = try #require(plan.steps.map(\.test).first { $0.name == "ExplicitGrandparentSuite_C" })
499+
#expect(explicitGrandparentSuite_C.sourceLocation != implicitParentSuite_C.sourceLocation)
500+
#expect(!explicitGrandparentSuite_C.isSynthesized)
501+
}
502+
503+
@Test("A test function in a type hierarchy with all implicit suites")
504+
func allImplicitAncestors() async throws {
505+
let plan = await Runner.Plan(selecting: ImplicitGrandparentSuite_D.self)
506+
let testNames = plan.stepGraph.compactMap { $0.value }.map(\.test.name)
507+
#expect(testNames == [
508+
"ImplicitGrandparentSuite_D",
509+
"ImplicitParentSuite_D",
510+
"ImplicitChildSuite_D",
511+
"ImplicitGrandchildSuite_D",
512+
"example()",
513+
])
514+
515+
let testFunction = try #require(plan.steps.map(\.test).first { $0.name == "example()" })
516+
let implicitGrandchildSuite_D = try #require(plan.steps.map(\.test).first { $0.name == "ImplicitGrandchildSuite_D" })
517+
#expect(implicitGrandchildSuite_D.sourceLocation == testFunction.sourceLocation)
518+
519+
let implicitChildSuite_D = try #require(plan.steps.map(\.test).first { $0.name == "ImplicitChildSuite_D" })
520+
#expect(implicitChildSuite_D.sourceLocation == implicitGrandchildSuite_D.sourceLocation)
521+
522+
let implicitParentSuite_D = try #require(plan.steps.map(\.test).first { $0.name == "ImplicitParentSuite_D" })
523+
#expect(implicitParentSuite_D.sourceLocation == implicitChildSuite_D.sourceLocation)
524+
525+
let implicitGrandparentSuite_D = try #require(plan.steps.map(\.test).first { $0.name == "ImplicitGrandparentSuite_D" })
526+
#expect(implicitGrandparentSuite_D.sourceLocation == implicitParentSuite_D.sourceLocation)
527+
}
528+
}
529+
432530
#if !SWT_NO_SNAPSHOT_TYPES
433531
@Test("Test cases of a disabled test are not evaluated")
434532
func disabledTestCases() async throws {
@@ -486,3 +584,42 @@ private struct BasicRecursiveTrait: SuiteTrait, TestTrait, CustomStringConvertib
486584
self.description = description
487585
}
488586
}
587+
588+
// This fixture must not have an explicit `@Suite` attribute to validate suite
589+
// synthesis. Its children can be `.hidden`, though.
590+
fileprivate struct ImplicitParentSuite_A {
591+
@Test(.hidden) func example() {}
592+
}
593+
594+
fileprivate struct ImplicitGrandparentSuite_B {
595+
// This fixture must not have an explicit `@Suite` attribute to validate suite
596+
// synthesis. Its children can be `.hidden`, though.
597+
struct ImplicitParentSuite_B {
598+
@Suite(.hidden) struct ExplicitChildSuite_B {
599+
@Test func example() {}
600+
}
601+
}
602+
}
603+
604+
@Suite(.hidden) // This one intentionally _does_ have `@Suite`.
605+
fileprivate struct ExplicitGrandparentSuite_C {
606+
// This fixture must not have an explicit `@Suite` attribute to validate suite
607+
// synthesis. Its children can be `.hidden`, though.
608+
struct ImplicitParentSuite_C {
609+
@Suite struct ExplicitChildSuite_C {
610+
@Test func example() {}
611+
}
612+
}
613+
}
614+
615+
// These fixture suites must not have explicit `@Suite` attributes to validate
616+
// suite synthesis.
617+
fileprivate struct ImplicitGrandparentSuite_D {
618+
struct ImplicitParentSuite_D {
619+
struct ImplicitChildSuite_D {
620+
struct ImplicitGrandchildSuite_D {
621+
@Test(.hidden) func example() {}
622+
}
623+
}
624+
}
625+
}

0 commit comments

Comments
 (0)