Skip to content

Commit 6d20200

Browse files
authored
Limit the amount of value reflection data expectation checking collects by default (#915)
This modifies the internal logic which recursively collects reflection data about values passed to `#expect(...)` and similar expectation APIs so that it imposes an artificial limit on the amount of data collected. ### Motivation: Some users have attempted to use `#expect` with instances of values with large collections or with deep value hierarchies involving many sub-properties, etc. In these situations they have noticed that the automatic value reflection is adding a noticeable amount of overhead to the overall test execution time, and would like to reduce that by default (near term), as well as have some ability to control that so the behavior could be tweaked for particular tests (in the future). ### Modifications: - Add some new `Configuration` properties controlling data collection behaviors and setting default limits. - Consult these new configuration properties in the relevant places in `Expression.Value`. - Make `Expression.Value.init(reflecting:)` optional and return `nil` when value reflection is disabled in the configuration. - Add a new initializer `Expression.Value.init(describing:)` as a lighter-weight alternative to `init(reflecting:)` which only forms a string description of its subject, and adopt this as a fallback in places affected by the new optional in the previous bullet. - Add representative new unit tests. ### Checklist: - [x] Code and documentation should follow the style of the [Style Guide](https://github.com/apple/swift-testing/blob/main/Documentation/StyleGuide.md). - [x] If public symbols are renamed or modified, DocC references should be updated. Fixes rdar://138208832
1 parent 0538f22 commit 6d20200

File tree

5 files changed

+247
-30
lines changed

5 files changed

+247
-30
lines changed

Sources/Testing/Parameterization/Test.Case.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ extension Test.Case.Argument {
214214
/// - argument: The original test case argument to snapshot.
215215
public init(snapshotting argument: Test.Case.Argument) {
216216
id = argument.id
217-
value = Expression.Value(reflecting: argument.value)
217+
value = Expression.Value(reflecting: argument.value) ?? .init(describing: argument.value)
218218
parameter = argument.parameter
219219
}
220220
}

Sources/Testing/Running/Configuration.swift

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,53 @@ public struct Configuration: Sendable {
258258

259259
/// The test case filter to which test cases should be filtered when run.
260260
public var testCaseFilter: TestCaseFilter = { _, _ in true }
261+
262+
// MARK: - Expectation value reflection
263+
264+
/// The options to use when reflecting values in expressions checked by
265+
/// expectations, or `nil` if reflection is disabled.
266+
///
267+
/// When the value of this property is a non-`nil` instance, values checked by
268+
/// expressions will be reflected using `Mirror` and the specified options
269+
/// will influence how that reflection is formed. Otherwise, when its value is
270+
/// `nil`, value reflection will not use `Mirror` and instead will use
271+
/// `String(describing:)`.
272+
///
273+
/// The default value of this property is an instance of ``ValueReflectionOptions-swift.struct``
274+
/// with its properties initialized to their default values.
275+
public var valueReflectionOptions: ValueReflectionOptions? = .init()
276+
277+
/// A type describing options to use when forming a reflection of a value
278+
/// checked by an expectation.
279+
public struct ValueReflectionOptions: Sendable {
280+
/// The maximum number of elements that can included in a single child
281+
/// collection when reflecting a value checked by an expectation.
282+
///
283+
/// When ``Expression/Value/init(reflecting:)`` is reflecting a value and it
284+
/// encounters a child value which is a collection, it consults the value of
285+
/// this property and only includes the children of that collection up to
286+
/// this maximum count. After this maximum is reached, all subsequent
287+
/// elements are omitted and a single placeholder child is added indicating
288+
/// the number of elements which have been truncated.
289+
public var maximumCollectionCount = 10
290+
291+
/// The maximum depth of children that can be included in the reflection of
292+
/// a checked expectation value.
293+
///
294+
/// When ``Expression/Value/init(reflecting:)`` is reflecting a value, it
295+
/// recursively reflects that value's children. Before doing so, it consults
296+
/// the value of this property to determine the maximum depth of the
297+
/// children to include. After this maximum depth is reached, all children
298+
/// at deeper levels are omitted and the ``Expression/Value/isTruncated``
299+
/// property is set to `true` to reflect that the reflection is incomplete.
300+
///
301+
/// - Note: `Optional` values contribute twice towards this maximum, since
302+
/// their mirror represents the wrapped value as a child of the optional.
303+
/// Since optionals are common, the default value of this property is
304+
/// somewhat larger than it otherwise would be in an attempt to make the
305+
/// defaults useful for real-world tests.
306+
public var maximumChildDepth = 10
307+
}
261308
}
262309

263310
// MARK: - Deprecated

Sources/Testing/Running/Runner.RuntimeState.swift

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,27 @@ extension Configuration {
7272
/// - Returns: Whatever is returned by `body`.
7373
///
7474
/// - Throws: Whatever is thrown by `body`.
75+
static func withCurrent<R>(_ configuration: Self, perform body: () throws -> R) rethrows -> R {
76+
let id = configuration._addToAll()
77+
defer {
78+
configuration._removeFromAll(identifiedBy: id)
79+
}
80+
81+
var runtimeState = Runner.RuntimeState.current ?? .init()
82+
runtimeState.configuration = configuration
83+
return try Runner.RuntimeState.$current.withValue(runtimeState, operation: body)
84+
}
85+
86+
/// Call an asynchronous function while the value of ``Configuration/current``
87+
/// is set.
88+
///
89+
/// - Parameters:
90+
/// - configuration: The new value to set for ``Configuration/current``.
91+
/// - body: A function to call.
92+
///
93+
/// - Returns: Whatever is returned by `body`.
94+
///
95+
/// - Throws: Whatever is thrown by `body`.
7596
static func withCurrent<R>(_ configuration: Self, perform body: () async throws -> R) async rethrows -> R {
7697
let id = configuration._addToAll()
7798
defer {

Sources/Testing/SourceAttribution/Expression.swift

Lines changed: 90 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,16 @@ public struct __Expression: Sendable {
156156
/// property is `nil`.
157157
public var label: String?
158158

159+
/// Whether or not the values of certain properties of this instance have
160+
/// been truncated for brevity.
161+
///
162+
/// If the value of this property is `true`, this instance does not
163+
/// represent its original value completely because doing so would exceed
164+
/// the maximum allowed data collection settings of the ``Configuration`` in
165+
/// effect. When this occurs, the value ``children`` is not guaranteed to be
166+
/// accurate or complete.
167+
public var isTruncated: Bool = false
168+
159169
/// Whether or not this value represents a collection of values.
160170
public var isCollection: Bool
161171

@@ -167,14 +177,47 @@ public struct __Expression: Sendable {
167177
/// the value it represents contains substructural values.
168178
public var children: [Self]?
169179

180+
/// Initialize an instance of this type describing the specified subject.
181+
///
182+
/// - Parameters:
183+
/// - subject: The subject this instance should describe.
184+
init(describing subject: Any) {
185+
description = String(describingForTest: subject)
186+
debugDescription = String(reflecting: subject)
187+
typeInfo = TypeInfo(describingTypeOf: subject)
188+
189+
let mirror = Mirror(reflecting: subject)
190+
isCollection = mirror.displayStyle?.isCollection ?? false
191+
}
192+
193+
/// Initialize an instance of this type with the specified description.
194+
///
195+
/// - Parameters:
196+
/// - description: The value to use for this instance's `description`
197+
/// property.
198+
///
199+
/// Unlike ``init(describing:)``, this initializer does not use
200+
/// ``String/init(describingForTest:)`` to form a description.
201+
private init(_description description: String) {
202+
self.description = description
203+
self.debugDescription = description
204+
typeInfo = TypeInfo(describing: String.self)
205+
isCollection = false
206+
}
207+
170208
/// Initialize an instance of this type describing the specified subject and
171209
/// its children (if any).
172210
///
173211
/// - Parameters:
174-
/// - subject: The subject this instance should describe.
175-
init(reflecting subject: Any) {
212+
/// - subject: The subject this instance should reflect.
213+
init?(reflecting subject: Any) {
214+
let configuration = Configuration.current ?? .init()
215+
guard let options = configuration.valueReflectionOptions else {
216+
return nil
217+
}
218+
176219
var seenObjects: [ObjectIdentifier: AnyObject] = [:]
177-
self.init(_reflecting: subject, label: nil, seenObjects: &seenObjects)
220+
self.init(_reflecting: subject, label: nil, seenObjects: &seenObjects, depth: 0, options: options)
178221
}
179222

180223
/// Initialize an instance of this type describing the specified subject and
@@ -189,11 +232,28 @@ public struct __Expression: Sendable {
189232
/// this initializer recursively, keyed by their object identifiers.
190233
/// This is used to halt further recursion if a previously-seen object
191234
/// is encountered again.
235+
/// - depth: The depth of this recursive call.
236+
/// - options: The configuration options to use when deciding how to
237+
/// reflect `subject`.
192238
private init(
193239
_reflecting subject: Any,
194240
label: String?,
195-
seenObjects: inout [ObjectIdentifier: AnyObject]
241+
seenObjects: inout [ObjectIdentifier: AnyObject],
242+
depth: Int,
243+
options: Configuration.ValueReflectionOptions
196244
) {
245+
// Stop recursing if we've reached the maximum allowed depth for
246+
// reflection. Instead, return a node describing this value instead and
247+
// set `isTruncated` to `true`.
248+
if depth >= options.maximumChildDepth {
249+
self = Self(describing: subject)
250+
isTruncated = true
251+
return
252+
}
253+
254+
self.init(describing: subject)
255+
self.label = label
256+
197257
let mirror = Mirror(reflecting: subject)
198258

199259
// If the subject being reflected is an instance of a reference type (e.g.
@@ -236,24 +296,19 @@ public struct __Expression: Sendable {
236296
}
237297
}
238298

239-
description = String(describingForTest: subject)
240-
debugDescription = String(reflecting: subject)
241-
typeInfo = TypeInfo(describingTypeOf: subject)
242-
self.label = label
243-
244-
isCollection = switch mirror.displayStyle {
245-
case .some(.collection),
246-
.some(.dictionary),
247-
.some(.set):
248-
true
249-
default:
250-
false
251-
}
252-
253299
if shouldIncludeChildren && (!mirror.children.isEmpty || isCollection) {
254-
self.children = mirror.children.map { child in
255-
Self(_reflecting: child.value, label: child.label, seenObjects: &seenObjects)
300+
var children: [Self] = []
301+
for (index, child) in mirror.children.enumerated() {
302+
if isCollection && index >= options.maximumCollectionCount {
303+
isTruncated = true
304+
let message = "(\(mirror.children.count - index) out of \(mirror.children.count) elements omitted for brevity)"
305+
children.append(Self(_description: message))
306+
break
307+
}
308+
309+
children.append(Self(_reflecting: child.value, label: child.label, seenObjects: &seenObjects, depth: depth + 1, options: options))
256310
}
311+
self.children = children
257312
}
258313
}
259314
}
@@ -274,7 +329,7 @@ public struct __Expression: Sendable {
274329
/// value captured for future use.
275330
func capturingRuntimeValue(_ value: (some Any)?) -> Self {
276331
var result = self
277-
result.runtimeValue = value.map { Value(reflecting: $0) }
332+
result.runtimeValue = value.flatMap(Value.init(reflecting:))
278333
if case let .negation(subexpression, isParenthetical) = kind, let value = value as? Bool {
279334
result.kind = .negation(subexpression.capturingRuntimeValue(!value), isParenthetical: isParenthetical)
280335
}
@@ -547,3 +602,17 @@ extension __Expression.Value: CustomStringConvertible, CustomDebugStringConverti
547602
/// ```
548603
@_spi(ForToolsIntegrationOnly)
549604
public typealias Expression = __Expression
605+
606+
extension Mirror.DisplayStyle {
607+
/// Whether or not this display style represents a collection of values.
608+
fileprivate var isCollection: Bool {
609+
switch self {
610+
case .collection,
611+
.dictionary,
612+
.set:
613+
true
614+
default:
615+
false
616+
}
617+
}
618+
}

0 commit comments

Comments
 (0)