diff --git a/Sources/Testing/Parameterization/Test.Case.swift b/Sources/Testing/Parameterization/Test.Case.swift index db35c3aa1..80ff101da 100644 --- a/Sources/Testing/Parameterization/Test.Case.swift +++ b/Sources/Testing/Parameterization/Test.Case.swift @@ -214,7 +214,7 @@ extension Test.Case.Argument { /// - argument: The original test case argument to snapshot. public init(snapshotting argument: Test.Case.Argument) { id = argument.id - value = Expression.Value(reflecting: argument.value) + value = Expression.Value(reflecting: argument.value) ?? .init(describing: argument.value) parameter = argument.parameter } } diff --git a/Sources/Testing/Running/Configuration.swift b/Sources/Testing/Running/Configuration.swift index 3f9ed67f4..bdb9cbbef 100644 --- a/Sources/Testing/Running/Configuration.swift +++ b/Sources/Testing/Running/Configuration.swift @@ -258,6 +258,53 @@ public struct Configuration: Sendable { /// The test case filter to which test cases should be filtered when run. public var testCaseFilter: TestCaseFilter = { _, _ in true } + + // MARK: - Expectation value reflection + + /// The options to use when reflecting values in expressions checked by + /// expectations, or `nil` if reflection is disabled. + /// + /// When the value of this property is a non-`nil` instance, values checked by + /// expressions will be reflected using `Mirror` and the specified options + /// will influence how that reflection is formed. Otherwise, when its value is + /// `nil`, value reflection will not use `Mirror` and instead will use + /// `String(describing:)`. + /// + /// The default value of this property is an instance of ``ValueReflectionOptions-swift.struct`` + /// with its properties initialized to their default values. + public var valueReflectionOptions: ValueReflectionOptions? = .init() + + /// A type describing options to use when forming a reflection of a value + /// checked by an expectation. + public struct ValueReflectionOptions: Sendable { + /// The maximum number of elements that can included in a single child + /// collection when reflecting a value checked by an expectation. + /// + /// When ``Expression/Value/init(reflecting:)`` is reflecting a value and it + /// encounters a child value which is a collection, it consults the value of + /// this property and only includes the children of that collection up to + /// this maximum count. After this maximum is reached, all subsequent + /// elements are omitted and a single placeholder child is added indicating + /// the number of elements which have been truncated. + public var maximumCollectionCount = 10 + + /// The maximum depth of children that can be included in the reflection of + /// a checked expectation value. + /// + /// When ``Expression/Value/init(reflecting:)`` is reflecting a value, it + /// recursively reflects that value's children. Before doing so, it consults + /// the value of this property to determine the maximum depth of the + /// children to include. After this maximum depth is reached, all children + /// at deeper levels are omitted and the ``Expression/Value/isTruncated`` + /// property is set to `true` to reflect that the reflection is incomplete. + /// + /// - Note: `Optional` values contribute twice towards this maximum, since + /// their mirror represents the wrapped value as a child of the optional. + /// Since optionals are common, the default value of this property is + /// somewhat larger than it otherwise would be in an attempt to make the + /// defaults useful for real-world tests. + public var maximumChildDepth = 10 + } } // MARK: - Deprecated diff --git a/Sources/Testing/Running/Runner.RuntimeState.swift b/Sources/Testing/Running/Runner.RuntimeState.swift index a128d9ce9..f69e13cd6 100644 --- a/Sources/Testing/Running/Runner.RuntimeState.swift +++ b/Sources/Testing/Running/Runner.RuntimeState.swift @@ -72,6 +72,27 @@ extension Configuration { /// - Returns: Whatever is returned by `body`. /// /// - Throws: Whatever is thrown by `body`. + static func withCurrent(_ configuration: Self, perform body: () throws -> R) rethrows -> R { + let id = configuration._addToAll() + defer { + configuration._removeFromAll(identifiedBy: id) + } + + var runtimeState = Runner.RuntimeState.current ?? .init() + runtimeState.configuration = configuration + return try Runner.RuntimeState.$current.withValue(runtimeState, operation: body) + } + + /// Call an asynchronous function while the value of ``Configuration/current`` + /// is set. + /// + /// - Parameters: + /// - configuration: The new value to set for ``Configuration/current``. + /// - body: A function to call. + /// + /// - Returns: Whatever is returned by `body`. + /// + /// - Throws: Whatever is thrown by `body`. static func withCurrent(_ configuration: Self, perform body: () async throws -> R) async rethrows -> R { let id = configuration._addToAll() defer { diff --git a/Sources/Testing/SourceAttribution/Expression.swift b/Sources/Testing/SourceAttribution/Expression.swift index 3f6a7e716..dce4ed2a2 100644 --- a/Sources/Testing/SourceAttribution/Expression.swift +++ b/Sources/Testing/SourceAttribution/Expression.swift @@ -156,6 +156,16 @@ public struct __Expression: Sendable { /// property is `nil`. public var label: String? + /// Whether or not the values of certain properties of this instance have + /// been truncated for brevity. + /// + /// If the value of this property is `true`, this instance does not + /// represent its original value completely because doing so would exceed + /// the maximum allowed data collection settings of the ``Configuration`` in + /// effect. When this occurs, the value ``children`` is not guaranteed to be + /// accurate or complete. + public var isTruncated: Bool = false + /// Whether or not this value represents a collection of values. public var isCollection: Bool @@ -167,14 +177,47 @@ public struct __Expression: Sendable { /// the value it represents contains substructural values. public var children: [Self]? + /// Initialize an instance of this type describing the specified subject. + /// + /// - Parameters: + /// - subject: The subject this instance should describe. + init(describing subject: Any) { + description = String(describingForTest: subject) + debugDescription = String(reflecting: subject) + typeInfo = TypeInfo(describingTypeOf: subject) + + let mirror = Mirror(reflecting: subject) + isCollection = mirror.displayStyle?.isCollection ?? false + } + + /// Initialize an instance of this type with the specified description. + /// + /// - Parameters: + /// - description: The value to use for this instance's `description` + /// property. + /// + /// Unlike ``init(describing:)``, this initializer does not use + /// ``String/init(describingForTest:)`` to form a description. + private init(_description description: String) { + self.description = description + self.debugDescription = description + typeInfo = TypeInfo(describing: String.self) + isCollection = false + } + /// Initialize an instance of this type describing the specified subject and /// its children (if any). /// /// - Parameters: - /// - subject: The subject this instance should describe. - init(reflecting subject: Any) { + /// - subject: The subject this instance should reflect. + init?(reflecting subject: Any) { + let configuration = Configuration.current ?? .init() + guard let options = configuration.valueReflectionOptions else { + return nil + } + var seenObjects: [ObjectIdentifier: AnyObject] = [:] - self.init(_reflecting: subject, label: nil, seenObjects: &seenObjects) + self.init(_reflecting: subject, label: nil, seenObjects: &seenObjects, depth: 0, options: options) } /// Initialize an instance of this type describing the specified subject and @@ -189,11 +232,28 @@ public struct __Expression: Sendable { /// this initializer recursively, keyed by their object identifiers. /// This is used to halt further recursion if a previously-seen object /// is encountered again. + /// - depth: The depth of this recursive call. + /// - options: The configuration options to use when deciding how to + /// reflect `subject`. private init( _reflecting subject: Any, label: String?, - seenObjects: inout [ObjectIdentifier: AnyObject] + seenObjects: inout [ObjectIdentifier: AnyObject], + depth: Int, + options: Configuration.ValueReflectionOptions ) { + // Stop recursing if we've reached the maximum allowed depth for + // reflection. Instead, return a node describing this value instead and + // set `isTruncated` to `true`. + if depth >= options.maximumChildDepth { + self = Self(describing: subject) + isTruncated = true + return + } + + self.init(describing: subject) + self.label = label + let mirror = Mirror(reflecting: subject) // If the subject being reflected is an instance of a reference type (e.g. @@ -236,24 +296,19 @@ public struct __Expression: Sendable { } } - description = String(describingForTest: subject) - debugDescription = String(reflecting: subject) - typeInfo = TypeInfo(describingTypeOf: subject) - self.label = label - - isCollection = switch mirror.displayStyle { - case .some(.collection), - .some(.dictionary), - .some(.set): - true - default: - false - } - if shouldIncludeChildren && (!mirror.children.isEmpty || isCollection) { - self.children = mirror.children.map { child in - Self(_reflecting: child.value, label: child.label, seenObjects: &seenObjects) + var children: [Self] = [] + for (index, child) in mirror.children.enumerated() { + if isCollection && index >= options.maximumCollectionCount { + isTruncated = true + let message = "(\(mirror.children.count - index) out of \(mirror.children.count) elements omitted for brevity)" + children.append(Self(_description: message)) + break + } + + children.append(Self(_reflecting: child.value, label: child.label, seenObjects: &seenObjects, depth: depth + 1, options: options)) } + self.children = children } } } @@ -274,7 +329,7 @@ public struct __Expression: Sendable { /// value captured for future use. func capturingRuntimeValue(_ value: (some Any)?) -> Self { var result = self - result.runtimeValue = value.map { Value(reflecting: $0) } + result.runtimeValue = value.flatMap(Value.init(reflecting:)) if case let .negation(subexpression, isParenthetical) = kind, let value = value as? Bool { result.kind = .negation(subexpression.capturingRuntimeValue(!value), isParenthetical: isParenthetical) } @@ -547,3 +602,17 @@ extension __Expression.Value: CustomStringConvertible, CustomDebugStringConverti /// ``` @_spi(ForToolsIntegrationOnly) public typealias Expression = __Expression + +extension Mirror.DisplayStyle { + /// Whether or not this display style represents a collection of values. + fileprivate var isCollection: Bool { + switch self { + case .collection, + .dictionary, + .set: + true + default: + false + } + } +} diff --git a/Tests/TestingTests/Expression.ValueTests.swift b/Tests/TestingTests/Expression.ValueTests.swift index c9929f59c..e1e18d4ea 100644 --- a/Tests/TestingTests/Expression.ValueTests.swift +++ b/Tests/TestingTests/Expression.ValueTests.swift @@ -21,7 +21,7 @@ struct Expression_ValueTests { let foo = Foo() - let value = Expression.Value(reflecting: foo) + let value = try #require(Expression.Value(reflecting: foo)) let children = try #require(value.children) try #require(children.count == 1) @@ -49,7 +49,7 @@ struct Expression_ValueTests { x.one = y x.two = y - let value = Expression.Value(reflecting: x) + let value = try #require(Expression.Value(reflecting: x)) let children = try #require(value.children) try #require(children.count == 3) @@ -78,7 +78,7 @@ struct Expression_ValueTests { x.two = y y.two = x - let value = Expression.Value(reflecting: x) + let value = try #require(Expression.Value(reflecting: x)) let children = try #require(value.children) try #require(children.count == 3) @@ -116,7 +116,7 @@ struct Expression_ValueTests { let recursiveItem = RecursiveItem() recursiveItem.anotherItem = recursiveItem - let value = Expression.Value(reflecting: recursiveItem) + let value = try #require(Expression.Value(reflecting: recursiveItem)) let children = try #require(value.children) try #require(children.count == 2) @@ -142,7 +142,7 @@ struct Expression_ValueTests { one.two = two two.one = one - let value = Expression.Value(reflecting: one) + let value = try #require(Expression.Value(reflecting: one)) let children = try #require(value.children) try #require(children.count == 1) @@ -168,7 +168,7 @@ struct Expression_ValueTests { @Test("Value reflecting an object with two back-references to itself", .bug("https://github.com/swiftlang/swift-testing/issues/785#issuecomment-2440222995")) - func multipleSelfReferences() { + func multipleSelfReferences() throws { class A { weak var one: A? weak var two: A? @@ -178,7 +178,7 @@ struct Expression_ValueTests { a.one = a a.two = a - let value = Expression.Value(reflecting: a) + let value = try #require(Expression.Value(reflecting: a)) #expect(value.children?.count == 2) } @@ -208,8 +208,88 @@ struct Expression_ValueTests { b.c = c c.a = a - let value = Expression.Value(reflecting: a) + let value = try #require(Expression.Value(reflecting: a)) #expect(value.children?.count == 3) } + @Test("Value reflection can be disabled via Configuration") + func valueReflectionDisabled() { + var configuration = Configuration.current ?? .init() + configuration.valueReflectionOptions = nil + Configuration.withCurrent(configuration) { + #expect(Expression.Value(reflecting: "hello") == nil) + } + } + + @Test("Value reflection truncates large values") + func reflectionOfLargeValues() throws { + struct Large { + var foo: Int? + var bar: [Int] + } + + var configuration = Configuration.current ?? .init() + var options = configuration.valueReflectionOptions ?? .init() + options.maximumCollectionCount = 2 + options.maximumChildDepth = 2 + configuration.valueReflectionOptions = options + + try Configuration.withCurrent(configuration) { + let large = Large(foo: 123, bar: [4, 5, 6, 7]) + let value = try #require(Expression.Value(reflecting: large)) + + #expect(!value.isTruncated) + do { + let fooValue = try #require(value.children?.first) + #expect(!fooValue.isTruncated) + let fooChildren = try #require(fooValue.children) + try #require(fooChildren.count == 1) + let fooChild = try #require(fooChildren.first) + #expect(fooChild.isTruncated) + #expect(fooChild.children == nil) + } + do { + let barValue = try #require(value.children?.last) + #expect(barValue.isTruncated) + #expect(barValue.children?.count == 3) + let lastBarChild = try #require(barValue.children?.last) + #expect(String(describing: lastBarChild) == "(2 out of 4 elements omitted for brevity)") + } + } + } + + @Test("Value reflection max collection count only applies to collections") + func reflectionMaximumCollectionCount() throws { + struct X { + var a = 1 + var b = 2 + var c = 3 + var d = 4 + } + + var configuration = Configuration.current ?? .init() + var options = configuration.valueReflectionOptions ?? .init() + options.maximumCollectionCount = 2 + configuration.valueReflectionOptions = options + + try Configuration.withCurrent(configuration) { + let x = X() + let value = try #require(Expression.Value(reflecting: x)) + #expect(!value.isTruncated) + #expect(value.children?.count == 4) + } + } + + @Test("Value describing a simple struct") + func describeSimpleStruct() { + struct Foo { + var x: Int = 123 + } + + let foo = Foo() + let value = Expression.Value(describing: foo) + #expect(String(describing: value) == "Foo(x: 123)") + #expect(value.children == nil) + } + }