From d296ba77199a13061d68d2fd2564523a2dd0373a Mon Sep 17 00:00:00 2001 From: Jonathan Grynspan Date: Wed, 18 Mar 2026 15:28:41 -0400 Subject: [PATCH 1/8] Allow inheriting test functions from superclasses used as test suites This PR adds experimental functionality that allows you to write a test function in a test suite of class type, and then inherit that test function in subclasses of the test suite automatically. For example: ```swift class AnimalTests { func makeNoise() -> Noise? { return nil // nothing by default } @Test open func `Animal makes noise`() { #expect(self.makeNoise() != nil) } } class DogTests: AnimalTests { override func makeNoise() -> Noise? { return .bark } } class CatTests: AnimalTests { override func makeNoise() -> Noise? { return .meow } } ``` We leverage the `open` keyword for this purpose for a few reasons: 1. It has no semantic meaning in test suites today, so it's unlikely to be in use already. 2. It implies that there will be subclasses of the class in which it is found. 3. The compiler enforces that it can only be applied to member functions of classes, and can do so with type system knowledge rather than just syntax. We could spell it differently, e.g. with another attribute macro like `@inherited`, but we wouldn't be able to statically determine when it is incorrectly applied to something that isn't a member function of a class. So for the purposes of the experimental feature, `open` it is! This makes the `override` keyword ambiguous, so I've blocked it when used with `@Test`, but there is probably a way to resolve that so we should consider that constraint to be temporary. --- Package.swift | 4 +- .../Testing/ABI/Encoded/ABI.EncodedTest.swift | 3 +- .../Event.HumanReadableOutputRecorder.swift | 15 +- .../Testing/Parameterization/TypeInfo.swift | 8 ++ Sources/Testing/Running/Runner.Plan.swift | 133 +++++++++++++++++- Sources/Testing/Running/Runner.swift | 40 +++--- Sources/Testing/Test+Macro.swift | 50 ++++++- Sources/Testing/Test.swift | 41 +++++- Sources/TestingMacros/CMakeLists.txt | 1 + .../WithModifiersSyntaxAdditions.swift | 62 ++++++++ .../Support/AttributeDiscovery.swift | 5 + .../Support/DiagnosticMessage.swift | 31 +++- .../TestingMacros/TestDeclarationMacro.swift | 45 +++++- .../_TestDiscovery/TestContentRecord.swift | 17 ++- Sources/_TestingInternals/Discovery.cpp | 23 ++- Sources/_TestingInternals/include/Discovery.h | 4 +- .../TestDeclarationMacroTests.swift | 2 +- Tests/TestingTests/InheritanceTests.swift | 38 +++++ .../TestSupport/TestingAdditions.swift | 10 +- 19 files changed, 469 insertions(+), 63 deletions(-) create mode 100644 Sources/TestingMacros/Support/Additions/WithModifiersSyntaxAdditions.swift create mode 100644 Tests/TestingTests/InheritanceTests.swift diff --git a/Package.swift b/Package.swift index 6c42031e4..667d35879 100644 --- a/Package.swift +++ b/Package.swift @@ -159,7 +159,9 @@ let package = Package( "_Testing_WinSDK", "MemorySafeTestingTests", ], - swiftSettings: .packageSettings(isTestTarget: true), + swiftSettings: .packageSettings(isTestTarget: true) + [ + .define("SWIFT_TESTING_EXPERIMENTAL_TEST_INHERITANCE_ENABLED"), + ], linkerSettings: [ .linkedLibrary("util", .when(platforms: [.openbsd])) ] diff --git a/Sources/Testing/ABI/Encoded/ABI.EncodedTest.swift b/Sources/Testing/ABI/Encoded/ABI.EncodedTest.swift index d5d30340c..d633ca7e6 100644 --- a/Sources/Testing/ABI/Encoded/ABI.EncodedTest.swift +++ b/Sources/Testing/ABI/Encoded/ABI.EncodedTest.swift @@ -313,7 +313,8 @@ extension Test { testCases: { () -> Test.Case.Generator> in throw APIMisuseError(description: "This instance of 'Test' was synthesized at runtime and cannot be run directly.") }, - parameters: parameters ?? [] + parameters: parameters ?? [], + isInheritable: false ) } } diff --git a/Sources/Testing/Events/Recorder/Event.HumanReadableOutputRecorder.swift b/Sources/Testing/Events/Recorder/Event.HumanReadableOutputRecorder.swift index 8d4277d75..11da1efd3 100644 --- a/Sources/Testing/Events/Recorder/Event.HumanReadableOutputRecorder.swift +++ b/Sources/Testing/Events/Recorder/Event.HumanReadableOutputRecorder.swift @@ -209,7 +209,7 @@ extension Test { /// /// - Returns: The name of this test, suitable for display to the user. func humanReadableName(withVerbosity verbosity: Int = 0) -> String { - switch displayName { + var result = switch displayName { case let .some(displayName) where verbosity > 0: #""\#(displayName)" (aka '\#(name)')"# case let .some(displayName): @@ -217,6 +217,19 @@ extension Test { default: name } + if isInheritable, let clazz = containingTypeInfo { + let className = if verbosity > 0 { + clazz.fullyQualifiedName + } else { + clazz.unqualifiedName + } + if isInherited { + result = "\(result) (inherited by '\(className)')" + } else { + result = "\(result) (implemented in '\(className)')" + } + } + return result } } diff --git a/Sources/Testing/Parameterization/TypeInfo.swift b/Sources/Testing/Parameterization/TypeInfo.swift index 1ad586f83..21dd8dce5 100644 --- a/Sources/Testing/Parameterization/TypeInfo.swift +++ b/Sources/Testing/Parameterization/TypeInfo.swift @@ -49,6 +49,14 @@ public struct TypeInfo: Sendable { return nil } + var `class`: AnyClass? { + if case let .type(type) = _kind { + // FIXME: casting `any (~).Type` to `AnyClass` warns that it always fails + return unsafeBitCast(type, to: Any.Type.self) as? AnyClass + } + return nil + } + /// Initialize an instance of this type with the specified names. /// /// - Parameters: diff --git a/Sources/Testing/Running/Runner.Plan.swift b/Sources/Testing/Running/Runner.Plan.swift index 05d1b4f0e..fb72a01f4 100644 --- a/Sources/Testing/Running/Runner.Plan.swift +++ b/Sources/Testing/Running/Runner.Plan.swift @@ -8,6 +8,12 @@ // See https://swift.org/CONTRIBUTORS.txt for Swift project authors // +#if _runtime(_ObjC) +private import ObjectiveC +#else +private import _TestDiscovery +#endif + extension Runner { /// A type describing a runner plan. public struct Plan: Sendable { @@ -123,6 +129,116 @@ extension Runner { // MARK: - Constructing a new runner plan extension Runner.Plan { +#if !_runtime(_ObjC) + /// A dictionary keyed by classes whose values are arrays of all known + /// subclasses of those classes. + /// + /// This dictionary is constructed in reverse by walking all known classes in + /// the current process and recursively querying each one for its immediate + /// superclass. This is less efficient than the Objective-C-based + /// implementation (which can avoid realizing classes that aren't of + /// interest to us). + private static let _allSubtypeInfo: [TypeInfo: [TypeInfo]] = { + var result = [TypeInfo: [TypeInfo]]() + + for clazz in allClasses() { + let hierarchy = sequence(first: clazz, next: _getSuperclass) + var subclass: AnyClass? = nil + for clazz in hierarchy { + defer { + subclass = clazz + } + + let typeInfo = TypeInfo(describing: clazz) + if let subclass { + result[typeInfo, default: []].append(TypeInfo(describing: subclass)) + } else { + result[typeInfo, default: []].reserveCapacity(1) + } + } + } + + return result + }() +#endif + + /// Add `open` test functions in base classes to their corresponding + /// subclasses. + /// + /// - Parameters: + /// - testGraph: The graph of tests to modify. + private static func _inheritTestFunctions(in testGraph: inout Graph) { + // First, recursively mark tests as inheritable if they're contained in + // inheritable suites. + func makeInheritableIfNeeded(_ inheritable: Bool, in testGraph: inout Graph, wasSomethingInheritable: inout Bool) { + // Inherit the inheritable flag (yes, really). + if inheritable { + testGraph.value?.isInheritable = true + } + let inheritable = inheritable || (true == testGraph.value?.isInheritable) + + // Track whether anything is inheritable. If at the end of this recursion + // nothing was inheritable, then we don't need to walk the graph again. + wasSomethingInheritable = wasSomethingInheritable || inheritable + + testGraph.children = testGraph.children.mapValues { child in + var child = child + // Inheritance doesn't descend into nested types, so clear the + // inheritable flag when recursing into a child suite. + let inheritableByChild = inheritable && (true != child.value?.isSuite) + makeInheritableIfNeeded(inheritableByChild, in: &child, wasSomethingInheritable: &wasSomethingInheritable) + return child + } + } + + var wasSomethingInheritable = false + makeInheritableIfNeeded(false, in: &testGraph, wasSomethingInheritable: &wasSomethingInheritable) + guard wasSomethingInheritable else { + // No inheritable tests, so we can exit early. + return + } + + // Now go through the graph again and find all tests that are marked as + // inheritable. + let inheritableTests: [Test] = testGraph.compactMap { _, test in + guard let test, test.isInheritable else { + return nil + } + return test + } + + // For each of the discovered, inheritable tests, find the type it's applied + // to (which, by definition, must be a class) and find all subclasses + // thereof. + var allSubtypeInfo: [TypeInfo: [TypeInfo]] +#if _runtime(_ObjC) + allSubtypeInfo = Dictionary( + uniqueKeysWithValues: inheritableTests + .compactMap { $0.test.containingTypeInfo?.class } + .reduce(into: Set()) { baseTypeInfo, clazz in + baseTypeInfo.insert(TypeInfo(describing: clazz)) + }.compactMap { typeInfo in + let subtypeInfo = objc_enumerateClasses(subclassing: typeInfo.class!).map { TypeInfo(describing: $0) } + return (typeInfo, subtypeInfo) + } + ) +#else + allSubtypeInfo = _allSubtypeInfo +#endif + + // For each of the discovered, inheritable tests, make copies of that test + // for each known subclass and insert them into the test graph. + for test in inheritableTests { + for subtypeInfo in allSubtypeInfo[test.containingTypeInfo!]! { + var testCopy = test + testCopy.containingTypeInfo = subtypeInfo + testCopy.isSynthesized = true + testCopy.isInherited = true + testGraph.insertValue(testCopy, at: testCopy.id.keyPathRepresentation) + } + } + } + /// Recursively apply eligible traits from a test suite to its children in a /// graph. /// @@ -314,18 +430,21 @@ extension Runner.Plan { Backtrace.flushThrownErrorCache() } - // Convert the list of test into a graph of steps. The actions for these - // steps will all be .run() *unless* an error was thrown while examining - // them, in which case it will be .recordIssue(). + // Convert the list of test into a graph of steps. let runAction = _runAction var testGraph = Graph() - var actionGraph = Graph(value: runAction) for test in tests { - let idComponents = test.id.keyPathRepresentation - testGraph.insertValue(test, at: idComponents) - actionGraph.insertValue(runAction, at: idComponents, intermediateValue: runAction) + testGraph.insertValue(test, at: test.id.keyPathRepresentation) } + // Synthesize test functions inherited by subclasses. + _inheritTestFunctions(in: &testGraph) + + // Generate the initial action graph. The actions for these steps will all + // be .run() *unless* an error was thrown while examining them, in which + // case they will be .recordIssue(). + var actionGraph = testGraph.mapValues { _, _ in runAction } + // Remove any tests that should be filtered out per the runner's // configuration. The action graph is not modified here: actions that lose // their corresponding tests are effectively filtered out by the call to diff --git a/Sources/Testing/Running/Runner.swift b/Sources/Testing/Running/Runner.swift index 36d42c5c1..0825bc83f 100644 --- a/Sources/Testing/Running/Runner.swift +++ b/Sources/Testing/Running/Runner.swift @@ -370,27 +370,29 @@ extension Runner { private static func _runTestCases(_ testCases: some Sequence, within step: Plan.Step, context: _Context) async { let configuration = _configuration - // Apply the configuration's test case filter. - let testCaseFilter = configuration.testCaseFilter - let testCases = testCases.lazy.filter { testCase in - testCaseFilter(testCase, step.test) - } - - // Figure out how to name child tasks. - let testName = "test \(step.test.humanReadableName())" - let taskNamer: (Int, Test.Case) -> (String, String?)? = if step.test.isParameterized { - { i, _ in (testName, "running test case #\(i + 1)") } - } else { - { _, _ in (testName, "running") } - } + await withCurrentSubclassIfNeeded(for: step.test) { + // Apply the configuration's test case filter. + let testCaseFilter = configuration.testCaseFilter + let testCases = testCases.lazy.filter { testCase in + testCaseFilter(testCase, step.test) + } - await _forEach(in: testCases.enumerated(), namingTasksWith: taskNamer) { _, testCase in - if let testCaseSerializer = context.testCaseSerializer { - // Note that if .serialized is applied to an inner scope, we still use - // this serializer (if set) so that we don't overcommit. - await testCaseSerializer.run { await _runTestCase(testCase, within: step, context: context) } + // Figure out how to name child tasks. + let testName = "test \(step.test.humanReadableName())" + let taskNamer: (Int, Test.Case) -> (String, String?)? = if step.test.isParameterized { + { i, _ in (testName, "running test case #\(i + 1)") } } else { - await _runTestCase(testCase, within: step, context: context) + { _, _ in (testName, "running") } + } + + await _forEach(in: testCases.enumerated(), namingTasksWith: taskNamer) { _, testCase in + if let testCaseSerializer = context.testCaseSerializer { + // Note that if .serialized is applied to an inner scope, we still use + // this serializer (if set) so that we don't overcommit. + await testCaseSerializer.run { await _runTestCase(testCase, within: step, context: context) } + } else { + await _runTestCase(testCase, within: step, context: context) + } } } } diff --git a/Sources/Testing/Test+Macro.swift b/Sources/Testing/Test+Macro.swift index 97fe104de..7066fbbd0 100644 --- a/Sources/Testing/Test+Macro.swift +++ b/Sources/Testing/Test+Macro.swift @@ -162,6 +162,7 @@ extension Test { displayName: String? = nil, traits: [any TestTrait], sourceBounds: __SourceBounds, + isInheritable: Bool = false, parameters: [__Parameter] = [], testFunction: @escaping @Sendable () async throws -> Void ) -> Self where S: ~Copyable & ~Escapable { @@ -173,7 +174,7 @@ extension Test { nil } let caseGenerator = { @Sendable in Case.Generator(testFunction: testFunction) } - return Self(name: testFunctionName, displayName: displayName, traits: traits, sourceBounds: sourceBounds, containingTypeInfo: containingTypeInfo, xcTestCompatibleSelector: xcTestCompatibleSelector, testCases: caseGenerator, parameters: []) + return Self(name: testFunctionName, displayName: displayName, traits: traits, sourceBounds: sourceBounds, containingTypeInfo: containingTypeInfo, xcTestCompatibleSelector: xcTestCompatibleSelector, testCases: caseGenerator, parameters: [], isInheritable: isInheritable) } } @@ -247,6 +248,7 @@ extension Test { traits: [any TestTrait], arguments collection: @escaping @Sendable () async throws -> C, sourceBounds: __SourceBounds, + isInheritable: Bool = false, parameters paramTuples: [__Parameter], testFunction: @escaping @Sendable (C.Element) async throws -> Void ) -> Self where S: ~Copyable & ~Escapable, C: Collection & Sendable, C.Element: Sendable { @@ -257,7 +259,7 @@ extension Test { } let parameters = paramTuples.parameters let caseGenerator = { @Sendable in Case.Generator(arguments: try await collection(), parameters: parameters, testFunction: testFunction) } - return Self(name: testFunctionName, displayName: displayName, traits: traits, sourceBounds: sourceBounds, containingTypeInfo: containingTypeInfo, xcTestCompatibleSelector: xcTestCompatibleSelector, testCases: caseGenerator, parameters: parameters) + return Self(name: testFunctionName, displayName: displayName, traits: traits, sourceBounds: sourceBounds, containingTypeInfo: containingTypeInfo, xcTestCompatibleSelector: xcTestCompatibleSelector, testCases: caseGenerator, parameters: parameters, isInheritable: isInheritable) } } @@ -394,6 +396,7 @@ extension Test { traits: [any TestTrait], arguments collection1: @escaping @Sendable () async throws -> C1, _ collection2: @escaping @Sendable () async throws -> C2, sourceBounds: __SourceBounds, + isInheritable: Bool = false, parameters paramTuples: [__Parameter], testFunction: @escaping @Sendable (C1.Element, C2.Element) async throws -> Void ) -> Self where S: ~Copyable & ~Escapable, C1: Collection & Sendable, C1.Element: Sendable, C2: Collection & Sendable, C2.Element: Sendable { @@ -404,7 +407,7 @@ extension Test { } let parameters = paramTuples.parameters let caseGenerator = { @Sendable in try await Case.Generator(arguments: collection1(), collection2(), parameters: parameters, testFunction: testFunction) } - return Self(name: testFunctionName, displayName: displayName, traits: traits, sourceBounds: sourceBounds, containingTypeInfo: containingTypeInfo, xcTestCompatibleSelector: xcTestCompatibleSelector, testCases: caseGenerator, parameters: parameters) + return Self(name: testFunctionName, displayName: displayName, traits: traits, sourceBounds: sourceBounds, containingTypeInfo: containingTypeInfo, xcTestCompatibleSelector: xcTestCompatibleSelector, testCases: caseGenerator, parameters: parameters, isInheritable: isInheritable) } /// Create an instance of ``Test`` for a parameterized function. @@ -422,6 +425,7 @@ extension Test { traits: [any TestTrait], arguments collection: @escaping @Sendable () async throws -> C, sourceBounds: __SourceBounds, + isInheritable: Bool = false, parameters paramTuples: [__Parameter], testFunction: @escaping @Sendable ((E1, E2)) async throws -> Void ) -> Self where S: ~Copyable & ~Escapable, C: Collection & Sendable, C.Element == (E1, E2), E1: Sendable, E2: Sendable { @@ -432,7 +436,7 @@ extension Test { } let parameters = paramTuples.parameters let caseGenerator = { @Sendable in Case.Generator(arguments: try await collection(), parameters: parameters, testFunction: testFunction) } - return Self(name: testFunctionName, displayName: displayName, traits: traits, sourceBounds: sourceBounds, containingTypeInfo: containingTypeInfo, xcTestCompatibleSelector: xcTestCompatibleSelector, testCases: caseGenerator, parameters: parameters) + return Self(name: testFunctionName, displayName: displayName, traits: traits, sourceBounds: sourceBounds, containingTypeInfo: containingTypeInfo, xcTestCompatibleSelector: xcTestCompatibleSelector, testCases: caseGenerator, parameters: parameters, isInheritable: isInheritable) } /// Create an instance of ``Test`` for a parameterized function. @@ -453,6 +457,7 @@ extension Test { traits: [any TestTrait], arguments dictionary: @escaping @Sendable () async throws -> Dictionary, sourceBounds: __SourceBounds, + isInheritable: Bool = false, parameters paramTuples: [__Parameter], testFunction: @escaping @Sendable ((Key, Value)) async throws -> Void ) -> Self where S: ~Copyable & ~Escapable, Key: Sendable, Value: Sendable { @@ -463,7 +468,7 @@ extension Test { } let parameters = paramTuples.parameters let caseGenerator = { @Sendable in Case.Generator(arguments: try await dictionary(), parameters: parameters, testFunction: testFunction) } - return Self(name: testFunctionName, displayName: displayName, traits: traits, sourceBounds: sourceBounds, containingTypeInfo: containingTypeInfo, xcTestCompatibleSelector: xcTestCompatibleSelector, testCases: caseGenerator, parameters: parameters) + return Self(name: testFunctionName, displayName: displayName, traits: traits, sourceBounds: sourceBounds, containingTypeInfo: containingTypeInfo, xcTestCompatibleSelector: xcTestCompatibleSelector, testCases: caseGenerator, parameters: parameters, isInheritable: isInheritable) } /// Create an instance of ``Test`` for a parameterized function. @@ -478,6 +483,7 @@ extension Test { traits: [any TestTrait], arguments zippedCollections: @escaping @Sendable () async throws -> Zip2Sequence, sourceBounds: __SourceBounds, + isInheritable: Bool = false, parameters paramTuples: [__Parameter], testFunction: @escaping @Sendable (C1.Element, C2.Element) async throws -> Void ) -> Self where S: ~Copyable & ~Escapable, C1: Collection & Sendable, C1.Element: Sendable, C2: Collection & Sendable, C2.Element: Sendable { @@ -492,7 +498,7 @@ extension Test { try await testFunction($0, $1) } } - return Self(name: testFunctionName, displayName: displayName, traits: traits, sourceBounds: sourceBounds, containingTypeInfo: containingTypeInfo, xcTestCompatibleSelector: xcTestCompatibleSelector, testCases: caseGenerator, parameters: parameters) + return Self(name: testFunctionName, displayName: displayName, traits: traits, sourceBounds: sourceBounds, containingTypeInfo: containingTypeInfo, xcTestCompatibleSelector: xcTestCompatibleSelector, testCases: caseGenerator, parameters: parameters, isInheritable: isInheritable) } } @@ -619,3 +625,35 @@ public func __invokeXCTestMethod( issue.record() return true } + +/// The current subclass to use for inherited test functions. +@TaskLocal var currentSubclass: AnyClass? + +/// Set the current subclass to use for inherited test functions. +/// +/// - Parameters: +/// - test: The test to treat as polymorphic. +/// - body: A function to run while the current subclass is set. +/// +/// - Returns: Whatever is returned by `body`. +/// +/// - Throws: Whatever is thrown by `body`. +func withCurrentSubclassIfNeeded(for test: Test, _ body: () async throws -> R) async rethrows -> R { + guard test.isInheritable, let subclass = test.containingTypeInfo?.class else { + return try await body() + } + return try await $currentSubclass.withValue(subclass) { + try await body() + } +} + +public func __currentSubclass(of baseClass: C.Type) throws -> C.Type where C: AnyObject { + if let currentSubclass { + guard let result = currentSubclass as? C.Type else { + throw SystemError(description: "Expected a subclass of '\(baseClass)' to instantiate for the current test, but found '\(currentSubclass)' instead") + } + return result + } else { + throw SystemError(description: "Expected a subclass of '\(baseClass)' to instantiate for the current test, but no class was configured") + } +} diff --git a/Sources/Testing/Test.swift b/Sources/Testing/Test.swift index 4e077f7e6..8847c0c8e 100644 --- a/Sources/Testing/Test.swift +++ b/Sources/Testing/Test.swift @@ -37,6 +37,8 @@ public struct Test: Sendable { var testCasesState: TestCasesState? var parameters: [Parameter]? var isSynthesized: Bool + var isInheritable: Bool + var isInherited: Bool #if DEBUG var mutationCount = 0 #endif @@ -300,6 +302,27 @@ public struct Test: Sendable { } } + /// Whether or not this instance should be copied and inherited by subclasses + /// of ``containingTypeInfo``. + var isInheritable: Bool { + get { + _properties.value.isInheritable + } + set { + _setValue(newValue, forKeyPath: \.isInheritable) + } + } + + /// Whether or not this instance was inherited from a superclass. + var isInherited: Bool { + get { + _properties.value.isInherited + } + set { + _setValue(newValue, forKeyPath: \.isInherited) + } + } + #if DEBUG /// The number of times any property on this instance of ``Test`` has been /// mutated after initialization. @@ -329,7 +352,9 @@ public struct Test: Sendable { traits: traits, sourceBounds: sourceBounds, containingTypeInfo: containingTypeInfo, - isSynthesized: isSynthesized + isSynthesized: isSynthesized, + isInheritable: false, + isInherited: false ) _properties = Allocated(properties) } @@ -343,7 +368,8 @@ public struct Test: Sendable { containingTypeInfo: TypeInfo? = nil, xcTestCompatibleSelector: __XCTestCompatibleSelector? = nil, testCases: @escaping @Sendable () async throws -> Test.Case.Generator, - parameters: [Parameter] + parameters: [Parameter], + isInheritable: Bool ) { let properties = _Properties( name: name, @@ -354,7 +380,9 @@ public struct Test: Sendable { xcTestCompatibleSelector: xcTestCompatibleSelector, testCasesState: .unevaluated { try await testCases() }, parameters: parameters, - isSynthesized: false + isSynthesized: false, + isInheritable: isInheritable, + isInherited: false ) _properties = Allocated(properties) } @@ -368,7 +396,8 @@ public struct Test: Sendable { containingTypeInfo: TypeInfo? = nil, xcTestCompatibleSelector: __XCTestCompatibleSelector? = nil, testCases: Test.Case.Generator, - parameters: [Parameter] + parameters: [Parameter], + isInheritable: Bool ) { let properties = _Properties( name: name, @@ -379,7 +408,9 @@ public struct Test: Sendable { xcTestCompatibleSelector: xcTestCompatibleSelector, testCasesState: .evaluated(testCases), parameters: parameters, - isSynthesized: false + isSynthesized: false, + isInheritable: isInheritable, + isInherited: false ) _properties = Allocated(properties) } diff --git a/Sources/TestingMacros/CMakeLists.txt b/Sources/TestingMacros/CMakeLists.txt index 9908b05f4..415707b35 100644 --- a/Sources/TestingMacros/CMakeLists.txt +++ b/Sources/TestingMacros/CMakeLists.txt @@ -97,6 +97,7 @@ target_sources(TestingMacros PRIVATE Support/Additions/TypeSyntaxProtocolAdditions.swift Support/Additions/VersionTupleSyntaxAdditions.swift Support/Additions/WithAttributesSyntaxAdditions.swift + Support/Additions/WithModifiersSyntaxAdditions.swift Support/Argument.swift Support/AttributeDiscovery.swift Support/AvailabilityGuards.swift diff --git a/Sources/TestingMacros/Support/Additions/WithModifiersSyntaxAdditions.swift b/Sources/TestingMacros/Support/Additions/WithModifiersSyntaxAdditions.swift new file mode 100644 index 000000000..7f07cc2c2 --- /dev/null +++ b/Sources/TestingMacros/Support/Additions/WithModifiersSyntaxAdditions.swift @@ -0,0 +1,62 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for Swift project authors +// + +import SwiftIfConfig +import SwiftSyntax +import SwiftSyntaxMacros + +extension WithModifiersSyntax { + /// The `open` modifier, if any, applied to this declaration. + var openModifier: DeclModifierSyntax? { + modifiers.first { $0.name.tokenKind == .keyword(.open) } + } + + /// The `override` modifier, if any, applied to this declaration. + var overrideModifier: DeclModifierSyntax? { + modifiers.first { $0.name.tokenKind == .keyword(.override) } + } + + /// Check whether or not this declaration is an inheritable test or suite + /// declaration. + /// + /// - Parameters: + /// - context: The macro context in which the expression is being parsed. + /// + /// - Returns: Whether or not this declaration should be inherited by + /// subclasses of this declaration (if it is a class) or subclasses of its + /// containing declaration (if it is a function). + func isInheritableTestDeclaration(in context: some MacroExpansionContext) -> Bool { + var isInheritable = self.openModifier != nil + if !isInheritable, + self.is(FunctionDeclSyntax.self), + let containingTypeDecl = context.lexicalContext.first?.asProtocol((any WithModifiersSyntax).self) { + isInheritable = containingTypeDecl.openModifier != nil + } + if isInheritable && !isTestInheritanceEnabled(in: context) { + // Test inheritance is disabled. + isInheritable = false + } + return isInheritable + } +} + +/// Check whether the experimental test inheritance feature is enabled. +/// +/// - Parameters: +/// - context: The macro context in which the expression is being parsed. +/// +/// - Returns: Whether or not the feature is enabled. +func isTestInheritanceEnabled(in context: some MacroExpansionContext) -> Bool { + if let buildConfiguration = context.buildConfiguration, + let isInheritanceEnabled = try? buildConfiguration.isCustomConditionSet(name: "SWIFT_TESTING_EXPERIMENTAL_TEST_INHERITANCE_ENABLED") { + return isInheritanceEnabled + } + return false +} diff --git a/Sources/TestingMacros/Support/AttributeDiscovery.swift b/Sources/TestingMacros/Support/AttributeDiscovery.swift index c3d19543b..86ddf1c52 100644 --- a/Sources/TestingMacros/Support/AttributeDiscovery.swift +++ b/Sources/TestingMacros/Support/AttributeDiscovery.swift @@ -178,6 +178,11 @@ struct AttributeInfo { arguments.append(Argument(label: "sourceBounds", expression: sourceBounds)) + if let modifiedDecl = declaration.asProtocol((any WithModifiersSyntax).self), + modifiedDecl.isInheritableTestDeclaration(in: context) { + arguments.append(Argument(label: "isInheritable", expression: BooleanLiteralExprSyntax(true))) + } + return LabeledExprListSyntax(arguments) } } diff --git a/Sources/TestingMacros/Support/DiagnosticMessage.swift b/Sources/TestingMacros/Support/DiagnosticMessage.swift index cfd0c98ce..9b790a45c 100644 --- a/Sources/TestingMacros/Support/DiagnosticMessage.swift +++ b/Sources/TestingMacros/Support/DiagnosticMessage.swift @@ -418,10 +418,10 @@ struct DiagnosticMessage: SwiftDiagnostics.DiagnosticMessage { /// - decl: The extension declaration in question. /// /// - Returns: A diagnostic message. - static func attributeHasNoEffect(_ attribute: AttributeSyntax, on decl: ExtensionDeclSyntax) -> Self { + static func attributeHasNoEffect(_ attribute: AttributeSyntax, on decl: some DeclSyntaxProtocol) -> Self { Self( syntax: Syntax(decl), - message: "Attribute \(_macroName(attribute)) has no effect when applied to an extension", + message: "Attribute \(_macroName(attribute)) has no effect when applied to \(_kindString(for: decl, includeA: true))", severity: .error, fixIts: [ FixIt( @@ -732,6 +732,33 @@ struct DiagnosticMessage: SwiftDiagnostics.DiagnosticMessage { ) } + /// Create a diagnostic message stating that the `override` modifier is + /// ambiguous on a test function. + /// + /// - Parameters: + /// - decl: The declaration that has the modifier. + /// - modifier: The unsupported modifier. + /// - attribute: The `@Test` or `@Suite` attribute. + /// + /// - Returns: A diagnostic message. + static func overrideModifier( + _ modifier: DeclModifierSyntax, + isAmbiguousWhenAppliedTo decl: some WithModifiersSyntax, + using attribute: AttributeSyntax + ) -> Self { + Self( + syntax: Syntax(modifier), + message: "Modifier '\(modifier.name.trimmed)' is ambiguous when applied to \(_kindString(for: decl, includeA: true)) with attribute \(_macroName(attribute))", + severity: .warning, + fixIts: [ + FixIt( + message: MacroExpansionFixItMessage("Remove '\(modifier.name.trimmed)'"), + changes: [.replace(oldNode: Syntax(modifier), newNode: Syntax("" as ExprSyntax))] + ), + ] + ) + } + /// Create a diagnostic messages stating that the expression passed to /// `#require()` is ambiguous. /// diff --git a/Sources/TestingMacros/TestDeclarationMacro.swift b/Sources/TestingMacros/TestDeclarationMacro.swift index a5dd78bab..4a6989c32 100644 --- a/Sources/TestingMacros/TestDeclarationMacro.swift +++ b/Sources/TestingMacros/TestDeclarationMacro.swift @@ -86,9 +86,9 @@ public struct TestDeclarationMacro: PeerMacro, Sendable { } // Only one @Test attribute is supported. - let suiteAttributes = function.attributes(named: "Test") - if suiteAttributes.count > 1 { - diagnostics.append(.multipleAttributesNotSupported(suiteAttributes, on: declaration)) + let testAttributes = function.attributes(named: "Test") + if testAttributes.count > 1 { + diagnostics.append(.multipleAttributesNotSupported(testAttributes, on: declaration)) } let parameterList = function.signature.parameterClause.parameters @@ -130,6 +130,14 @@ public struct TestDeclarationMacro: PeerMacro, Sendable { } } + // Check if the declaration has the `override` keyword applied to it. The + // effect of that keyword is ambiguous. + if let modifiedDecl = declaration.asProtocol((any WithModifiersSyntax).self), + let overrideModifier = modifiedDecl.overrideModifier, + isTestInheritanceEnabled(in: context) { + context.diagnose(.overrideModifier(overrideModifier, isAmbiguousWhenAppliedTo: modifiedDecl, using: testAttribute)) + } + return !diagnostics.lazy.map(\.severity).contains(.error) } @@ -253,6 +261,10 @@ public struct TestDeclarationMacro: PeerMacro, Sendable { } } + // Should the thunk be declared generic over subclasses of `typeName`? + let isInheritable = functionDecl.isInheritableTestDeclaration(in: context) + lazy var genericSubclassName = context.makeUniqueName(thunking: functionDecl) + // Generate a thunk function that invokes the actual function. var thunkBody: CodeBlockItemListSyntax if functionDecl.availability(when: .unavailable).first(where: { $0.platformVersion == nil }) != nil { @@ -263,10 +275,15 @@ public struct TestDeclarationMacro: PeerMacro, Sendable { if functionDecl.isStaticOrClass { thunkBody = "_ = \(forwardCall("\(typeName).\(functionDecl.name.trimmed)\(forwardedParamsExpr)"))" } else { - let instanceName = context.makeUniqueName("") + let instanceName = context.makeUniqueName("subclass") let varOrLet = functionDecl.isMutating ? "var" : "let" + let initExpr: ExprSyntax = if isInheritable { + "\(genericSubclassName).init()" + } else { + "\(typeName)()" + } thunkBody = """ - \(raw: varOrLet) \(raw: instanceName) = \(forwardInit("\(typeName)()")) + \(raw: varOrLet) \(raw: instanceName) = \(forwardInit(initExpr)) _ = \(forwardCall("\(raw: instanceName).\(functionDecl.name.trimmed)\(forwardedParamsExpr)")) """ @@ -280,13 +297,27 @@ public struct TestDeclarationMacro: PeerMacro, Sendable { // matches the indexer's heuristic when discovering XCTest functions. let sourceLocationExpr = createSourceLocationExpr(of: functionDecl.name, context: context) + let classExpr: ExprSyntax = if isInheritable { + "\(genericSubclassName)" + } else { + "\(typeName).self" + } thunkBody = """ - if try await Testing.__invokeXCTestMethod(\(selectorExpr), onInstanceOf: \(typeName).self, sourceLocation: \(sourceLocationExpr)) { + if try await Testing.__invokeXCTestMethod(\(selectorExpr), onInstanceOf: \(classExpr), sourceLocation: \(sourceLocationExpr)) { return } \(thunkBody) """ } + + // If this function is inherited by subclasses, acquire the appropriate + // metatype value to initialize. + if isInheritable { + thunkBody = """ + let \(genericSubclassName) = try Testing.__currentSubclass(of: \(typeName).self) + \(thunkBody) + """ + } } } else { thunkBody = "_ = \(forwardCall("\(functionDecl.name.trimmed)\(forwardedParamsExpr)"))" @@ -306,7 +337,7 @@ public struct TestDeclarationMacro: PeerMacro, Sendable { // Get a unique name for this secondary thunk. We don't need it to be // uniqued against functionDecl because it's interior to the "real" thunk, // so its name can't conflict with any other names visible in this scope. - let isolationThunkName = context.makeUniqueName("") + let isolationThunkName = context.makeUniqueName("isolation") // Insert a (defaulted) isolated argument. If we emit a closure (or inner // function) that captured the arguments to the "real" thunk, the compiler diff --git a/Sources/_TestDiscovery/TestContentRecord.swift b/Sources/_TestDiscovery/TestContentRecord.swift index 0f074816d..962484186 100644 --- a/Sources/_TestDiscovery/TestContentRecord.swift +++ b/Sources/_TestDiscovery/TestContentRecord.swift @@ -292,7 +292,7 @@ extension DiscoverableAsTestContent { return SectionBounds.all(.typeMetadata).lazy.flatMap { sb in stride(from: 0, to: sb.buffer.count, by: SWTTypeMetadataRecordByteCount).lazy .map { sb.buffer.baseAddress! + $0 } - .compactMap { swt_getType(fromTypeMetadataRecord: $0, ifNameContains: typeNameHint) } + .compactMap { swt_getType(fromTypeMetadataRecord: $0, ifClassOnly: false, ifNameContains: typeNameHint) } .map { unsafeBitCast($0, to: Any.Type.self) } .compactMap(loader) .filter { $0.kind == kind } @@ -300,4 +300,19 @@ extension DiscoverableAsTestContent { } } } + +#if !hasFeature(Embedded) +/// Get a sequence of all known, non-generic class types in the current process. +/// +/// - Returns: A sequence of classes. +package func allClasses() -> some Sequence { + SectionBounds.all(.typeMetadata).lazy.flatMap { sb in + stride(from: 0, to: sb.buffer.count, by: SWTTypeMetadataRecordByteCount).lazy + .map { sb.buffer.baseAddress! + $0 } + .compactMap { swt_getType(fromTypeMetadataRecord: $0, ifClassOnly: true, ifNameContains: "") } + .map { unsafeBitCast($0, to: Any.Type.self) } + .compactMap { $0 as? AnyClass } + } +} +#endif #endif diff --git a/Sources/_TestingInternals/Discovery.cpp b/Sources/_TestingInternals/Discovery.cpp index 1e70a038d..1a1bb9f92 100644 --- a/Sources/_TestingInternals/Discovery.cpp +++ b/Sources/_TestingInternals/Discovery.cpp @@ -129,6 +129,11 @@ struct SWTTypeContextDescriptor { bool isGeneric(void) const& { return (_flags & 0x80u) != 0; } + + bool isClass(void) const& { + // SEE: ContextDescriptorFlags and ContextDescriptorKind::Class + return (_flags & 0x1Fu) == 16; + } }; /// A type representing a relative pointer to a type descriptor. @@ -161,7 +166,7 @@ struct SWTTypeMetadataRecord { const size_t SWTTypeMetadataRecordByteCount = sizeof(SWTTypeMetadataRecord); -const void *swt_getTypeFromTypeMetadataRecord(const void *recordAddress, const char *nameSubstring) { +const void *swt_getTypeFromTypeMetadataRecord(const void *recordAddress, bool classesOnly, const char *nameSubstring) { auto record = reinterpret_cast(recordAddress); auto contextDescriptor = record->getContextDescriptor(); if (!contextDescriptor) { @@ -174,14 +179,20 @@ const void *swt_getTypeFromTypeMetadataRecord(const void *recordAddress, const c return nullptr; } - // Check that the type's name passes. This will be more expensive than the - // checks above, but should be cheaper than realizing the metadata. - const char *typeName = contextDescriptor->getName(); - bool nameOK = typeName && nullptr != std::strstr(typeName, nameSubstring); - if (!nameOK) { + if (classesOnly && !contextDescriptor->isClass()) { return nullptr; } + if (nameSubstring[0] != '\0') { + // Check that the type's name passes. This will be more expensive than the + // checks above, but should be cheaper than realizing the metadata. + const char *typeName = contextDescriptor->getName(); + bool nameOK = typeName && nullptr != std::strstr(typeName, nameSubstring); + if (!nameOK) { + return nullptr; + } + } + if (void *typeMetadata = contextDescriptor->getMetadata()) { return typeMetadata; } diff --git a/Sources/_TestingInternals/include/Discovery.h b/Sources/_TestingInternals/include/Discovery.h index 25c3603b3..965461db8 100644 --- a/Sources/_TestingInternals/include/Discovery.h +++ b/Sources/_TestingInternals/include/Discovery.h @@ -40,14 +40,16 @@ SWT_EXTERN const size_t SWTTypeMetadataRecordByteCount; /// /// - Parameters: /// - recordAddress: The address of the Swift type metadata record. +/// - classesOnly: Whether or not to only return class types. /// - nameSubstring: A string which the names of matching types contain. /// /// - Returns: A Swift metatype (as `const void *`) or `nullptr` if it wasn't a /// usable type metadata record or its name did not contain `nameSubstring`. SWT_EXTERN const void *_Nullable swt_getTypeFromTypeMetadataRecord( const void *recordAddress, + bool classesOnly, const char *nameSubstring -) SWT_SWIFT_NAME(swt_getType(fromTypeMetadataRecord:ifNameContains:)); +) SWT_SWIFT_NAME(swt_getType(fromTypeMetadataRecord:ifClassOnly:ifNameContains:)); SWT_ASSUME_NONNULL_END diff --git a/Tests/TestingMacrosTests/TestDeclarationMacroTests.swift b/Tests/TestingMacrosTests/TestDeclarationMacroTests.swift index 8131bbb67..cc8225446 100644 --- a/Tests/TestingMacrosTests/TestDeclarationMacroTests.swift +++ b/Tests/TestingMacrosTests/TestDeclarationMacroTests.swift @@ -60,7 +60,7 @@ struct TestDeclarationMacroTests { "@Suite func f() {}": "Attribute 'Suite' cannot be applied to a function", "@Suite extension X {}": - "Attribute 'Suite' has no effect when applied to an extension", + "Attribute 'Suite' has no effect when applied to this extension", "@Test macro m()": "Attribute 'Test' cannot be applied to a macro", "@Test struct S {}": diff --git a/Tests/TestingTests/InheritanceTests.swift b/Tests/TestingTests/InheritanceTests.swift new file mode 100644 index 000000000..afe9d90ea --- /dev/null +++ b/Tests/TestingTests/InheritanceTests.swift @@ -0,0 +1,38 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for Swift project authors +// + +@testable import Testing + +struct `Inherited test function tests` { + open class BaseClass { + var calledTestFunction = false + + @Test func `Invoke inherited test function`() { + calledTestFunction = true + } + + public required init() {} + + deinit { + #expect(calledTestFunction) + } + + class DoesNotInheritBaseClass { + @Test func `This function should not be inherited`() { + #expect(Self.self == DoesNotInheritBaseClass.self) + } + + final class DoesNotInheritDerivedClass: DoesNotInheritBaseClass {} + } + } + + private class DerivedClass: BaseClass {} + private final class TertiaryClass: DerivedClass {} +} diff --git a/Tests/TestingTests/TestSupport/TestingAdditions.swift b/Tests/TestingTests/TestSupport/TestingAdditions.swift index 8758ac9c6..2609cbefe 100644 --- a/Tests/TestingTests/TestSupport/TestingAdditions.swift +++ b/Tests/TestingTests/TestSupport/TestingAdditions.swift @@ -179,7 +179,7 @@ extension Test { ) { let sourceBounds = sourceBounds ?? __SourceBounds(lowerBoundOnly: sourceLocation) let caseGenerator = Case.Generator(testFunction: testFunction) - self.init(name: name, displayName: name, traits: traits, sourceBounds: sourceBounds, containingTypeInfo: nil, testCases: caseGenerator, parameters: []) + self.init(name: name, displayName: name, traits: traits, sourceBounds: sourceBounds, containingTypeInfo: nil, testCases: caseGenerator, parameters: [], isInheritable: false) } /// Initialize an instance of this type with a function or closure to call, @@ -211,7 +211,7 @@ extension Test { ) where C: Collection & Sendable, C.Element: Sendable { let sourceBounds = sourceBounds ?? __SourceBounds(lowerBoundOnly: sourceLocation) let caseGenerator = Case.Generator(arguments: collection, parameters: parameters, testFunction: testFunction) - self.init(name: name, displayName: name, traits: traits, sourceBounds: sourceBounds, containingTypeInfo: nil, testCases: caseGenerator, parameters: parameters) + self.init(name: name, displayName: name, traits: traits, sourceBounds: sourceBounds, containingTypeInfo: nil, testCases: caseGenerator, parameters: parameters, isInheritable: false) } init( @@ -230,7 +230,7 @@ extension Test { let caseGenerator = { @Sendable in Case.Generator(arguments: try await collection(), parameters: parameters, testFunction: testFunction) } - self.init(name: name, displayName: name, traits: traits, sourceBounds: sourceBounds, containingTypeInfo: nil, testCases: caseGenerator, parameters: parameters) + self.init(name: name, displayName: name, traits: traits, sourceBounds: sourceBounds, containingTypeInfo: nil, testCases: caseGenerator, parameters: parameters, isInheritable: false) } /// Initialize an instance of this type with a function or closure to call, @@ -263,7 +263,7 @@ extension Test { ) where C1: Collection & Sendable, C1.Element: Sendable, C2: Collection & Sendable, C2.Element: Sendable { let sourceBounds = sourceBounds ?? __SourceBounds(lowerBoundOnly: sourceLocation) let caseGenerator = Case.Generator(arguments: collection1, collection2, parameters: parameters, testFunction: testFunction) - self.init(name: name, displayName: name, traits: traits, sourceBounds: sourceBounds, containingTypeInfo: nil, testCases: caseGenerator, parameters: parameters) + self.init(name: name, displayName: name, traits: traits, sourceBounds: sourceBounds, containingTypeInfo: nil, testCases: caseGenerator, parameters: parameters, isInheritable: false) } /// Initialize an instance of this type with a function or closure to call, @@ -291,7 +291,7 @@ extension Test { ) where C1: Collection & Sendable, C1.Element: Sendable, C2: Collection & Sendable, C2.Element: Sendable { let sourceBounds = sourceBounds ?? __SourceBounds(lowerBoundOnly: sourceLocation) let caseGenerator = Case.Generator(arguments: zippedCollections, parameters: parameters, testFunction: testFunction) - self.init(name: name, displayName: name, traits: traits, sourceBounds: sourceBounds, containingTypeInfo: nil, testCases: caseGenerator, parameters: parameters) + self.init(name: name, displayName: name, traits: traits, sourceBounds: sourceBounds, containingTypeInfo: nil, testCases: caseGenerator, parameters: parameters, isInheritable: false) } } From 64fbfad848cbb035b193f90ccc1160c36f013c47 Mon Sep 17 00:00:00 2001 From: Jonathan Grynspan Date: Thu, 19 Mar 2026 12:09:47 -0400 Subject: [PATCH 2/8] Make it a dedicated attribute instead --- Package.swift | 4 +- .../Testing/ABI/Encoded/ABI.EncodedTest.swift | 6 +- .../Event.HumanReadableOutputRecorder.swift | 4 +- .../Testing/Parameterization/TypeInfo.swift | 4 +- Sources/Testing/Running/Runner.Plan.swift | 178 +++++++++--------- Sources/Testing/Running/Runner.swift | 4 +- Sources/Testing/Test+Macro.swift | 149 ++++++++++++--- Sources/Testing/Test.swift | 37 ++-- Sources/TestingMacros/CMakeLists.txt | 2 +- .../TestingMacros/PolymorphicSuiteMacro.swift | 81 ++++++++ .../WithModifiersSyntaxAdditions.swift | 62 ------ .../Support/AttributeDiscovery.swift | 6 +- .../DiagnosticMessage+Diagnosing.swift | 2 +- .../Support/DiagnosticMessage.swift | 84 ++++++--- .../Support/EffectfulExpressionHandling.swift | 4 +- .../TestingMacros/TestDeclarationMacro.swift | 53 +++--- Sources/TestingMacros/TestingMacrosMain.swift | 1 + .../_TestDiscovery/TestContentRecord.swift | 2 +- .../ConditionMacroTests.swift | 12 +- .../TestDeclarationMacroTests.swift | 2 +- Tests/TestingTests/AttachmentTests.swift | 96 +++++----- Tests/TestingTests/InheritanceTests.swift | 38 ---- Tests/TestingTests/MiscellaneousTests.swift | 2 +- Tests/TestingTests/PolymorphismTests.swift | 71 +++++++ .../TestSupport/TestingAdditions.swift | 10 +- 25 files changed, 540 insertions(+), 374 deletions(-) create mode 100644 Sources/TestingMacros/PolymorphicSuiteMacro.swift delete mode 100644 Sources/TestingMacros/Support/Additions/WithModifiersSyntaxAdditions.swift delete mode 100644 Tests/TestingTests/InheritanceTests.swift create mode 100644 Tests/TestingTests/PolymorphismTests.swift diff --git a/Package.swift b/Package.swift index 667d35879..6c42031e4 100644 --- a/Package.swift +++ b/Package.swift @@ -159,9 +159,7 @@ let package = Package( "_Testing_WinSDK", "MemorySafeTestingTests", ], - swiftSettings: .packageSettings(isTestTarget: true) + [ - .define("SWIFT_TESTING_EXPERIMENTAL_TEST_INHERITANCE_ENABLED"), - ], + swiftSettings: .packageSettings(isTestTarget: true), linkerSettings: [ .linkedLibrary("util", .when(platforms: [.openbsd])) ] diff --git a/Sources/Testing/ABI/Encoded/ABI.EncodedTest.swift b/Sources/Testing/ABI/Encoded/ABI.EncodedTest.swift index d633ca7e6..e1ae35852 100644 --- a/Sources/Testing/ABI/Encoded/ABI.EncodedTest.swift +++ b/Sources/Testing/ABI/Encoded/ABI.EncodedTest.swift @@ -290,7 +290,8 @@ extension Test { traits: traits, sourceLocation: sourceLocation, containingTypeInfo: typeInfo, - isSynthesized: true + isSynthesized: true, + isPolymorphic: false ) case .function: let parameters = test._parameters.map { parameters in @@ -313,8 +314,7 @@ extension Test { testCases: { () -> Test.Case.Generator> in throw APIMisuseError(description: "This instance of 'Test' was synthesized at runtime and cannot be run directly.") }, - parameters: parameters ?? [], - isInheritable: false + parameters: parameters ?? [] ) } } diff --git a/Sources/Testing/Events/Recorder/Event.HumanReadableOutputRecorder.swift b/Sources/Testing/Events/Recorder/Event.HumanReadableOutputRecorder.swift index 11da1efd3..9a1592026 100644 --- a/Sources/Testing/Events/Recorder/Event.HumanReadableOutputRecorder.swift +++ b/Sources/Testing/Events/Recorder/Event.HumanReadableOutputRecorder.swift @@ -217,13 +217,13 @@ extension Test { default: name } - if isInheritable, let clazz = containingTypeInfo { + if isPolymorphic, let clazz = containingTypeInfo { let className = if verbosity > 0 { clazz.fullyQualifiedName } else { clazz.unqualifiedName } - if isInherited { + if wasInherited { result = "\(result) (inherited by '\(className)')" } else { result = "\(result) (implemented in '\(className)')" diff --git a/Sources/Testing/Parameterization/TypeInfo.swift b/Sources/Testing/Parameterization/TypeInfo.swift index 21dd8dce5..c6fbc01a6 100644 --- a/Sources/Testing/Parameterization/TypeInfo.swift +++ b/Sources/Testing/Parameterization/TypeInfo.swift @@ -61,9 +61,9 @@ public struct TypeInfo: Sendable { /// /// - Parameters: /// - fullyQualifiedNameComponents: The fully-qualified name components of - /// the type. + /// the type. /// - unqualifiedName: The unqualified name of the type. If `nil`, the last - /// string in `fullyQualifiedNameComponents` is used instead. + /// string in `fullyQualifiedNameComponents` is used instead. /// - mangled: The mangled name of the type, if available. init(fullyQualifiedNameComponents: [String], unqualifiedName: String? = nil, mangledName: String? = nil) { let unqualifiedName = unqualifiedName ?? fullyQualifiedNameComponents.last ?? fullyQualifiedNameComponents.joined(separator: ".") diff --git a/Sources/Testing/Running/Runner.Plan.swift b/Sources/Testing/Running/Runner.Plan.swift index fb72a01f4..73abc2078 100644 --- a/Sources/Testing/Running/Runner.Plan.swift +++ b/Sources/Testing/Running/Runner.Plan.swift @@ -162,83 +162,6 @@ extension Runner.Plan { }() #endif - /// Add `open` test functions in base classes to their corresponding - /// subclasses. - /// - /// - Parameters: - /// - testGraph: The graph of tests to modify. - private static func _inheritTestFunctions(in testGraph: inout Graph) { - // First, recursively mark tests as inheritable if they're contained in - // inheritable suites. - func makeInheritableIfNeeded(_ inheritable: Bool, in testGraph: inout Graph, wasSomethingInheritable: inout Bool) { - // Inherit the inheritable flag (yes, really). - if inheritable { - testGraph.value?.isInheritable = true - } - let inheritable = inheritable || (true == testGraph.value?.isInheritable) - - // Track whether anything is inheritable. If at the end of this recursion - // nothing was inheritable, then we don't need to walk the graph again. - wasSomethingInheritable = wasSomethingInheritable || inheritable - - testGraph.children = testGraph.children.mapValues { child in - var child = child - // Inheritance doesn't descend into nested types, so clear the - // inheritable flag when recursing into a child suite. - let inheritableByChild = inheritable && (true != child.value?.isSuite) - makeInheritableIfNeeded(inheritableByChild, in: &child, wasSomethingInheritable: &wasSomethingInheritable) - return child - } - } - - var wasSomethingInheritable = false - makeInheritableIfNeeded(false, in: &testGraph, wasSomethingInheritable: &wasSomethingInheritable) - guard wasSomethingInheritable else { - // No inheritable tests, so we can exit early. - return - } - - // Now go through the graph again and find all tests that are marked as - // inheritable. - let inheritableTests: [Test] = testGraph.compactMap { _, test in - guard let test, test.isInheritable else { - return nil - } - return test - } - - // For each of the discovered, inheritable tests, find the type it's applied - // to (which, by definition, must be a class) and find all subclasses - // thereof. - var allSubtypeInfo: [TypeInfo: [TypeInfo]] -#if _runtime(_ObjC) - allSubtypeInfo = Dictionary( - uniqueKeysWithValues: inheritableTests - .compactMap { $0.test.containingTypeInfo?.class } - .reduce(into: Set()) { baseTypeInfo, clazz in - baseTypeInfo.insert(TypeInfo(describing: clazz)) - }.compactMap { typeInfo in - let subtypeInfo = objc_enumerateClasses(subclassing: typeInfo.class!).map { TypeInfo(describing: $0) } - return (typeInfo, subtypeInfo) - } - ) -#else - allSubtypeInfo = _allSubtypeInfo -#endif - - // For each of the discovered, inheritable tests, make copies of that test - // for each known subclass and insert them into the test graph. - for test in inheritableTests { - for subtypeInfo in allSubtypeInfo[test.containingTypeInfo!]! { - var testCopy = test - testCopy.containingTypeInfo = subtypeInfo - testCopy.isSynthesized = true - testCopy.isInherited = true - testGraph.insertValue(testCopy, at: testCopy.id.keyPathRepresentation) - } - } - } - /// Recursively apply eligible traits from a test suite to its children in a /// graph. /// @@ -301,7 +224,7 @@ extension Runner.Plan { // source location, so we use the source location of a close descendant // test. We do this instead of falling back to some "unknown" // placeholder in an attempt to preserve the correct sort ordering. - graph.value = Test(traits: [], sourceLocation: sourceLocation, containingTypeInfo: typeInfo, isSynthesized: true) + graph.value = Test(traits: [], sourceLocation: sourceLocation, containingTypeInfo: typeInfo, isSynthesized: true, isPolymorphic: false) } } @@ -309,6 +232,88 @@ extension Runner.Plan { synthesizeSuites(in: &graph, sourceLocation: &sourceLocation) } + /// Add copies of test functions in `@polymorphic` suites to all known + /// subclasses of said suites. + /// + /// - Parameters: + /// - testGraph: The graph of tests to modify. + private static func _synthesizePolymorphicTests(in testGraph: inout Graph) { + // First, recursively mark tests as polymorphic if they're contained in + // polymorphic suites. + var polymorphicTests = [Test]() + func makePolymorphicIfNeeded(_ makePolymorphic: Bool, in testGraph: inout Graph) { + var makeChildrenPolymorphic = false + if var test = testGraph.value.take() { + if test.isSuite { + // If this test is a polymorphic suite, mark all its child test + // functions (and not nested suites!) as polymorphic too. + makeChildrenPolymorphic = test.isPolymorphic + } else { + if makePolymorphic { + // This is a test function and the parent wants to make its children + // polymorphic, so set the flag. + test.isPolymorphic = true + } + } + + // Gather polymorphic tests as we go. If at the end of this recursion + // there were no polymorphic tests found, then we don't need to walk the + // graph again and the transformations below become no-ops. + if test.isPolymorphic { + polymorphicTests.append(test) + } + testGraph.value = test + } else { + // This node is sparse, so propagate the makePolymorphic flag down + // through it (this is generally expected). + makeChildrenPolymorphic = makePolymorphic + } + + testGraph.children = testGraph.children.mapValues { child in + var child = child + makePolymorphicIfNeeded(makeChildrenPolymorphic, in: &child) + return child + } + } + makePolymorphicIfNeeded(false, in: &testGraph) + + // For each of the discovered, polymorphic tests, find the type it's applied + // to (which, by definition, must be a class) and find all subclasses + // thereof. + var allSubtypeInfo: [TypeInfo: [TypeInfo]] +#if _runtime(_ObjC) + allSubtypeInfo = Dictionary( + uniqueKeysWithValues: polymorphicTests + .compactMap { $0.test.containingTypeInfo?.class } + .reduce(into: Set()) { baseTypeInfo, clazz in + baseTypeInfo.insert(TypeInfo(describing: clazz)) + }.compactMap { typeInfo in + let clazz: AnyClass = typeInfo.class! + let subtypeInfo = objc_enumerateClasses(subclassing: clazz) + .map { TypeInfo(describing: $0) } + return (typeInfo, subtypeInfo) + } + ) +#else + allSubtypeInfo = _allSubtypeInfo +#endif + + // For each of the discovered, polymorphic tests, make copies of that test + // for each known subclass and insert them into the test graph. + for test in polymorphicTests { + for subtypeInfo in allSubtypeInfo[test.containingTypeInfo!]! { + var testCopy = test + testCopy.containingTypeInfo = subtypeInfo + if testCopy.isSuite { + testCopy.name = subtypeInfo.unqualifiedName + } + testCopy.isSynthesized = true + testCopy.wasInherited = true + testGraph.insertValue(testCopy, at: testCopy.id.keyPathRepresentation) + } + } + } + /// Given an array of tests, synthesize any containing suites that are not /// already represented in that array. /// @@ -437,14 +442,6 @@ extension Runner.Plan { testGraph.insertValue(test, at: test.id.keyPathRepresentation) } - // Synthesize test functions inherited by subclasses. - _inheritTestFunctions(in: &testGraph) - - // Generate the initial action graph. The actions for these steps will all - // be .run() *unless* an error was thrown while examining them, in which - // case they will be .recordIssue(). - var actionGraph = testGraph.mapValues { _, _ in runAction } - // Remove any tests that should be filtered out per the runner's // configuration. The action graph is not modified here: actions that lose // their corresponding tests are effectively filtered out by the call to @@ -465,6 +462,15 @@ extension Runner.Plan { // Synthesize suites for nodes in the test graph for which they are missing. _recursivelySynthesizeSuites(in: &testGraph) + // Synthesize test functions inherited by subclasses of polymorphic test + // suites. + _synthesizePolymorphicTests(in: &testGraph) + + // Generate the initial action graph. The actions for these steps will all + // be .run() *unless* an error was thrown while examining them, in which + // case they will be .recordIssue(). + var actionGraph = testGraph.mapValues { _, _ in runAction } + // Recursively apply all recursive suite traits to children. // // This must be done _before_ calling `prepare(for:)` on the traits below. diff --git a/Sources/Testing/Running/Runner.swift b/Sources/Testing/Running/Runner.swift index 0825bc83f..932e6dcf9 100644 --- a/Sources/Testing/Running/Runner.swift +++ b/Sources/Testing/Running/Runner.swift @@ -175,7 +175,7 @@ extension Runner { /// - body: The function to invoke. /// /// - Throws: Whatever is thrown by `body`. - private static func _forEach( + private nonisolated(nonsending) static func _forEach( in sequence: some Sequence, namingTasksWith taskNamer: (borrowing E) -> (taskName: String, action: String?)?, _ body: @Sendable @escaping (borrowing E) async throws -> Void @@ -370,7 +370,7 @@ extension Runner { private static func _runTestCases(_ testCases: some Sequence, within step: Plan.Step, context: _Context) async { let configuration = _configuration - await withCurrentSubclassIfNeeded(for: step.test) { + await withCurrentPolymorphicSubclassIfNeeded(for: step.test) { // Apply the configuration's test case filter. let testCaseFilter = configuration.testCaseFilter let testCases = testCases.lazy.filter { testCase in diff --git a/Sources/Testing/Test+Macro.swift b/Sources/Testing/Test+Macro.swift index 7066fbbd0..450609e7a 100644 --- a/Sources/Testing/Test+Macro.swift +++ b/Sources/Testing/Test+Macro.swift @@ -107,10 +107,11 @@ extension Test { _ containingType: S.Type, displayName: String? = nil, traits: [any SuiteTrait], - sourceBounds: __SourceBounds + sourceBounds: __SourceBounds, + isPolymorphic: Bool = false ) -> Self where S: ~Copyable & ~Escapable { let containingTypeInfo = TypeInfo(describing: containingType) - return Self(displayName: displayName, traits: traits, sourceLocation: sourceBounds.lowerBound, containingTypeInfo: containingTypeInfo) + return Self(displayName: displayName, traits: traits, sourceLocation: sourceBounds.lowerBound, containingTypeInfo: containingTypeInfo, isPolymorphic: isPolymorphic) } } @@ -162,7 +163,6 @@ extension Test { displayName: String? = nil, traits: [any TestTrait], sourceBounds: __SourceBounds, - isInheritable: Bool = false, parameters: [__Parameter] = [], testFunction: @escaping @Sendable () async throws -> Void ) -> Self where S: ~Copyable & ~Escapable { @@ -174,7 +174,7 @@ extension Test { nil } let caseGenerator = { @Sendable in Case.Generator(testFunction: testFunction) } - return Self(name: testFunctionName, displayName: displayName, traits: traits, sourceBounds: sourceBounds, containingTypeInfo: containingTypeInfo, xcTestCompatibleSelector: xcTestCompatibleSelector, testCases: caseGenerator, parameters: [], isInheritable: isInheritable) + return Self(name: testFunctionName, displayName: displayName, traits: traits, sourceBounds: sourceBounds, containingTypeInfo: containingTypeInfo, xcTestCompatibleSelector: xcTestCompatibleSelector, testCases: caseGenerator, parameters: []) } } @@ -248,7 +248,6 @@ extension Test { traits: [any TestTrait], arguments collection: @escaping @Sendable () async throws -> C, sourceBounds: __SourceBounds, - isInheritable: Bool = false, parameters paramTuples: [__Parameter], testFunction: @escaping @Sendable (C.Element) async throws -> Void ) -> Self where S: ~Copyable & ~Escapable, C: Collection & Sendable, C.Element: Sendable { @@ -259,7 +258,7 @@ extension Test { } let parameters = paramTuples.parameters let caseGenerator = { @Sendable in Case.Generator(arguments: try await collection(), parameters: parameters, testFunction: testFunction) } - return Self(name: testFunctionName, displayName: displayName, traits: traits, sourceBounds: sourceBounds, containingTypeInfo: containingTypeInfo, xcTestCompatibleSelector: xcTestCompatibleSelector, testCases: caseGenerator, parameters: parameters, isInheritable: isInheritable) + return Self(name: testFunctionName, displayName: displayName, traits: traits, sourceBounds: sourceBounds, containingTypeInfo: containingTypeInfo, xcTestCompatibleSelector: xcTestCompatibleSelector, testCases: caseGenerator, parameters: parameters) } } @@ -396,7 +395,6 @@ extension Test { traits: [any TestTrait], arguments collection1: @escaping @Sendable () async throws -> C1, _ collection2: @escaping @Sendable () async throws -> C2, sourceBounds: __SourceBounds, - isInheritable: Bool = false, parameters paramTuples: [__Parameter], testFunction: @escaping @Sendable (C1.Element, C2.Element) async throws -> Void ) -> Self where S: ~Copyable & ~Escapable, C1: Collection & Sendable, C1.Element: Sendable, C2: Collection & Sendable, C2.Element: Sendable { @@ -407,7 +405,7 @@ extension Test { } let parameters = paramTuples.parameters let caseGenerator = { @Sendable in try await Case.Generator(arguments: collection1(), collection2(), parameters: parameters, testFunction: testFunction) } - return Self(name: testFunctionName, displayName: displayName, traits: traits, sourceBounds: sourceBounds, containingTypeInfo: containingTypeInfo, xcTestCompatibleSelector: xcTestCompatibleSelector, testCases: caseGenerator, parameters: parameters, isInheritable: isInheritable) + return Self(name: testFunctionName, displayName: displayName, traits: traits, sourceBounds: sourceBounds, containingTypeInfo: containingTypeInfo, xcTestCompatibleSelector: xcTestCompatibleSelector, testCases: caseGenerator, parameters: parameters) } /// Create an instance of ``Test`` for a parameterized function. @@ -425,7 +423,6 @@ extension Test { traits: [any TestTrait], arguments collection: @escaping @Sendable () async throws -> C, sourceBounds: __SourceBounds, - isInheritable: Bool = false, parameters paramTuples: [__Parameter], testFunction: @escaping @Sendable ((E1, E2)) async throws -> Void ) -> Self where S: ~Copyable & ~Escapable, C: Collection & Sendable, C.Element == (E1, E2), E1: Sendable, E2: Sendable { @@ -436,7 +433,7 @@ extension Test { } let parameters = paramTuples.parameters let caseGenerator = { @Sendable in Case.Generator(arguments: try await collection(), parameters: parameters, testFunction: testFunction) } - return Self(name: testFunctionName, displayName: displayName, traits: traits, sourceBounds: sourceBounds, containingTypeInfo: containingTypeInfo, xcTestCompatibleSelector: xcTestCompatibleSelector, testCases: caseGenerator, parameters: parameters, isInheritable: isInheritable) + return Self(name: testFunctionName, displayName: displayName, traits: traits, sourceBounds: sourceBounds, containingTypeInfo: containingTypeInfo, xcTestCompatibleSelector: xcTestCompatibleSelector, testCases: caseGenerator, parameters: parameters) } /// Create an instance of ``Test`` for a parameterized function. @@ -457,7 +454,6 @@ extension Test { traits: [any TestTrait], arguments dictionary: @escaping @Sendable () async throws -> Dictionary, sourceBounds: __SourceBounds, - isInheritable: Bool = false, parameters paramTuples: [__Parameter], testFunction: @escaping @Sendable ((Key, Value)) async throws -> Void ) -> Self where S: ~Copyable & ~Escapable, Key: Sendable, Value: Sendable { @@ -468,7 +464,7 @@ extension Test { } let parameters = paramTuples.parameters let caseGenerator = { @Sendable in Case.Generator(arguments: try await dictionary(), parameters: parameters, testFunction: testFunction) } - return Self(name: testFunctionName, displayName: displayName, traits: traits, sourceBounds: sourceBounds, containingTypeInfo: containingTypeInfo, xcTestCompatibleSelector: xcTestCompatibleSelector, testCases: caseGenerator, parameters: parameters, isInheritable: isInheritable) + return Self(name: testFunctionName, displayName: displayName, traits: traits, sourceBounds: sourceBounds, containingTypeInfo: containingTypeInfo, xcTestCompatibleSelector: xcTestCompatibleSelector, testCases: caseGenerator, parameters: parameters) } /// Create an instance of ``Test`` for a parameterized function. @@ -483,7 +479,6 @@ extension Test { traits: [any TestTrait], arguments zippedCollections: @escaping @Sendable () async throws -> Zip2Sequence, sourceBounds: __SourceBounds, - isInheritable: Bool = false, parameters paramTuples: [__Parameter], testFunction: @escaping @Sendable (C1.Element, C2.Element) async throws -> Void ) -> Self where S: ~Copyable & ~Escapable, C1: Collection & Sendable, C1.Element: Sendable, C2: Collection & Sendable, C2.Element: Sendable { @@ -498,7 +493,7 @@ extension Test { try await testFunction($0, $1) } } - return Self(name: testFunctionName, displayName: displayName, traits: traits, sourceBounds: sourceBounds, containingTypeInfo: containingTypeInfo, xcTestCompatibleSelector: xcTestCompatibleSelector, testCases: caseGenerator, parameters: parameters, isInheritable: isInheritable) + return Self(name: testFunctionName, displayName: displayName, traits: traits, sourceBounds: sourceBounds, containingTypeInfo: containingTypeInfo, xcTestCompatibleSelector: xcTestCompatibleSelector, testCases: caseGenerator, parameters: parameters) } } @@ -542,9 +537,9 @@ extension Test { /// /// - Warning: This function is used to implement the `@Test` macro. Do not use /// it directly. -@_lifetime(copy value) +@_lifetime(immortal) @inlinable public func __requiringTry(_ value: consuming T) throws -> T where T: ~Copyable & ~Escapable { - value + _overrideLifetime(value, copying: ()) } /// A function that abstracts away whether or not the `await` keyword is needed @@ -552,9 +547,9 @@ extension Test { /// /// - Warning: This function is used to implement the `@Test` macro. Do not use /// it directly. -@_lifetime(copy value) +@_lifetime(immortal) @inlinable public func __requiringAwait(_ value: consuming T, isolation: isolated (any Actor)? = #isolation) async -> T where T: ~Copyable & ~Escapable { - value + _overrideLifetime(value, copying: ()) } /// A function that abstracts away whether or not the `unsafe` keyword is needed @@ -562,9 +557,9 @@ extension Test { /// /// - Warning: This function is used to implement the `@Test` macro. Do not use /// it directly. -@_lifetime(copy value) +@_lifetime(immortal) @unsafe @inlinable public func __requiringUnsafe(_ value: consuming T) -> T where T: ~Copyable & ~Escapable { - value + _overrideLifetime(value, copying: ()) } /// The current default isolation context. @@ -626,8 +621,70 @@ public func __invokeXCTestMethod( return true } +// MARK: - Test inheritance + +public protocol __PolymorphicSuite: AnyObject & SendableMetatype { + init() async throws +} + +/// An attribute that marks a test suite as being polymorphic. +/// +/// When you apply this attribute to a class that you're using as a test suite, +/// the testing library looks up all subclasses of that class at runtime, makes +/// copies of all test functions in the test suite, and adds them to each of +/// those subclasses as distinct test functions. +/// +/// - Note A polymorphic test suite must be a class, and you must also apply the +/// ``Suite(_:_:)`` attribute to it. +/// +/// You can use this attribute when you want to share test functions among +/// multiple suites. The testing library will automatically create copies of any +/// test functions in the suite for all non-generic subclasses, subclasses of +/// subclasses, and so on. For example: +/// +/// ```swift +/// @polymorphic @Suite class IngredientTests { +/// open var ingredient: (any Ingredient)? { nil } +/// +/// @Test func `This ingredient is fresh`() throws { +/// guard let ingredient = self.ingredient else { +/// try Test.cancel() +/// } +/// #expect(ingredient.expirationDate > .now) +/// } +/// } +/// +/// final class LettuceTests: IngredientTests { +/// override var ingredient: (any Ingredient)? { Lettuce() } +/// } +/// +/// class CheeseTests: IngredientTests {} +/// +/// final class CheddarCheeseTests: IngredientTests { +/// override var ingredient: (any Ingredient)? { CheddarCheese() } +/// } +/// +/// final class SwissCheeseTests: IngredientTests { +/// override var ingredient: (any Ingredient)? { SwissCheese() } +/// } +/// ``` +/// +/// When you run this test target, the testing library will run the +/// \``This ingredient is fresh`\` test function on an instance of +/// `IngredientTests`, `LettuceTests`, `CheeseTests`, `CheddarCheeseTests`, and +/// `SwissCheeseTests`. +/// +/// In this example, we have configured the test function to cancel itself when +/// the value of the `ingredient` property is `nil`. +/// +/// - Important: If you subclass a polymorphic test suite with a generic +/// subclass, the testing library ignores that subclass when you run your +/// tests. +@_spi(Experimental) +@attached(extension, conformances: __PolymorphicSuite) public macro polymorphic() = #externalMacro(module: "TestingMacros", type: "PolymorphicSuiteMacro") + /// The current subclass to use for inherited test functions. -@TaskLocal var currentSubclass: AnyClass? +@TaskLocal private var _currentPolymorphicSubclass: (any __PolymorphicSuite.Type)? /// Set the current subclass to use for inherited test functions. /// @@ -638,22 +695,54 @@ public func __invokeXCTestMethod( /// - Returns: Whatever is returned by `body`. /// /// - Throws: Whatever is thrown by `body`. -func withCurrentSubclassIfNeeded(for test: Test, _ body: () async throws -> R) async rethrows -> R { - guard test.isInheritable, let subclass = test.containingTypeInfo?.class else { +nonisolated(nonsending) func withCurrentPolymorphicSubclassIfNeeded(for test: Test, _ body: nonisolated(nonsending) () async throws -> R) async rethrows -> R { + guard test.isPolymorphic, + let clazz = test.containingTypeInfo?.class, + let polymorphicClass = clazz as? any __PolymorphicSuite.Type else { return try await body() } - return try await $currentSubclass.withValue(subclass) { + return try await $_currentPolymorphicSubclass.withValue(polymorphicClass) { try await body() } } -public func __currentSubclass(of baseClass: C.Type) throws -> C.Type where C: AnyObject { - if let currentSubclass { - guard let result = currentSubclass as? C.Type else { - throw SystemError(description: "Expected a subclass of '\(baseClass)' to instantiate for the current test, but found '\(currentSubclass)' instead") +/// Initialize an instance of a type to be used as the `self` argument of a test +/// function. +/// +/// - Parameters: +/// - initExpr: A closure to invoke that creates an instance of the type `T`. +/// +/// - Returns: An instance of the type `T` returned from `initExpr`. +/// +/// - Throws: Whatever is thrown by `initExpr`. +/// +/// - Warning: This function is used to implement the `@Test` macro. Do not call +/// it directly. +@_lifetime(immortal) +public nonisolated(nonsending) func __initialize(_ initExpr: nonisolated(nonsending) () async throws -> T) async throws -> T where T: ~Copyable & ~Escapable { + try await _overrideLifetime(initExpr(), copying: ()) +} + +/// Initialize an instance of a subclass of some class `T` to be used as the +/// `self` argument of a polymorphic test function. +/// +/// - Parameters: +/// - initExpr: Unused. +/// +/// - Returns: An instance of the current subclass of class `T` (or `T` itself). +/// +/// - Throws: Any error that prevented instantiating a subclass of `T`. +/// +/// - Warning: This function is used to implement the `@Test` macro. Do not call +/// it directly. +@_spi(Experimental) +public nonisolated(nonsending) func __initialize(_ initExpr: nonisolated(nonsending) () async throws -> C) async throws -> C where C: __PolymorphicSuite { + if let _currentPolymorphicSubclass { + guard let result = _currentPolymorphicSubclass as? C.Type else { + throw SystemError(description: "Expected a subclass of '\(C.self)' to instantiate for the current test, but found '\(_currentPolymorphicSubclass)' instead") } - return result + return try await result.init() } else { - throw SystemError(description: "Expected a subclass of '\(baseClass)' to instantiate for the current test, but no class was configured") + throw SystemError(description: "Expected a subclass of '\(C.self)' to instantiate for the current test, but no class was configured") } } diff --git a/Sources/Testing/Test.swift b/Sources/Testing/Test.swift index 8847c0c8e..f7f4976ba 100644 --- a/Sources/Testing/Test.swift +++ b/Sources/Testing/Test.swift @@ -37,8 +37,8 @@ public struct Test: Sendable { var testCasesState: TestCasesState? var parameters: [Parameter]? var isSynthesized: Bool - var isInheritable: Bool - var isInherited: Bool + var isPolymorphic: Bool + var wasInherited: Bool #if DEBUG var mutationCount = 0 #endif @@ -304,22 +304,22 @@ public struct Test: Sendable { /// Whether or not this instance should be copied and inherited by subclasses /// of ``containingTypeInfo``. - var isInheritable: Bool { + var isPolymorphic: Bool { get { - _properties.value.isInheritable + _properties.value.isPolymorphic } set { - _setValue(newValue, forKeyPath: \.isInheritable) + _setValue(newValue, forKeyPath: \.isPolymorphic) } } /// Whether or not this instance was inherited from a superclass. - var isInherited: Bool { + var wasInherited: Bool { get { - _properties.value.isInherited + _properties.value.wasInherited } set { - _setValue(newValue, forKeyPath: \.isInherited) + _setValue(newValue, forKeyPath: \.wasInherited) } } @@ -337,7 +337,8 @@ public struct Test: Sendable { traits: [any Trait], sourceLocation: SourceLocation, containingTypeInfo: TypeInfo, - isSynthesized: Bool = false + isSynthesized: Bool = false, + isPolymorphic: Bool ) { let name = containingTypeInfo.unqualifiedName var displayName = displayName @@ -353,8 +354,8 @@ public struct Test: Sendable { sourceBounds: sourceBounds, containingTypeInfo: containingTypeInfo, isSynthesized: isSynthesized, - isInheritable: false, - isInherited: false + isPolymorphic: isPolymorphic, + wasInherited: false ) _properties = Allocated(properties) } @@ -368,8 +369,7 @@ public struct Test: Sendable { containingTypeInfo: TypeInfo? = nil, xcTestCompatibleSelector: __XCTestCompatibleSelector? = nil, testCases: @escaping @Sendable () async throws -> Test.Case.Generator, - parameters: [Parameter], - isInheritable: Bool + parameters: [Parameter] ) { let properties = _Properties( name: name, @@ -381,8 +381,8 @@ public struct Test: Sendable { testCasesState: .unevaluated { try await testCases() }, parameters: parameters, isSynthesized: false, - isInheritable: isInheritable, - isInherited: false + isPolymorphic: false, + wasInherited: false ) _properties = Allocated(properties) } @@ -396,8 +396,7 @@ public struct Test: Sendable { containingTypeInfo: TypeInfo? = nil, xcTestCompatibleSelector: __XCTestCompatibleSelector? = nil, testCases: Test.Case.Generator, - parameters: [Parameter], - isInheritable: Bool + parameters: [Parameter] ) { let properties = _Properties( name: name, @@ -409,8 +408,8 @@ public struct Test: Sendable { testCasesState: .evaluated(testCases), parameters: parameters, isSynthesized: false, - isInheritable: isInheritable, - isInherited: false + isPolymorphic: false, + wasInherited: false ) _properties = Allocated(properties) } diff --git a/Sources/TestingMacros/CMakeLists.txt b/Sources/TestingMacros/CMakeLists.txt index 415707b35..919ed5dd8 100644 --- a/Sources/TestingMacros/CMakeLists.txt +++ b/Sources/TestingMacros/CMakeLists.txt @@ -84,6 +84,7 @@ endif() target_sources(TestingMacros PRIVATE ConditionMacro.swift ExitTestCapturedValueMacro.swift + PolymorphicSuiteMacro.swift PragmaMacro.swift SourceLocationMacro.swift SuiteDeclarationMacro.swift @@ -97,7 +98,6 @@ target_sources(TestingMacros PRIVATE Support/Additions/TypeSyntaxProtocolAdditions.swift Support/Additions/VersionTupleSyntaxAdditions.swift Support/Additions/WithAttributesSyntaxAdditions.swift - Support/Additions/WithModifiersSyntaxAdditions.swift Support/Argument.swift Support/AttributeDiscovery.swift Support/AvailabilityGuards.swift diff --git a/Sources/TestingMacros/PolymorphicSuiteMacro.swift b/Sources/TestingMacros/PolymorphicSuiteMacro.swift new file mode 100644 index 000000000..2ea49f4f8 --- /dev/null +++ b/Sources/TestingMacros/PolymorphicSuiteMacro.swift @@ -0,0 +1,81 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2024 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for Swift project authors +// + +import SwiftDiagnostics +public import SwiftSyntax +import SwiftSyntaxBuilder +public import SwiftSyntaxMacros + +/// A type describing the expansion of the `@polymorphic` attribute macro. +/// +/// This type is used to implement the `@polymorphic` attribute macro. Do not +/// use it directly. +public struct PolymorphicSuiteMacro: ExtensionMacro, Sendable { + public static func expansion( + of node: AttributeSyntax, + attachedTo declaration: some DeclGroupSyntax, + providingExtensionsOf type: some TypeSyntaxProtocol, + conformingTo protocols: [TypeSyntax], + in context: some MacroExpansionContext + ) throws -> [ExtensionDeclSyntax] { + guard _diagnoseIssues(with: declaration, polymorphicAttribute: node, in: context) else { + return [] + } + + let extensionDecl: DeclSyntax = """ + extension \(type.trimmed) : Testing.__PolymorphicSuite {} + """ + return [extensionDecl.cast(ExtensionDeclSyntax.self)] + } + + /// Diagnose issues with the `@polymorphic` attribute. + /// + /// - Parameters: + /// - decl: The declaration to diagnose. + /// - polymorphicAttribute: The `@polymorphic` attribute applied to `decl`. + /// - context: The macro context in which the expression is being parsed. + /// + /// - Returns: Whether or not macro expansion should continue (i.e. stopping + /// if a fatal error was diagnosed.) + private static func _diagnoseIssues( + with decl: some DeclSyntaxProtocol, + polymorphicAttribute: AttributeSyntax, + in context: some MacroExpansionContext + ) -> Bool { + var diagnostics = [DiagnosticMessage]() + defer { + context.diagnose(diagnostics) + } + + // The @polymorphic attribute is only supported on class and actor + // declarations. (Note that extension macros cannot be applied to extension + // declarations, so we don't need to think about them). + guard decl.kind == .classDecl || decl.kind == .actorDecl else { + diagnostics.append(.attributeNotSupported(polymorphicAttribute, on: decl)) + return false + } + + // The @polymorphic attribute must be used with @Suite because @Suite is the + // one that emits the test content record containing the isPolymorphic flag. + if let attributedDecl = decl.asProtocol((any WithAttributesSyntax).self), + attributedDecl.attributes(named: "Suite").isEmpty { + diagnostics.append(.attributeNotSupported(polymorphicAttribute, withoutAttribute: "@Suite", on: decl)) + } + + if let modifiedDecl = decl.asProtocol((any WithModifiersSyntax).self) { + let finalModifier = modifiedDecl.modifiers.first { $0.name.tokenKind == .keyword(.final) } + if let finalModifier { + diagnostics.append(.attributeHasNoEffect(polymorphicAttribute, becauseOf: finalModifier, on: decl)) + } + } + + return !diagnostics.lazy.map(\.severity).contains(.error) + } +} diff --git a/Sources/TestingMacros/Support/Additions/WithModifiersSyntaxAdditions.swift b/Sources/TestingMacros/Support/Additions/WithModifiersSyntaxAdditions.swift deleted file mode 100644 index 7f07cc2c2..000000000 --- a/Sources/TestingMacros/Support/Additions/WithModifiersSyntaxAdditions.swift +++ /dev/null @@ -1,62 +0,0 @@ -// -// This source file is part of the Swift.org open source project -// -// Copyright (c) 2026 Apple Inc. and the Swift project authors -// Licensed under Apache License v2.0 with Runtime Library Exception -// -// See https://swift.org/LICENSE.txt for license information -// See https://swift.org/CONTRIBUTORS.txt for Swift project authors -// - -import SwiftIfConfig -import SwiftSyntax -import SwiftSyntaxMacros - -extension WithModifiersSyntax { - /// The `open` modifier, if any, applied to this declaration. - var openModifier: DeclModifierSyntax? { - modifiers.first { $0.name.tokenKind == .keyword(.open) } - } - - /// The `override` modifier, if any, applied to this declaration. - var overrideModifier: DeclModifierSyntax? { - modifiers.first { $0.name.tokenKind == .keyword(.override) } - } - - /// Check whether or not this declaration is an inheritable test or suite - /// declaration. - /// - /// - Parameters: - /// - context: The macro context in which the expression is being parsed. - /// - /// - Returns: Whether or not this declaration should be inherited by - /// subclasses of this declaration (if it is a class) or subclasses of its - /// containing declaration (if it is a function). - func isInheritableTestDeclaration(in context: some MacroExpansionContext) -> Bool { - var isInheritable = self.openModifier != nil - if !isInheritable, - self.is(FunctionDeclSyntax.self), - let containingTypeDecl = context.lexicalContext.first?.asProtocol((any WithModifiersSyntax).self) { - isInheritable = containingTypeDecl.openModifier != nil - } - if isInheritable && !isTestInheritanceEnabled(in: context) { - // Test inheritance is disabled. - isInheritable = false - } - return isInheritable - } -} - -/// Check whether the experimental test inheritance feature is enabled. -/// -/// - Parameters: -/// - context: The macro context in which the expression is being parsed. -/// -/// - Returns: Whether or not the feature is enabled. -func isTestInheritanceEnabled(in context: some MacroExpansionContext) -> Bool { - if let buildConfiguration = context.buildConfiguration, - let isInheritanceEnabled = try? buildConfiguration.isCustomConditionSet(name: "SWIFT_TESTING_EXPERIMENTAL_TEST_INHERITANCE_ENABLED") { - return isInheritanceEnabled - } - return false -} diff --git a/Sources/TestingMacros/Support/AttributeDiscovery.swift b/Sources/TestingMacros/Support/AttributeDiscovery.swift index 86ddf1c52..b88e96fd9 100644 --- a/Sources/TestingMacros/Support/AttributeDiscovery.swift +++ b/Sources/TestingMacros/Support/AttributeDiscovery.swift @@ -178,9 +178,9 @@ struct AttributeInfo { arguments.append(Argument(label: "sourceBounds", expression: sourceBounds)) - if let modifiedDecl = declaration.asProtocol((any WithModifiersSyntax).self), - modifiedDecl.isInheritableTestDeclaration(in: context) { - arguments.append(Argument(label: "isInheritable", expression: BooleanLiteralExprSyntax(true))) + if let declaration = declaration.asProtocol((any WithAttributesSyntax).self), + !declaration.attributes(named: "polymorphic").isEmpty { + arguments.append(Argument(label: "isPolymorphic", expression: BooleanLiteralExprSyntax(true))) } return LabeledExprListSyntax(arguments) diff --git a/Sources/TestingMacros/Support/DiagnosticMessage+Diagnosing.swift b/Sources/TestingMacros/Support/DiagnosticMessage+Diagnosing.swift index a79f60740..178629e91 100644 --- a/Sources/TestingMacros/Support/DiagnosticMessage+Diagnosing.swift +++ b/Sources/TestingMacros/Support/DiagnosticMessage+Diagnosing.swift @@ -286,7 +286,7 @@ func makeGenericGuardDecl( /// its known subclasses `XCTestCase` and `XCTestSuite`. /// /// - Parameters: -/// - decl: The declaration to examine. +/// - decl: The declaration to examine. /// /// - Returns: Whether or not `decl` inherits from `XCTest.XCTest`. If the /// result could not be determined from the available syntax, returns `nil`. diff --git a/Sources/TestingMacros/Support/DiagnosticMessage.swift b/Sources/TestingMacros/Support/DiagnosticMessage.swift index 9b790a45c..038148d2b 100644 --- a/Sources/TestingMacros/Support/DiagnosticMessage.swift +++ b/Sources/TestingMacros/Support/DiagnosticMessage.swift @@ -124,6 +124,8 @@ struct DiagnosticMessage: SwiftDiagnostics.DiagnosticMessage { result = ("enumeration", "an") case .actorDecl: result = ("actor", "an") + case .extensionDecl: + result = ("extension", "an") case .variableDecl: // This string could be "variable" in some contexts but none we're // currently looking at. @@ -237,6 +239,30 @@ struct DiagnosticMessage: SwiftDiagnostics.DiagnosticMessage { ) } + /// Create a diagnostic message stating that the given attribute cannot be + /// applied to the given declaration because it requires the presence of + /// another attribute. + /// + /// - Parameters: + /// - attribute: The `@Test` or `@Suite` attribute. + /// - decl: The declaration in question. + /// + /// - Returns: A diagnostic message. + static func attributeNotSupported(_ attribute: AttributeSyntax, withoutAttribute requiredAttribute: AttributeSyntax, on decl: some SyntaxProtocol) -> Self { + let combinedAttributes: AttributeSyntax = "\(attribute) \(requiredAttribute)" + return Self( + syntax: Syntax(decl), + message: "Attribute \(_macroName(attribute)) cannot be applied to \(_kindString(for: decl, includeA: true)) without also applying attribute \(_macroName(attribute))", + severity: .error, + fixIts: [ + FixIt( + message: MacroExpansionFixItMessage("Add \(_macroName(attribute))"), + changes: [.replace(oldNode: Syntax(attribute), newNode: Syntax(combinedAttributes)),] + ), + ] + ) + } + /// Create a diagnostic message stating that the given attribute can only be /// applied to `static` properties. /// @@ -411,11 +437,11 @@ struct DiagnosticMessage: SwiftDiagnostics.DiagnosticMessage { } /// Create a diagnostic message stating that the given attribute has no effect - /// when applied to the given extension declaration. + /// when applied to the given declaration. /// /// - Parameters: /// - attribute: The `@Test` or `@Suite` attribute. - /// - decl: The extension declaration in question. + /// - decl: The declaration in question. /// /// - Returns: A diagnostic message. static func attributeHasNoEffect(_ attribute: AttributeSyntax, on decl: some DeclSyntaxProtocol) -> Self { @@ -432,6 +458,33 @@ struct DiagnosticMessage: SwiftDiagnostics.DiagnosticMessage { ) } + /// Create a diagnostic message stating that the given attribute has no effect + /// when applied to the given declaration due to the presence of a modifier. + /// + /// - Parameters: + /// - attribute: The `@Test` or `@Suite` attribute. + /// - modifier: The modifier that makes `attribute` have no effect. + /// - decl: The declaration in question. + /// + /// - Returns: A diagnostic message. + static func attributeHasNoEffect(_ attribute: AttributeSyntax, becauseOf modifier: DeclModifierSyntax, on decl: some DeclSyntaxProtocol) -> Self { + Self( + syntax: Syntax(decl), + message: "Attribute \(_macroName(attribute)) has no effect when applied to \(_kindString(for: decl)) with modifier '\(modifier.name.trimmed)'", + severity: .warning, + fixIts: [ + FixIt( + message: MacroExpansionFixItMessage("Remove attribute \(_macroName(attribute))"), + changes: [.replace(oldNode: Syntax(attribute), newNode: Syntax("" as ExprSyntax))] + ), + FixIt( + message: MacroExpansionFixItMessage("Remove modifier '\(modifier.name.trimmed)'"), + changes: [.replace(oldNode: Syntax(modifier), newNode: Syntax("" as ExprSyntax))] + ), + ] + ) + } + /// Create a diagnostic message stating that the given attribute has the wrong /// number of arguments when applied to the given function declaration. /// @@ -732,33 +785,6 @@ struct DiagnosticMessage: SwiftDiagnostics.DiagnosticMessage { ) } - /// Create a diagnostic message stating that the `override` modifier is - /// ambiguous on a test function. - /// - /// - Parameters: - /// - decl: The declaration that has the modifier. - /// - modifier: The unsupported modifier. - /// - attribute: The `@Test` or `@Suite` attribute. - /// - /// - Returns: A diagnostic message. - static func overrideModifier( - _ modifier: DeclModifierSyntax, - isAmbiguousWhenAppliedTo decl: some WithModifiersSyntax, - using attribute: AttributeSyntax - ) -> Self { - Self( - syntax: Syntax(modifier), - message: "Modifier '\(modifier.name.trimmed)' is ambiguous when applied to \(_kindString(for: decl, includeA: true)) with attribute \(_macroName(attribute))", - severity: .warning, - fixIts: [ - FixIt( - message: MacroExpansionFixItMessage("Remove '\(modifier.name.trimmed)'"), - changes: [.replace(oldNode: Syntax(modifier), newNode: Syntax("" as ExprSyntax))] - ), - ] - ) - } - /// Create a diagnostic messages stating that the expression passed to /// `#require()` is ambiguous. /// diff --git a/Sources/TestingMacros/Support/EffectfulExpressionHandling.swift b/Sources/TestingMacros/Support/EffectfulExpressionHandling.swift index 5bd5e77e5..5920cbdcb 100644 --- a/Sources/TestingMacros/Support/EffectfulExpressionHandling.swift +++ b/Sources/TestingMacros/Support/EffectfulExpressionHandling.swift @@ -17,7 +17,7 @@ import SwiftSyntaxMacros /// Get the effect keyword corresponding to a given syntax node, if any. /// /// - Parameters: -/// - expr: The syntax node that may represent an effectful expression. +/// - expr: The syntax node that may represent an effectful expression. /// /// - Returns: The effect keyword corresponding to `expr`, if any. private func _effectKeyword(for expr: ExprSyntax) -> Keyword? { @@ -164,7 +164,7 @@ private func _makeCallToEffectfulThunk(_ thunkName: TokenSyntax, passing expr: s /// - effectfulKeywords: The effectful keywords to apply. /// - expr: The expression to apply the keywords and thunk functions to. /// - insertThunkCalls: Whether or not to also insert calls to thunks to -/// ensure the inserted keywords do not generate warnings. If you aren't +/// ensure the inserted keywords do not generate warnings. If you aren't /// sure whether thunk calls are needed, pass `true`. /// /// - Returns: A copy of `expr` if no changes are needed, or an expression that diff --git a/Sources/TestingMacros/TestDeclarationMacro.swift b/Sources/TestingMacros/TestDeclarationMacro.swift index 4a6989c32..242306649 100644 --- a/Sources/TestingMacros/TestDeclarationMacro.swift +++ b/Sources/TestingMacros/TestDeclarationMacro.swift @@ -130,14 +130,6 @@ public struct TestDeclarationMacro: PeerMacro, Sendable { } } - // Check if the declaration has the `override` keyword applied to it. The - // effect of that keyword is ambiguous. - if let modifiedDecl = declaration.asProtocol((any WithModifiersSyntax).self), - let overrideModifier = modifiedDecl.overrideModifier, - isTestInheritanceEnabled(in: context) { - context.diagnose(.overrideModifier(overrideModifier, isAmbiguousWhenAppliedTo: modifiedDecl, using: testAttribute)) - } - return !diagnostics.lazy.map(\.severity).contains(.error) } @@ -262,8 +254,20 @@ public struct TestDeclarationMacro: PeerMacro, Sendable { } // Should the thunk be declared generic over subclasses of `typeName`? - let isInheritable = functionDecl.isInheritableTestDeclaration(in: context) - lazy var genericSubclassName = context.makeUniqueName(thunking: functionDecl) + var isPolymorphicSuiteClass = false + var mayBePolymorphicSuiteExtension = false + if let containingTypeDecl = context.lexicalContext.first?.asProtocol((any WithAttributesSyntax).self) { + switch containingTypeDecl.kind { + case .classDecl, .actorDecl: + if !containingTypeDecl.attributes(named: "polymorphic").isEmpty { + isPolymorphicSuiteClass = true + } + case .extensionDecl: + mayBePolymorphicSuiteExtension = true + default: + break + } + } // Generate a thunk function that invokes the actual function. var thunkBody: CodeBlockItemListSyntax @@ -277,13 +281,18 @@ public struct TestDeclarationMacro: PeerMacro, Sendable { } else { let instanceName = context.makeUniqueName("subclass") let varOrLet = functionDecl.isMutating ? "var" : "let" - let initExpr: ExprSyntax = if isInheritable { - "\(genericSubclassName).init()" + var initExpr: ExprSyntax = "\(typeName)()" + if isPolymorphicSuiteClass || mayBePolymorphicSuiteExtension { + initExpr = """ + try await Testing.__initialize { + \(forwardInit(initExpr)) + } + """ } else { - "\(typeName)()" + initExpr = forwardInit(initExpr) } thunkBody = """ - \(raw: varOrLet) \(raw: instanceName) = \(forwardInit(initExpr)) + \(raw: varOrLet) \(raw: instanceName) = \(initExpr) _ = \(forwardCall("\(raw: instanceName).\(functionDecl.name.trimmed)\(forwardedParamsExpr)")) """ @@ -297,27 +306,13 @@ public struct TestDeclarationMacro: PeerMacro, Sendable { // matches the indexer's heuristic when discovering XCTest functions. let sourceLocationExpr = createSourceLocationExpr(of: functionDecl.name, context: context) - let classExpr: ExprSyntax = if isInheritable { - "\(genericSubclassName)" - } else { - "\(typeName).self" - } thunkBody = """ - if try await Testing.__invokeXCTestMethod(\(selectorExpr), onInstanceOf: \(classExpr), sourceLocation: \(sourceLocationExpr)) { + if try await Testing.__invokeXCTestMethod(\(selectorExpr), onInstanceOf: \(typeName).self, sourceLocation: \(sourceLocationExpr)) { return } \(thunkBody) """ } - - // If this function is inherited by subclasses, acquire the appropriate - // metatype value to initialize. - if isInheritable { - thunkBody = """ - let \(genericSubclassName) = try Testing.__currentSubclass(of: \(typeName).self) - \(thunkBody) - """ - } } } else { thunkBody = "_ = \(forwardCall("\(functionDecl.name.trimmed)\(forwardedParamsExpr)"))" diff --git a/Sources/TestingMacros/TestingMacrosMain.swift b/Sources/TestingMacros/TestingMacrosMain.swift index 4453ac8ef..19d549211 100644 --- a/Sources/TestingMacros/TestingMacrosMain.swift +++ b/Sources/TestingMacros/TestingMacrosMain.swift @@ -19,6 +19,7 @@ struct TestingMacrosMain: CompilerPlugin { var providingMacros: [any Macro.Type] { [ SuiteDeclarationMacro.self, + PolymorphicSuiteMacro.self, TestDeclarationMacro.self, ExpectMacro.self, RequireMacro.self, diff --git a/Sources/_TestDiscovery/TestContentRecord.swift b/Sources/_TestDiscovery/TestContentRecord.swift index 962484186..01ee0cc6a 100644 --- a/Sources/_TestDiscovery/TestContentRecord.swift +++ b/Sources/_TestDiscovery/TestContentRecord.swift @@ -142,7 +142,7 @@ public struct TestContentRecord where T: DiscoverableAsTestContent { /// Invoke an accessor function to load a test content record. /// /// - Parameters: - /// - accessor: The accessor function to call. + /// - accessor: The accessor function to call. /// - typeAddress: A pointer to the type of test content record. /// - hint: An optional hint value. /// diff --git a/Tests/TestingMacrosTests/ConditionMacroTests.swift b/Tests/TestingMacrosTests/ConditionMacroTests.swift index dc864347c..5c25da1cf 100644 --- a/Tests/TestingMacrosTests/ConditionMacroTests.swift +++ b/Tests/TestingMacrosTests/ConditionMacroTests.swift @@ -441,17 +441,17 @@ struct ConditionMacroTests { "struct S { func f() { #expectExitTest(processExitsWith: x) {} } }": "Cannot call macro ''#expectExitTest(processExitsWith:_:)'' within generic structure 'S'", "extension S where T: U { func f() { #expectExitTest(processExitsWith: x) {} } }": - "Cannot call macro ''#expectExitTest(processExitsWith:_:)'' within a generic declaration", + "Cannot call macro ''#expectExitTest(processExitsWith:_:)'' within a generic extension", "extension [T] { func f() { #expectExitTest(processExitsWith: x) {} } }": - "Cannot call macro ''#expectExitTest(processExitsWith:_:)'' within a generic declaration", + "Cannot call macro ''#expectExitTest(processExitsWith:_:)'' within a generic extension", "extension [T:U] { func f() { #expectExitTest(processExitsWith: x) {} } }": - "Cannot call macro ''#expectExitTest(processExitsWith:_:)'' within a generic declaration", + "Cannot call macro ''#expectExitTest(processExitsWith:_:)'' within a generic extension", "extension T? { func f() { #expectExitTest(processExitsWith: x) {} } }": - "Cannot call macro ''#expectExitTest(processExitsWith:_:)'' within a generic declaration", + "Cannot call macro ''#expectExitTest(processExitsWith:_:)'' within a generic extension", "extension T! { func f() { #expectExitTest(processExitsWith: x) {} } }": - "Cannot call macro ''#expectExitTest(processExitsWith:_:)'' within a generic declaration", + "Cannot call macro ''#expectExitTest(processExitsWith:_:)'' within a generic extension", "extension [1 of T] { func f() { #expectExitTest(processExitsWith: x) {} } }": - "Cannot call macro ''#expectExitTest(processExitsWith:_:)'' within a generic declaration", + "Cannot call macro ''#expectExitTest(processExitsWith:_:)'' within a generic extension", "func f() { #expectExitTest(processExitsWith: x) {} }": "Cannot call macro ''#expectExitTest(processExitsWith:_:)'' within generic function 'f()'", "func f() where T: U { #expectExitTest(processExitsWith: x) {} }": diff --git a/Tests/TestingMacrosTests/TestDeclarationMacroTests.swift b/Tests/TestingMacrosTests/TestDeclarationMacroTests.swift index cc8225446..8131bbb67 100644 --- a/Tests/TestingMacrosTests/TestDeclarationMacroTests.swift +++ b/Tests/TestingMacrosTests/TestDeclarationMacroTests.swift @@ -60,7 +60,7 @@ struct TestDeclarationMacroTests { "@Suite func f() {}": "Attribute 'Suite' cannot be applied to a function", "@Suite extension X {}": - "Attribute 'Suite' has no effect when applied to this extension", + "Attribute 'Suite' has no effect when applied to an extension", "@Test macro m()": "Attribute 'Test' cannot be applied to a macro", "@Test struct S {}": diff --git a/Tests/TestingTests/AttachmentTests.swift b/Tests/TestingTests/AttachmentTests.swift index f3bb8df12..16895a642 100644 --- a/Tests/TestingTests/AttachmentTests.swift +++ b/Tests/TestingTests/AttachmentTests.swift @@ -542,7 +542,7 @@ extension AttachmentTests { case couldNotCreateCGGradient case couldNotCreateCGImage } - + #if canImport(CoreGraphics) && canImport(_Testing_CoreGraphics) static let cgImage = Result { let size = CGSize(width: 32.0, height: 32.0) @@ -581,7 +581,7 @@ extension AttachmentTests { } return image } - + @Test func attachCGImage() throws { let image = try Self.cgImage.get() let attachment = Attachment(image, named: "diamond") @@ -591,7 +591,7 @@ extension AttachmentTests { } Attachment.record(attachment) } - + @Test func attachCGImageDirectly() async throws { await confirmation("Attachment detected") { valueAttached in var configuration = Configuration() @@ -600,14 +600,14 @@ extension AttachmentTests { valueAttached() } } - + await Test { let image = try Self.cgImage.get() Attachment.record(image, named: "diamond.jpg") }.run(configuration: configuration) } } - + @Test(arguments: [Float(0.0).nextUp, 0.25, 0.5, 0.75, 1.0], [.png as UTType?, .jpeg, .gif, .image, nil]) func attachCGImage(quality: Float, type: UTType?) throws { let image = try Self.cgImage.get() @@ -621,7 +621,7 @@ extension AttachmentTests { #expect(attachment.preferredName == ("diamond" as NSString).appendingPathExtension(ext)) } } - + @Test(arguments: [AttachableImageFormat.png, .jpeg, .jpeg(withEncodingQuality: 0.5), .init(contentType: .tiff)]) func attachCGImage(format: AttachableImageFormat) throws { let image = try Self.cgImage.get() @@ -634,7 +634,7 @@ extension AttachmentTests { #expect(attachment.preferredName == ("diamond" as NSString).appendingPathExtension(ext)) } } - + @Test func attachCGImageWithCustomUTType() throws { let contentType = try #require(UTType(tag: "derived-from-jpeg", tagClass: .filenameExtension, conformingTo: .jpeg)) let format = AttachableImageFormat(contentType: contentType) @@ -648,7 +648,7 @@ extension AttachmentTests { #expect(attachment.preferredName == ("diamond" as NSString).appendingPathExtension(ext)) } } - + @Test func attachCGImageWithUnsupportedImageType() throws { let contentType = try #require(UTType(tag: "unsupported-image-format", tagClass: .filenameExtension, conformingTo: .image)) let format = AttachableImageFormat(contentType: contentType) @@ -659,7 +659,7 @@ extension AttachmentTests { try attachment.attachableValue.withUnsafeBytes(for: attachment) { _ in } } } - + #if !SWT_NO_EXIT_TESTS @Test func cannotAttachCGImageWithNonImageType() async { await #expect(processExitsWith: .failure) { @@ -669,7 +669,7 @@ extension AttachmentTests { } } #endif - + #if canImport(CoreImage) && canImport(_Testing_CoreImage) @Test func attachCIImage() throws { let image = CIImage(cgImage: try Self.cgImage.get()) @@ -680,7 +680,7 @@ extension AttachmentTests { } } #endif - + #if canImport(AppKit) && canImport(_Testing_AppKit) static var nsImage: NSImage { get throws { @@ -689,7 +689,7 @@ extension AttachmentTests { return NSImage(cgImage: cgImage, size: size) } } - + @Test func attachNSImage() throws { let image = try Self.nsImage let attachment = Attachment(image, named: "diamond.jpg") @@ -698,7 +698,7 @@ extension AttachmentTests { #expect(buffer.count > 32) } } - + @Test func attachNSImageWithCustomRep() throws { let image = NSImage(size: NSSize(width: 32.0, height: 32.0), flipped: false) { rect in NSColor.red.setFill() @@ -711,7 +711,7 @@ extension AttachmentTests { #expect(buffer.count > 32) } } - + @Test func attachNSImageWithSubclassedNSImage() throws { let image = MyImage(size: NSSize(width: 32.0, height: 32.0)) image.addRepresentation(NSCustomImageRep(size: image.size, flipped: false) { rect in @@ -719,7 +719,7 @@ extension AttachmentTests { rect.fill() return true }) - + let attachment = Attachment(image, named: "diamond.jpg") #expect(attachment.attachableValue === image) #expect(attachment.attachableValue.size == image.size) // NSImage makes a copy @@ -727,11 +727,11 @@ extension AttachmentTests { #expect(buffer.count > 32) } } - + @Test func attachNSImageWithSubclassedRep() throws { let image = NSImage(size: NSSize(width: 32.0, height: 32.0)) image.addRepresentation(MyImageRep()) - + let attachment = Attachment(image, named: "diamond.jpg") #expect(attachment.attachableValue.size == image.size) // NSImage makes a copy let firstRep = try #require(attachment.attachableValue.representations.first) @@ -741,7 +741,7 @@ extension AttachmentTests { } } #endif - + #if canImport(UIKit) && canImport(_Testing_UIKit) @Test func attachUIImage() throws { let image = UIImage(cgImage: try Self.cgImage.get()) @@ -754,65 +754,65 @@ extension AttachmentTests { } #endif #endif - + #if canImport(WinSDK) && canImport(_Testing_WinSDK) private func copyHICON() throws -> HICON { try #require(LoadIconA(nil, swt_IDI_SHIELD())) } - + @MainActor @Test func attachHICON() throws { let icon = try copyHICON() defer { DestroyIcon(icon) } - + let attachment = Attachment(icon, named: "diamond.jpeg") try attachment.withUnsafeBytes { buffer in #expect(buffer.count > 32) } } - + private func copyHBITMAP() throws -> HBITMAP { let (width, height) = (GetSystemMetrics(SM_CXICON), GetSystemMetrics(SM_CYICON)) - + let icon = try copyHICON() defer { DestroyIcon(icon) } - + let screenDC = try #require(GetDC(nil)) defer { ReleaseDC(nil, screenDC) } - + let dc = try #require(CreateCompatibleDC(nil)) defer { DeleteDC(dc) } - + let bitmap = try #require(CreateCompatibleBitmap(screenDC, width, height)) let oldSelectedObject = SelectObject(dc, bitmap) defer { _ = SelectObject(dc, oldSelectedObject) } DrawIcon(dc, 0, 0, icon) - + return bitmap } - + @MainActor @Test func attachHBITMAP() throws { let bitmap = try copyHBITMAP() defer { DeleteObject(bitmap) } - + let attachment = Attachment(bitmap, named: "diamond.png") try attachment.withUnsafeBytes { buffer in #expect(buffer.count > 32) } Attachment.record(attachment) } - + @MainActor @Test func attachHBITMAPAsJPEG() throws { let bitmap = try copyHBITMAP() defer { @@ -820,7 +820,7 @@ extension AttachmentTests { } let hiFi = Attachment(bitmap, named: "hifi", as: .jpeg(withEncodingQuality: 1.0)) let loFi = Attachment(bitmap, named: "lofi", as: .jpeg(withEncodingQuality: 0.1)) - + try hiFi.withUnsafeBytes { hiFi in try loFi.withUnsafeBytes { loFi in #expect(hiFi.count > loFi.count) @@ -828,18 +828,18 @@ extension AttachmentTests { } Attachment.record(loFi) } - + private func copyIWICBitmap() throws -> UnsafeMutablePointer { let factory = try IWICImagingFactory.create() defer { _ = factory.pointee.lpVtbl.pointee.Release(factory) } - + let bitmap = try copyHBITMAP() defer { DeleteObject(bitmap) } - + var wicBitmap: UnsafeMutablePointer? let rCreate = factory.pointee.lpVtbl.pointee.CreateBitmapFromHBITMAP(factory, bitmap, nil, WICBitmapUsePremultipliedAlpha, &wicBitmap) guard rCreate == S_OK, let wicBitmap else { @@ -847,60 +847,60 @@ extension AttachmentTests { } return wicBitmap } - + @MainActor @Test func attachIWICBitmap() throws { let wicBitmap = try copyIWICBitmap() defer { _ = wicBitmap.pointee.lpVtbl.pointee.Release(wicBitmap) } - + let attachment = Attachment(wicBitmap, named: "diamond.png") try attachment.withUnsafeBytes { buffer in #expect(buffer.count > 32) } Attachment.record(attachment) } - + @MainActor @Test func attachIWICBitmapSource() throws { let wicBitmapSource = try copyIWICBitmap().cast(to: IWICBitmapSource.self) defer { _ = wicBitmapSource.pointee.lpVtbl.pointee.Release(wicBitmapSource) } - + let attachment = Attachment(wicBitmapSource, named: "diamond.png") try attachment.withUnsafeBytes { buffer in #expect(buffer.count > 32) } Attachment.record(attachment) } - + @MainActor @Test func pathExtensionAndCLSID() { let pngCLSID = AttachableImageFormat.png.encoderCLSID let pngFilename = AttachableImageFormat.appendPathExtension(for: pngCLSID, to: "example") #expect(pngFilename == "example.png") - + let jpegCLSID = AttachableImageFormat.jpeg.encoderCLSID let jpegFilename = AttachableImageFormat.appendPathExtension(for: jpegCLSID, to: "example") #expect(jpegFilename == "example.jpeg") - + let pngjpegFilename = AttachableImageFormat.appendPathExtension(for: jpegCLSID, to: "example.png") #expect(pngjpegFilename == "example.png.jpeg") - + let jpgjpegFilename = AttachableImageFormat.appendPathExtension(for: jpegCLSID, to: "example.jpg") #expect(jpgjpegFilename == "example.jpg") } #endif - + #if (canImport(CoreGraphics) && canImport(_Testing_CoreGraphics)) || (canImport(WinSDK) && canImport(_Testing_WinSDK)) @Test func imageFormatFromPathExtension() { let format = AttachableImageFormat(pathExtension: "png") #expect(format != nil) #expect(format == .png) - + let badFormat = AttachableImageFormat(pathExtension: "no-such-image-format") #expect(badFormat == nil) } - + @Test func imageFormatEquatableConformance() { let format1 = AttachableImageFormat.png let format2 = AttachableImageFormat.jpeg @@ -915,7 +915,7 @@ extension AttachmentTests { #expect(format1 != format2) #expect(format2 != format3) #expect(format1 != format3) - + #expect(format1.hashValue == format1.hashValue) #expect(format2.hashValue == format2.hashValue) #expect(format3.hashValue == format3.hashValue) @@ -923,7 +923,7 @@ extension AttachmentTests { #expect(format2.hashValue != format3.hashValue) #expect(format1.hashValue != format3.hashValue) } - + @Test func imageFormatStringification() { let format: AttachableImageFormat = AttachableImageFormat.png #if canImport(CoreGraphics) && canImport(_Testing_CoreGraphics) @@ -934,7 +934,7 @@ extension AttachmentTests { #expect(String(reflecting: format) == "PNG format (27949969-876a-41d7-9447-568f6a35a4dc) at quality 1.0") #endif } - + @Test func imageFormatStringificationWithQuality() { let format: AttachableImageFormat = AttachableImageFormat.jpeg(withEncodingQuality: 0.5) #if canImport(CoreGraphics) && canImport(_Testing_CoreGraphics) diff --git a/Tests/TestingTests/InheritanceTests.swift b/Tests/TestingTests/InheritanceTests.swift deleted file mode 100644 index afe9d90ea..000000000 --- a/Tests/TestingTests/InheritanceTests.swift +++ /dev/null @@ -1,38 +0,0 @@ -// -// This source file is part of the Swift.org open source project -// -// Copyright (c) 2026 Apple Inc. and the Swift project authors -// Licensed under Apache License v2.0 with Runtime Library Exception -// -// See https://swift.org/LICENSE.txt for license information -// See https://swift.org/CONTRIBUTORS.txt for Swift project authors -// - -@testable import Testing - -struct `Inherited test function tests` { - open class BaseClass { - var calledTestFunction = false - - @Test func `Invoke inherited test function`() { - calledTestFunction = true - } - - public required init() {} - - deinit { - #expect(calledTestFunction) - } - - class DoesNotInheritBaseClass { - @Test func `This function should not be inherited`() { - #expect(Self.self == DoesNotInheritBaseClass.self) - } - - final class DoesNotInheritDerivedClass: DoesNotInheritBaseClass {} - } - } - - private class DerivedClass: BaseClass {} - private final class TertiaryClass: DerivedClass {} -} diff --git a/Tests/TestingTests/MiscellaneousTests.swift b/Tests/TestingTests/MiscellaneousTests.swift index 02d8f119c..d79bb10fc 100644 --- a/Tests/TestingTests/MiscellaneousTests.swift +++ b/Tests/TestingTests/MiscellaneousTests.swift @@ -318,7 +318,7 @@ struct MiscellaneousTests { @Test func `Suite type with raw identifier gets a display name`() throws { struct `Suite With De Facto Display Name` {} let typeInfo = TypeInfo(describing: `Suite With De Facto Display Name`.self) - let suite = Test(traits: [], sourceLocation: #_sourceLocation, containingTypeInfo: typeInfo, isSynthesized: true) + let suite = Test(traits: [], sourceLocation: #_sourceLocation, containingTypeInfo: typeInfo, isSynthesized: true, isPolymorphic: false) #expect(suite.name == "`Suite With De Facto Display Name`") let displayName = try #require(suite.displayName) #expect(displayName == "Suite With De Facto Display Name") diff --git a/Tests/TestingTests/PolymorphismTests.swift b/Tests/TestingTests/PolymorphismTests.swift new file mode 100644 index 000000000..8e7d700bb --- /dev/null +++ b/Tests/TestingTests/PolymorphismTests.swift @@ -0,0 +1,71 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for Swift project authors +// + +@_spi(ForToolsIntegrationOnly) @testable import Testing + +struct `Polymorphic test function tests` { + @Test func `Polymorphic functions are discovered and run`() async throws { + let testPlan = await Runner.Plan(selecting: PolymorphicBaseClass.self) + let runner = Runner(plan: testPlan) + let ranClasses = await $ranClasses.withValue(Allocated(Mutex([]))) { + await runner.run() + return $ranClasses.wrappedValue.value.rawValue + }.sorted { $0.unqualifiedName < $1.unqualifiedName } + let expectedClasses: [TypeInfo] = [ + TypeInfo(describing: PolymorphicBaseClass.self), + TypeInfo(describing: PolymorphicBaseClass.DerivedClass.self), + TypeInfo(describing: TertiaryClass.self), + ].sorted { $0.unqualifiedName < $1.unqualifiedName } + #expect(ranClasses == expectedClasses) + } + + @Test func `Non-polymorphic subclass does not inherit test functions`() async throws { + await confirmation("Test suite started", expectedCount: 1...) { suiteStarted in + var configuration = Configuration() + configuration.eventHandler = { event, eventContext in + if case .testStarted = event.kind { + let test = eventContext.test! + if test.isSuite { + suiteStarted() + } else { + Issue.record("Ran test function '\(eventContext.test!.name)' in what should have been an empty suite") + } + } + } + let testPlan = await Runner.Plan(selecting: PolymorphicBaseClass.DoesNotInheritBaseClass.DoesNotInheritDerivedClass.self, configuration: configuration) + let runner = Runner(plan: testPlan, configuration: configuration) + await runner.run() + } + } +} + +// MARK: - Fixtures + +@TaskLocal private var ranClasses = Allocated(Mutex<[TypeInfo]>([])) + +@polymorphic @Suite(.hidden) private class PolymorphicBaseClass { + @Test func `Invoke inherited test function`() { + ranClasses.value.withLock { $0.append(TypeInfo(describing: Self.self)) } + } + + required init() {} + + class DoesNotInheritBaseClass { + @Test func `This function should not be inherited`() { + Issue.record("Should not have run this function.") + } + + @Suite final class DoesNotInheritDerivedClass: DoesNotInheritBaseClass {} + } + + class DerivedClass: PolymorphicBaseClass {} +} + +private final class TertiaryClass: PolymorphicBaseClass.DerivedClass {} diff --git a/Tests/TestingTests/TestSupport/TestingAdditions.swift b/Tests/TestingTests/TestSupport/TestingAdditions.swift index 2609cbefe..8758ac9c6 100644 --- a/Tests/TestingTests/TestSupport/TestingAdditions.swift +++ b/Tests/TestingTests/TestSupport/TestingAdditions.swift @@ -179,7 +179,7 @@ extension Test { ) { let sourceBounds = sourceBounds ?? __SourceBounds(lowerBoundOnly: sourceLocation) let caseGenerator = Case.Generator(testFunction: testFunction) - self.init(name: name, displayName: name, traits: traits, sourceBounds: sourceBounds, containingTypeInfo: nil, testCases: caseGenerator, parameters: [], isInheritable: false) + self.init(name: name, displayName: name, traits: traits, sourceBounds: sourceBounds, containingTypeInfo: nil, testCases: caseGenerator, parameters: []) } /// Initialize an instance of this type with a function or closure to call, @@ -211,7 +211,7 @@ extension Test { ) where C: Collection & Sendable, C.Element: Sendable { let sourceBounds = sourceBounds ?? __SourceBounds(lowerBoundOnly: sourceLocation) let caseGenerator = Case.Generator(arguments: collection, parameters: parameters, testFunction: testFunction) - self.init(name: name, displayName: name, traits: traits, sourceBounds: sourceBounds, containingTypeInfo: nil, testCases: caseGenerator, parameters: parameters, isInheritable: false) + self.init(name: name, displayName: name, traits: traits, sourceBounds: sourceBounds, containingTypeInfo: nil, testCases: caseGenerator, parameters: parameters) } init( @@ -230,7 +230,7 @@ extension Test { let caseGenerator = { @Sendable in Case.Generator(arguments: try await collection(), parameters: parameters, testFunction: testFunction) } - self.init(name: name, displayName: name, traits: traits, sourceBounds: sourceBounds, containingTypeInfo: nil, testCases: caseGenerator, parameters: parameters, isInheritable: false) + self.init(name: name, displayName: name, traits: traits, sourceBounds: sourceBounds, containingTypeInfo: nil, testCases: caseGenerator, parameters: parameters) } /// Initialize an instance of this type with a function or closure to call, @@ -263,7 +263,7 @@ extension Test { ) where C1: Collection & Sendable, C1.Element: Sendable, C2: Collection & Sendable, C2.Element: Sendable { let sourceBounds = sourceBounds ?? __SourceBounds(lowerBoundOnly: sourceLocation) let caseGenerator = Case.Generator(arguments: collection1, collection2, parameters: parameters, testFunction: testFunction) - self.init(name: name, displayName: name, traits: traits, sourceBounds: sourceBounds, containingTypeInfo: nil, testCases: caseGenerator, parameters: parameters, isInheritable: false) + self.init(name: name, displayName: name, traits: traits, sourceBounds: sourceBounds, containingTypeInfo: nil, testCases: caseGenerator, parameters: parameters) } /// Initialize an instance of this type with a function or closure to call, @@ -291,7 +291,7 @@ extension Test { ) where C1: Collection & Sendable, C1.Element: Sendable, C2: Collection & Sendable, C2.Element: Sendable { let sourceBounds = sourceBounds ?? __SourceBounds(lowerBoundOnly: sourceLocation) let caseGenerator = Case.Generator(arguments: zippedCollections, parameters: parameters, testFunction: testFunction) - self.init(name: name, displayName: name, traits: traits, sourceBounds: sourceBounds, containingTypeInfo: nil, testCases: caseGenerator, parameters: parameters, isInheritable: false) + self.init(name: name, displayName: name, traits: traits, sourceBounds: sourceBounds, containingTypeInfo: nil, testCases: caseGenerator, parameters: parameters) } } From 046996eab7b0fd4b9536e52ff06bf691b3e060cc Mon Sep 17 00:00:00 2001 From: Jonathan Grynspan Date: Thu, 19 Mar 2026 15:57:58 -0400 Subject: [PATCH 3/8] Doh --- Tests/TestingTests/PolymorphismTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/TestingTests/PolymorphismTests.swift b/Tests/TestingTests/PolymorphismTests.swift index 8e7d700bb..621d79156 100644 --- a/Tests/TestingTests/PolymorphismTests.swift +++ b/Tests/TestingTests/PolymorphismTests.swift @@ -8,7 +8,7 @@ // See https://swift.org/CONTRIBUTORS.txt for Swift project authors // -@_spi(ForToolsIntegrationOnly) @testable import Testing +@_spi(Experimental) @_spi(ForToolsIntegrationOnly) @testable import Testing struct `Polymorphic test function tests` { @Test func `Polymorphic functions are discovered and run`() async throws { From e5884294150f33419950a1b88b26a0c643c8f771 Mon Sep 17 00:00:00 2001 From: Jonathan Grynspan Date: Fri, 20 Mar 2026 12:33:14 -0400 Subject: [PATCH 4/8] Revert stray whitespace changes --- .../Testing/Parameterization/TypeInfo.swift | 6 +- .../DiagnosticMessage+Diagnosing.swift | 2 +- .../Support/EffectfulExpressionHandling.swift | 4 +- Tests/TestingTests/AttachmentTests.swift | 96 +++++++++---------- 4 files changed, 56 insertions(+), 52 deletions(-) diff --git a/Sources/Testing/Parameterization/TypeInfo.swift b/Sources/Testing/Parameterization/TypeInfo.swift index c6fbc01a6..33e42c349 100644 --- a/Sources/Testing/Parameterization/TypeInfo.swift +++ b/Sources/Testing/Parameterization/TypeInfo.swift @@ -49,8 +49,12 @@ public struct TypeInfo: Sendable { return nil } + /// The described type, if available. + /// + /// The value of this property is `nil` if the described type is not a class, + /// as well as under any conditions where ``type`` is `nil`. var `class`: AnyClass? { - if case let .type(type) = _kind { + if let type { // FIXME: casting `any (~).Type` to `AnyClass` warns that it always fails return unsafeBitCast(type, to: Any.Type.self) as? AnyClass } diff --git a/Sources/TestingMacros/Support/DiagnosticMessage+Diagnosing.swift b/Sources/TestingMacros/Support/DiagnosticMessage+Diagnosing.swift index 178629e91..a79f60740 100644 --- a/Sources/TestingMacros/Support/DiagnosticMessage+Diagnosing.swift +++ b/Sources/TestingMacros/Support/DiagnosticMessage+Diagnosing.swift @@ -286,7 +286,7 @@ func makeGenericGuardDecl( /// its known subclasses `XCTestCase` and `XCTestSuite`. /// /// - Parameters: -/// - decl: The declaration to examine. +/// - decl: The declaration to examine. /// /// - Returns: Whether or not `decl` inherits from `XCTest.XCTest`. If the /// result could not be determined from the available syntax, returns `nil`. diff --git a/Sources/TestingMacros/Support/EffectfulExpressionHandling.swift b/Sources/TestingMacros/Support/EffectfulExpressionHandling.swift index 5920cbdcb..5bd5e77e5 100644 --- a/Sources/TestingMacros/Support/EffectfulExpressionHandling.swift +++ b/Sources/TestingMacros/Support/EffectfulExpressionHandling.swift @@ -17,7 +17,7 @@ import SwiftSyntaxMacros /// Get the effect keyword corresponding to a given syntax node, if any. /// /// - Parameters: -/// - expr: The syntax node that may represent an effectful expression. +/// - expr: The syntax node that may represent an effectful expression. /// /// - Returns: The effect keyword corresponding to `expr`, if any. private func _effectKeyword(for expr: ExprSyntax) -> Keyword? { @@ -164,7 +164,7 @@ private func _makeCallToEffectfulThunk(_ thunkName: TokenSyntax, passing expr: s /// - effectfulKeywords: The effectful keywords to apply. /// - expr: The expression to apply the keywords and thunk functions to. /// - insertThunkCalls: Whether or not to also insert calls to thunks to -/// ensure the inserted keywords do not generate warnings. If you aren't +/// ensure the inserted keywords do not generate warnings. If you aren't /// sure whether thunk calls are needed, pass `true`. /// /// - Returns: A copy of `expr` if no changes are needed, or an expression that diff --git a/Tests/TestingTests/AttachmentTests.swift b/Tests/TestingTests/AttachmentTests.swift index 16895a642..f3bb8df12 100644 --- a/Tests/TestingTests/AttachmentTests.swift +++ b/Tests/TestingTests/AttachmentTests.swift @@ -542,7 +542,7 @@ extension AttachmentTests { case couldNotCreateCGGradient case couldNotCreateCGImage } - + #if canImport(CoreGraphics) && canImport(_Testing_CoreGraphics) static let cgImage = Result { let size = CGSize(width: 32.0, height: 32.0) @@ -581,7 +581,7 @@ extension AttachmentTests { } return image } - + @Test func attachCGImage() throws { let image = try Self.cgImage.get() let attachment = Attachment(image, named: "diamond") @@ -591,7 +591,7 @@ extension AttachmentTests { } Attachment.record(attachment) } - + @Test func attachCGImageDirectly() async throws { await confirmation("Attachment detected") { valueAttached in var configuration = Configuration() @@ -600,14 +600,14 @@ extension AttachmentTests { valueAttached() } } - + await Test { let image = try Self.cgImage.get() Attachment.record(image, named: "diamond.jpg") }.run(configuration: configuration) } } - + @Test(arguments: [Float(0.0).nextUp, 0.25, 0.5, 0.75, 1.0], [.png as UTType?, .jpeg, .gif, .image, nil]) func attachCGImage(quality: Float, type: UTType?) throws { let image = try Self.cgImage.get() @@ -621,7 +621,7 @@ extension AttachmentTests { #expect(attachment.preferredName == ("diamond" as NSString).appendingPathExtension(ext)) } } - + @Test(arguments: [AttachableImageFormat.png, .jpeg, .jpeg(withEncodingQuality: 0.5), .init(contentType: .tiff)]) func attachCGImage(format: AttachableImageFormat) throws { let image = try Self.cgImage.get() @@ -634,7 +634,7 @@ extension AttachmentTests { #expect(attachment.preferredName == ("diamond" as NSString).appendingPathExtension(ext)) } } - + @Test func attachCGImageWithCustomUTType() throws { let contentType = try #require(UTType(tag: "derived-from-jpeg", tagClass: .filenameExtension, conformingTo: .jpeg)) let format = AttachableImageFormat(contentType: contentType) @@ -648,7 +648,7 @@ extension AttachmentTests { #expect(attachment.preferredName == ("diamond" as NSString).appendingPathExtension(ext)) } } - + @Test func attachCGImageWithUnsupportedImageType() throws { let contentType = try #require(UTType(tag: "unsupported-image-format", tagClass: .filenameExtension, conformingTo: .image)) let format = AttachableImageFormat(contentType: contentType) @@ -659,7 +659,7 @@ extension AttachmentTests { try attachment.attachableValue.withUnsafeBytes(for: attachment) { _ in } } } - + #if !SWT_NO_EXIT_TESTS @Test func cannotAttachCGImageWithNonImageType() async { await #expect(processExitsWith: .failure) { @@ -669,7 +669,7 @@ extension AttachmentTests { } } #endif - + #if canImport(CoreImage) && canImport(_Testing_CoreImage) @Test func attachCIImage() throws { let image = CIImage(cgImage: try Self.cgImage.get()) @@ -680,7 +680,7 @@ extension AttachmentTests { } } #endif - + #if canImport(AppKit) && canImport(_Testing_AppKit) static var nsImage: NSImage { get throws { @@ -689,7 +689,7 @@ extension AttachmentTests { return NSImage(cgImage: cgImage, size: size) } } - + @Test func attachNSImage() throws { let image = try Self.nsImage let attachment = Attachment(image, named: "diamond.jpg") @@ -698,7 +698,7 @@ extension AttachmentTests { #expect(buffer.count > 32) } } - + @Test func attachNSImageWithCustomRep() throws { let image = NSImage(size: NSSize(width: 32.0, height: 32.0), flipped: false) { rect in NSColor.red.setFill() @@ -711,7 +711,7 @@ extension AttachmentTests { #expect(buffer.count > 32) } } - + @Test func attachNSImageWithSubclassedNSImage() throws { let image = MyImage(size: NSSize(width: 32.0, height: 32.0)) image.addRepresentation(NSCustomImageRep(size: image.size, flipped: false) { rect in @@ -719,7 +719,7 @@ extension AttachmentTests { rect.fill() return true }) - + let attachment = Attachment(image, named: "diamond.jpg") #expect(attachment.attachableValue === image) #expect(attachment.attachableValue.size == image.size) // NSImage makes a copy @@ -727,11 +727,11 @@ extension AttachmentTests { #expect(buffer.count > 32) } } - + @Test func attachNSImageWithSubclassedRep() throws { let image = NSImage(size: NSSize(width: 32.0, height: 32.0)) image.addRepresentation(MyImageRep()) - + let attachment = Attachment(image, named: "diamond.jpg") #expect(attachment.attachableValue.size == image.size) // NSImage makes a copy let firstRep = try #require(attachment.attachableValue.representations.first) @@ -741,7 +741,7 @@ extension AttachmentTests { } } #endif - + #if canImport(UIKit) && canImport(_Testing_UIKit) @Test func attachUIImage() throws { let image = UIImage(cgImage: try Self.cgImage.get()) @@ -754,65 +754,65 @@ extension AttachmentTests { } #endif #endif - + #if canImport(WinSDK) && canImport(_Testing_WinSDK) private func copyHICON() throws -> HICON { try #require(LoadIconA(nil, swt_IDI_SHIELD())) } - + @MainActor @Test func attachHICON() throws { let icon = try copyHICON() defer { DestroyIcon(icon) } - + let attachment = Attachment(icon, named: "diamond.jpeg") try attachment.withUnsafeBytes { buffer in #expect(buffer.count > 32) } } - + private func copyHBITMAP() throws -> HBITMAP { let (width, height) = (GetSystemMetrics(SM_CXICON), GetSystemMetrics(SM_CYICON)) - + let icon = try copyHICON() defer { DestroyIcon(icon) } - + let screenDC = try #require(GetDC(nil)) defer { ReleaseDC(nil, screenDC) } - + let dc = try #require(CreateCompatibleDC(nil)) defer { DeleteDC(dc) } - + let bitmap = try #require(CreateCompatibleBitmap(screenDC, width, height)) let oldSelectedObject = SelectObject(dc, bitmap) defer { _ = SelectObject(dc, oldSelectedObject) } DrawIcon(dc, 0, 0, icon) - + return bitmap } - + @MainActor @Test func attachHBITMAP() throws { let bitmap = try copyHBITMAP() defer { DeleteObject(bitmap) } - + let attachment = Attachment(bitmap, named: "diamond.png") try attachment.withUnsafeBytes { buffer in #expect(buffer.count > 32) } Attachment.record(attachment) } - + @MainActor @Test func attachHBITMAPAsJPEG() throws { let bitmap = try copyHBITMAP() defer { @@ -820,7 +820,7 @@ extension AttachmentTests { } let hiFi = Attachment(bitmap, named: "hifi", as: .jpeg(withEncodingQuality: 1.0)) let loFi = Attachment(bitmap, named: "lofi", as: .jpeg(withEncodingQuality: 0.1)) - + try hiFi.withUnsafeBytes { hiFi in try loFi.withUnsafeBytes { loFi in #expect(hiFi.count > loFi.count) @@ -828,18 +828,18 @@ extension AttachmentTests { } Attachment.record(loFi) } - + private func copyIWICBitmap() throws -> UnsafeMutablePointer { let factory = try IWICImagingFactory.create() defer { _ = factory.pointee.lpVtbl.pointee.Release(factory) } - + let bitmap = try copyHBITMAP() defer { DeleteObject(bitmap) } - + var wicBitmap: UnsafeMutablePointer? let rCreate = factory.pointee.lpVtbl.pointee.CreateBitmapFromHBITMAP(factory, bitmap, nil, WICBitmapUsePremultipliedAlpha, &wicBitmap) guard rCreate == S_OK, let wicBitmap else { @@ -847,60 +847,60 @@ extension AttachmentTests { } return wicBitmap } - + @MainActor @Test func attachIWICBitmap() throws { let wicBitmap = try copyIWICBitmap() defer { _ = wicBitmap.pointee.lpVtbl.pointee.Release(wicBitmap) } - + let attachment = Attachment(wicBitmap, named: "diamond.png") try attachment.withUnsafeBytes { buffer in #expect(buffer.count > 32) } Attachment.record(attachment) } - + @MainActor @Test func attachIWICBitmapSource() throws { let wicBitmapSource = try copyIWICBitmap().cast(to: IWICBitmapSource.self) defer { _ = wicBitmapSource.pointee.lpVtbl.pointee.Release(wicBitmapSource) } - + let attachment = Attachment(wicBitmapSource, named: "diamond.png") try attachment.withUnsafeBytes { buffer in #expect(buffer.count > 32) } Attachment.record(attachment) } - + @MainActor @Test func pathExtensionAndCLSID() { let pngCLSID = AttachableImageFormat.png.encoderCLSID let pngFilename = AttachableImageFormat.appendPathExtension(for: pngCLSID, to: "example") #expect(pngFilename == "example.png") - + let jpegCLSID = AttachableImageFormat.jpeg.encoderCLSID let jpegFilename = AttachableImageFormat.appendPathExtension(for: jpegCLSID, to: "example") #expect(jpegFilename == "example.jpeg") - + let pngjpegFilename = AttachableImageFormat.appendPathExtension(for: jpegCLSID, to: "example.png") #expect(pngjpegFilename == "example.png.jpeg") - + let jpgjpegFilename = AttachableImageFormat.appendPathExtension(for: jpegCLSID, to: "example.jpg") #expect(jpgjpegFilename == "example.jpg") } #endif - + #if (canImport(CoreGraphics) && canImport(_Testing_CoreGraphics)) || (canImport(WinSDK) && canImport(_Testing_WinSDK)) @Test func imageFormatFromPathExtension() { let format = AttachableImageFormat(pathExtension: "png") #expect(format != nil) #expect(format == .png) - + let badFormat = AttachableImageFormat(pathExtension: "no-such-image-format") #expect(badFormat == nil) } - + @Test func imageFormatEquatableConformance() { let format1 = AttachableImageFormat.png let format2 = AttachableImageFormat.jpeg @@ -915,7 +915,7 @@ extension AttachmentTests { #expect(format1 != format2) #expect(format2 != format3) #expect(format1 != format3) - + #expect(format1.hashValue == format1.hashValue) #expect(format2.hashValue == format2.hashValue) #expect(format3.hashValue == format3.hashValue) @@ -923,7 +923,7 @@ extension AttachmentTests { #expect(format2.hashValue != format3.hashValue) #expect(format1.hashValue != format3.hashValue) } - + @Test func imageFormatStringification() { let format: AttachableImageFormat = AttachableImageFormat.png #if canImport(CoreGraphics) && canImport(_Testing_CoreGraphics) @@ -934,7 +934,7 @@ extension AttachmentTests { #expect(String(reflecting: format) == "PNG format (27949969-876a-41d7-9447-568f6a35a4dc) at quality 1.0") #endif } - + @Test func imageFormatStringificationWithQuality() { let format: AttachableImageFormat = AttachableImageFormat.jpeg(withEncodingQuality: 0.5) #if canImport(CoreGraphics) && canImport(_Testing_CoreGraphics) From ca1e8bfb339d6df08ba152de9b92258a69d343db Mon Sep 17 00:00:00 2001 From: Jonathan Grynspan Date: Fri, 20 Mar 2026 12:34:15 -0400 Subject: [PATCH 5/8] Fix test import --- Tests/TestingTests/PolymorphismTests.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Tests/TestingTests/PolymorphismTests.swift b/Tests/TestingTests/PolymorphismTests.swift index 621d79156..fc10a63b0 100644 --- a/Tests/TestingTests/PolymorphismTests.swift +++ b/Tests/TestingTests/PolymorphismTests.swift @@ -10,6 +10,10 @@ @_spi(Experimental) @_spi(ForToolsIntegrationOnly) @testable import Testing +#if canImport(Synchronization) +private import Synchronization +#endif + struct `Polymorphic test function tests` { @Test func `Polymorphic functions are discovered and run`() async throws { let testPlan = await Runner.Plan(selecting: PolymorphicBaseClass.self) From b8f62797fcb7de5c1686078aa9381d25ee67aec9 Mon Sep 17 00:00:00 2001 From: Jonathan Grynspan Date: Fri, 20 Mar 2026 14:26:44 -0400 Subject: [PATCH 6/8] Refactor logic to discover polymorphic tests, hopefully make it clearer how it works --- Sources/Testing/CMakeLists.txt | 1 + Sources/Testing/Running/Runner.Plan.swift | 143 ++++++-------------- Sources/Testing/Support/SubclassCache.swift | 137 +++++++++++++++++++ Tests/TestingTests/PolymorphismTests.swift | 4 +- 4 files changed, 183 insertions(+), 102 deletions(-) create mode 100644 Sources/Testing/Support/SubclassCache.swift diff --git a/Sources/Testing/CMakeLists.txt b/Sources/Testing/CMakeLists.txt index 6cde72716..5368864b3 100644 --- a/Sources/Testing/CMakeLists.txt +++ b/Sources/Testing/CMakeLists.txt @@ -100,6 +100,7 @@ add_library(Testing Support/Graph.swift Support/JSON.swift Support/Serializer.swift + Support/SubclassCache.swift Support/VersionNumber.swift Support/Versions.swift Discovery+Macro.swift diff --git a/Sources/Testing/Running/Runner.Plan.swift b/Sources/Testing/Running/Runner.Plan.swift index 73abc2078..3a80c02c4 100644 --- a/Sources/Testing/Running/Runner.Plan.swift +++ b/Sources/Testing/Running/Runner.Plan.swift @@ -8,12 +8,6 @@ // See https://swift.org/CONTRIBUTORS.txt for Swift project authors // -#if _runtime(_ObjC) -private import ObjectiveC -#else -private import _TestDiscovery -#endif - extension Runner { /// A type describing a runner plan. public struct Plan: Sendable { @@ -129,39 +123,6 @@ extension Runner { // MARK: - Constructing a new runner plan extension Runner.Plan { -#if !_runtime(_ObjC) - /// A dictionary keyed by classes whose values are arrays of all known - /// subclasses of those classes. - /// - /// This dictionary is constructed in reverse by walking all known classes in - /// the current process and recursively querying each one for its immediate - /// superclass. This is less efficient than the Objective-C-based - /// implementation (which can avoid realizing classes that aren't of - /// interest to us). - private static let _allSubtypeInfo: [TypeInfo: [TypeInfo]] = { - var result = [TypeInfo: [TypeInfo]]() - - for clazz in allClasses() { - let hierarchy = sequence(first: clazz, next: _getSuperclass) - var subclass: AnyClass? = nil - for clazz in hierarchy { - defer { - subclass = clazz - } - - let typeInfo = TypeInfo(describing: clazz) - if let subclass { - result[typeInfo, default: []].append(TypeInfo(describing: subclass)) - } else { - result[typeInfo, default: []].reserveCapacity(1) - } - } - } - - return result - }() -#endif - /// Recursively apply eligible traits from a test suite to its children in a /// graph. /// @@ -238,79 +199,61 @@ extension Runner.Plan { /// - Parameters: /// - testGraph: The graph of tests to modify. private static func _synthesizePolymorphicTests(in testGraph: inout Graph) { - // First, recursively mark tests as polymorphic if they're contained in - // polymorphic suites. - var polymorphicTests = [Test]() - func makePolymorphicIfNeeded(_ makePolymorphic: Bool, in testGraph: inout Graph) { - var makeChildrenPolymorphic = false + // First, recursively find all classes associated with polymorphic test + // suites (as determined at macro expansion time). + var subclassCache = SubclassCache( + testGraph + .compactMap { $0.value } + .filter(\.isPolymorphic) + .compactMap { $0.containingTypeInfo?.class } + ) + + // The set of all copied tests we created while recursing through the graph. + var testCopies = [Test]() + + // Recursively walk through the graph looking for test functions that are + // associated with classes in the set we created above. Any such test + // functions are implicitly polymorphic themselves. + func makePolymorphicCopies(in testGraph: inout Graph) { if var test = testGraph.value.take() { - if test.isSuite { - // If this test is a polymorphic suite, mark all its child test - // functions (and not nested suites!) as polymorphic too. - makeChildrenPolymorphic = test.isPolymorphic - } else { - if makePolymorphic { - // This is a test function and the parent wants to make its children - // polymorphic, so set the flag. - test.isPolymorphic = true - } + defer { + testGraph.value = test } - // Gather polymorphic tests as we go. If at the end of this recursion - // there were no polymorphic tests found, then we don't need to walk the - // graph again and the transformations below become no-ops. - if test.isPolymorphic { - polymorphicTests.append(test) + // If this test is a test function and its type is marked polymorphic, + // mark the test function as polymorphic too and make copies of it to + // insert into the graph after recursion is complete. + if !test.isSuite, + let clazz = test.containingTypeInfo?.class, + subclassCache.contains(clazz) { + test.isPolymorphic = true + testCopies += subclassCache.subclasses(of: clazz).lazy + .map { subclass in + let subtypeInfo = TypeInfo(describing: subclass) + var testCopy = test + testCopy.containingTypeInfo = subtypeInfo + if testCopy.isSuite { + testCopy.name = subtypeInfo.unqualifiedName + } + testCopy.isSynthesized = true + testCopy.wasInherited = true + return testCopy + } } - testGraph.value = test - } else { - // This node is sparse, so propagate the makePolymorphic flag down - // through it (this is generally expected). - makeChildrenPolymorphic = makePolymorphic } + // Recurse into child nodes. testGraph.children = testGraph.children.mapValues { child in var child = child - makePolymorphicIfNeeded(makeChildrenPolymorphic, in: &child) + makePolymorphicCopies(in: &child) return child } } - makePolymorphicIfNeeded(false, in: &testGraph) - - // For each of the discovered, polymorphic tests, find the type it's applied - // to (which, by definition, must be a class) and find all subclasses - // thereof. - var allSubtypeInfo: [TypeInfo: [TypeInfo]] -#if _runtime(_ObjC) - allSubtypeInfo = Dictionary( - uniqueKeysWithValues: polymorphicTests - .compactMap { $0.test.containingTypeInfo?.class } - .reduce(into: Set()) { baseTypeInfo, clazz in - baseTypeInfo.insert(TypeInfo(describing: clazz)) - }.compactMap { typeInfo in - let clazz: AnyClass = typeInfo.class! - let subtypeInfo = objc_enumerateClasses(subclassing: clazz) - .map { TypeInfo(describing: $0) } - return (typeInfo, subtypeInfo) - } - ) -#else - allSubtypeInfo = _allSubtypeInfo -#endif + makePolymorphicCopies(in: &testGraph) - // For each of the discovered, polymorphic tests, make copies of that test - // for each known subclass and insert them into the test graph. - for test in polymorphicTests { - for subtypeInfo in allSubtypeInfo[test.containingTypeInfo!]! { - var testCopy = test - testCopy.containingTypeInfo = subtypeInfo - if testCopy.isSuite { - testCopy.name = subtypeInfo.unqualifiedName - } - testCopy.isSynthesized = true - testCopy.wasInherited = true - testGraph.insertValue(testCopy, at: testCopy.id.keyPathRepresentation) - } + // Insert the copied tests into the graph. + for testCopy in testCopies { + testGraph.insertValue(testCopy, at: testCopy.id.keyPathRepresentation) } } diff --git a/Sources/Testing/Support/SubclassCache.swift b/Sources/Testing/Support/SubclassCache.swift new file mode 100644 index 000000000..58c0d3677 --- /dev/null +++ b/Sources/Testing/Support/SubclassCache.swift @@ -0,0 +1,137 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for Swift project authors +// + +#if _runtime(_ObjC) +private import ObjectiveC +#else +private import _TestDiscovery +#endif + +/// A type that contains a cache of classes and their known subclasses. +/// +/// - Note: In general, this type is not able to dynamically discover generic +/// classes that are subclasses of a given class. +struct SubclassCache { +#if !_runtime(_ObjC) + /// A dictionary keyed by classes whose values are arrays of all known + /// subclasses of those classes. + /// + /// This dictionary is constructed in reverse by walking all known classes in + /// the current process and recursively querying each one for its immediate + /// superclass. This is less efficient than the Objective-C-based + /// implementation (which can avoid realizing classes that aren't of + /// interest to us). + private static let _allSubclasses: [TypeInfo: [AnyClass]] = { + var result = [TypeInfo: [AnyClass]]() + + for clazz in allClasses() { + let superclasses = sequence(first: clazz, next: _getSuperclass).dropFirst() + for superclass in superclasses { + let typeInfo = TypeInfo(describing: superclass) + result[typeInfo, default: []].append(clazz) + } + } + + return result + }() +#endif + + /// An entry in the subclass cache. + private struct _CacheEntry { + /// Whether or not the represented type belongs in the cache. + var inCache: Bool + + /// The set of known subclasses for this entry, if cached. + var subclasses: [AnyClass]? + } + + /// The set of cached information, keyed by type (class). + /// + /// Negative entries (`inCache = false`) indicate that a type is known _not_ + /// to be contained in this cache (after considering superclasses and + /// subclasses). + private var _cache: [TypeInfo: _CacheEntry] + + /// Initialize an instance of this type to provide information for the given + /// set of base classes. + /// + /// - Parameters: + /// - baseClasses: The set of base classes for which this instance will + /// cache information. + init(_ baseClasses: some Sequence) { + let baseClasses = Set(baseClasses.lazy.map { TypeInfo(describing: $0) }) + _cache = Dictionary(uniqueKeysWithValues: baseClasses.lazy.map { ($0, _CacheEntry(inCache: true)) }) + } + + /// Look up the given type in the cache. + /// + /// - Parameters: + /// - typeInfo: The type to look up. + /// + /// - Returns: Whether or not the given type is contained in this cache. + /// + /// If `typeInfo` represents a class, and one of that class' superclasses is + /// contained in this cache, then that class is _also_ considered to be + /// contained in the cache. + private mutating func _find(_ typeInfo: TypeInfo) -> _CacheEntry? { + if let cached = _cache[typeInfo] { + return cached.inCache ? cached : nil + } + + var superclassFound = false + if let clazz = typeInfo.class, let superclass = _getSuperclass(clazz) { + superclassFound = _find(TypeInfo(describing: superclass)) != nil + } + let result = _CacheEntry(inCache: superclassFound) + _cache[typeInfo] = result + return result + } + + /// Check whether or not a given class is contained in this cache. + /// + /// - Parameters: + /// - clazz: The class to look up. + /// + /// - Returns: Whether or not the given class is contained in this cache. + /// + /// If one of the superclasses of `clazz` is contained in this cache, then + /// `clazz` is _also_ considered to be contained in the cache. + mutating func contains(_ clazz: AnyClass) -> Bool { + _find(TypeInfo(describing: clazz)) != nil + } + + /// Look up all known subclasses of a given class. + /// + /// - Parameters: + /// - clazz: The base class of interest. + /// + /// - Returns: An array of all known subclasses of the given class. + /// + /// If `clazz` or a superclass thereof was not passed to ``init(_:)``, this + /// function returns the empty array. + mutating func subclasses(of clazz: AnyClass) -> [AnyClass] { + let typeInfo = TypeInfo(describing: clazz) + + guard let cached = _find(typeInfo) else { + return [] + } + + if let result = cached.subclasses { + return result + } +#if _runtime(_ObjC) + let result = Array(objc_enumerateClasses(subclassing: clazz)) +#else + let result = Self._allSubclasses[typeInfo] ?? [] +#endif + _cache[typeInfo]!.subclasses = result + return result + } +} diff --git a/Tests/TestingTests/PolymorphismTests.swift b/Tests/TestingTests/PolymorphismTests.swift index fc10a63b0..0bf1fc970 100644 --- a/Tests/TestingTests/PolymorphismTests.swift +++ b/Tests/TestingTests/PolymorphismTests.swift @@ -62,11 +62,11 @@ struct `Polymorphic test function tests` { required init() {} class DoesNotInheritBaseClass { - @Test func `This function should not be inherited`() { + @Test(.hidden) func `This function should not be inherited`() { Issue.record("Should not have run this function.") } - @Suite final class DoesNotInheritDerivedClass: DoesNotInheritBaseClass {} + @Suite(.hidden) final class DoesNotInheritDerivedClass: DoesNotInheritBaseClass {} } class DerivedClass: PolymorphicBaseClass {} From 3f559417215558a0f4afa59c1038590d9a6ad308 Mon Sep 17 00:00:00 2001 From: Jonathan Grynspan Date: Fri, 20 Mar 2026 14:28:23 -0400 Subject: [PATCH 7/8] Note perf bug --- Sources/Testing/Support/SubclassCache.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Testing/Support/SubclassCache.swift b/Sources/Testing/Support/SubclassCache.swift index 58c0d3677..1726e5b11 100644 --- a/Sources/Testing/Support/SubclassCache.swift +++ b/Sources/Testing/Support/SubclassCache.swift @@ -27,7 +27,7 @@ struct SubclassCache { /// the current process and recursively querying each one for its immediate /// superclass. This is less efficient than the Objective-C-based /// implementation (which can avoid realizing classes that aren't of - /// interest to us). + /// interest to us). BUG: rdar://172942099 private static let _allSubclasses: [TypeInfo: [AnyClass]] = { var result = [TypeInfo: [AnyClass]]() From 44a41889b9c2a50361aac9129faa025988db6970 Mon Sep 17 00:00:00 2001 From: Jonathan Grynspan Date: Thu, 16 Apr 2026 12:56:20 -0400 Subject: [PATCH 8/8] Docs and SPI --- Sources/Testing/Test+Macro.swift | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/Sources/Testing/Test+Macro.swift b/Sources/Testing/Test+Macro.swift index 450609e7a..5ea1688e1 100644 --- a/Sources/Testing/Test+Macro.swift +++ b/Sources/Testing/Test+Macro.swift @@ -623,6 +623,11 @@ public func __invokeXCTestMethod( // MARK: - Test inheritance +/// A protocol applied automatically to suite types with the `@polymorphic` +/// attribute. +/// +/// - Warning: This function is used to implement the `@Suite` macro. Do not +/// call it directly. public protocol __PolymorphicSuite: AnyObject & SendableMetatype { init() async throws } @@ -716,8 +721,9 @@ nonisolated(nonsending) func withCurrentPolymorphicSubclassIfNeeded(for test: /// /// - Throws: Whatever is thrown by `initExpr`. /// -/// - Warning: This function is used to implement the `@Test` macro. Do not call -/// it directly. +/// - Warning: This function is used to implement the `@Suite` macro. Do not +/// call it directly. +@_spi(Experimental) @_lifetime(immortal) public nonisolated(nonsending) func __initialize(_ initExpr: nonisolated(nonsending) () async throws -> T) async throws -> T where T: ~Copyable & ~Escapable { try await _overrideLifetime(initExpr(), copying: ()) @@ -733,8 +739,8 @@ public nonisolated(nonsending) func __initialize(_ initExpr: nonisolated(nons /// /// - Throws: Any error that prevented instantiating a subclass of `T`. /// -/// - Warning: This function is used to implement the `@Test` macro. Do not call -/// it directly. +/// - Warning: This function is used to implement the `@Suite` macro. Do not +/// call it directly. @_spi(Experimental) public nonisolated(nonsending) func __initialize(_ initExpr: nonisolated(nonsending) () async throws -> C) async throws -> C where C: __PolymorphicSuite { if let _currentPolymorphicSubclass {