Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Limit the amount of value reflection data expectation checking collects by default #915

Merged
merged 3 commits into from
Jan 17, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Sources/Testing/Parameterization/Test.Case.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down
47 changes: 47 additions & 0 deletions Sources/Testing/Running/Configuration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions Sources/Testing/Running/Runner.RuntimeState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,27 @@ extension Configuration {
/// - Returns: Whatever is returned by `body`.
///
/// - Throws: Whatever is thrown by `body`.
static func withCurrent<R>(_ 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<R>(_ configuration: Self, perform body: () async throws -> R) async rethrows -> R {
let id = configuration._addToAll()
defer {
Expand Down
111 changes: 90 additions & 21 deletions Sources/Testing/SourceAttribution/Expression.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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
}
}
}
Expand All @@ -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)
}
Expand Down Expand Up @@ -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
}
}
}
Loading