Skip to content

Represent non-encodable test argument values in Test.Case.ID #1000

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

Merged
merged 9 commits into from
Mar 14, 2025
Merged
6 changes: 5 additions & 1 deletion Sources/Testing/ABI/Encoded/ABI.EncodedTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -124,9 +124,13 @@ extension ABI {
var displayName: String

init(encoding testCase: borrowing Test.Case) {
guard let arguments = testCase.arguments else {
preconditionFailure("Attempted to initialize an EncodedTestCase encoding a test case which is not parameterized: \(testCase). Please file a bug report at https://github.com/swiftlang/swift-testing/issues/new")
}

// TODO: define an encodable form of Test.Case.ID
id = String(describing: testCase.id)
displayName = testCase.arguments.lazy
displayName = arguments.lazy
.map(\.value)
.map(String.init(describingForTest:))
.joined(separator: ", ")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -171,8 +171,14 @@ extension Test.Case {
/// - Parameters:
/// - includeTypeNames: Whether the qualified type name of each argument's
/// runtime type should be included. Defaults to `false`.
///
/// - Returns: A string containing the arguments of this test case formatted
/// for presentation, or an empty string if this test cases is
/// non-parameterized.
fileprivate func labeledArguments(includingQualifiedTypeNames includeTypeNames: Bool = false) -> String {
arguments.lazy
guard let arguments else { return "" }

return arguments.lazy
.map { argument in
let valueDescription = String(describingForTest: argument.value)

Expand Down Expand Up @@ -494,14 +500,14 @@ extension Event.HumanReadableOutputRecorder {
return result

case .testCaseStarted:
guard let testCase = eventContext.testCase, testCase.isParameterized else {
guard let testCase = eventContext.testCase, testCase.isParameterized, let arguments = testCase.arguments else {
break
}

return [
Message(
symbol: .default,
stringValue: "Passing \(testCase.arguments.count.counting("argument")) \(testCase.labeledArguments(includingQualifiedTypeNames: verbosity > 0)) to \(testName)"
stringValue: "Passing \(arguments.count.counting("argument")) \(testCase.labeledArguments(includingQualifiedTypeNames: verbosity > 0)) to \(testName)"
)
]

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,18 +44,25 @@ public protocol CustomTestArgumentEncodable: Sendable {
}

extension Test.Case.Argument.ID {
/// Initialize this instance with an ID for the specified test argument.
/// Initialize an ID instance with the specified test argument value.
///
/// - Parameters:
/// - value: The value of a test argument for which to get an ID.
/// - parameter: The parameter of the test function to which this argument
/// value was passed.
///
/// - Returns: `nil` if an ID cannot be formed from the specified test
/// - Returns: `nil` if a stable ID cannot be formed from the specified test
/// argument value.
///
/// - Throws: Any error encountered while attempting to encode `value`.
///
/// If a stable representation of `value` can be encoded successfully, the
/// value of this instance's `bytes` property will be the the bytes of that
/// encoded JSON representation and this instance may be considered stable. If
/// no stable representation of `value` can be obtained, `nil` is returned. If
/// a stable representation was obtained but failed to encode, the error
/// resulting from the encoding attempt is thrown.
///
/// This function is not part of the public interface of the testing library.
///
/// ## See Also
Expand Down Expand Up @@ -83,7 +90,7 @@ extension Test.Case.Argument.ID {
return nil
}

self = .init(bytes: try Self._encode(encodableValue, parameter: parameter))
self.init(bytes: try Self._encode(encodableValue, parameter: parameter))
#else
nil
#endif
Expand Down
29 changes: 27 additions & 2 deletions Sources/Testing/Parameterization/Test.Case.Generator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ extension Test.Case {
// A beautiful hack to give us the right number of cases: iterate over a
// collection containing a single Void value.
self.init(sequence: CollectionOfOne(())) { _ in
Test.Case(arguments: [], body: testFunction)
Test.Case(body: testFunction)
}
}

Expand Down Expand Up @@ -257,7 +257,32 @@ extension Test.Case {

extension Test.Case.Generator: Sequence {
func makeIterator() -> some IteratorProtocol<Test.Case> {
_sequence.lazy.map(_mapElement).makeIterator()
let state = (
iterator: _sequence.makeIterator(),
testCaseIDs: [Test.Case.ID: Int](minimumCapacity: underestimatedCount)
)

return sequence(state: state) { state in
guard let element = state.iterator.next() else {
return nil
}

var testCase = _mapElement(element)

if testCase.isParameterized {
// Store the original, unmodified test case ID. We're about to modify a
// property which affects it, and we want to update state based on the
// original one.
let testCaseID = testCase.id

// Ensure test cases with identical IDs each have a unique discriminator.
let discriminator = state.testCaseIDs[testCaseID, default: 0]
Copy link
Contributor

@grynspan grynspan Mar 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the benefit of maintaining a dictionary here? What if the discriminator were just the enumerated() index of the case? Yes, we'd end up skipping over some discriminators, but so what—they'll still be unique in this context, and it'll be much cheaper to compute and store them. [P2]

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The goal is different than you may be thinking: I'm not trying to assign each test case the next-highest integer as the discriminator, which would be equivalent to the "enumeration index" of the collection; instead I'm trying to locate the test cases for each parameterized test function which have identical arguments, and for each such subset, assign each of those test cases a unique discriminator. Consider this example:

@Test(arguments: [9, 9, 8, 7]
func example(_: Int) {}

This will have 4 test cases, and with this PR's changes, they should be (in pseudo-code):

[
  Test.Case(arguments: [9], discriminator: 0),
  Test.Case(arguments: [9], discriminator: 1),
  Test.Case(arguments: [8], discriminator: 0), // back to 0 here!
  Test.Case(arguments: [7], discriminator: 0), // and here!
]

The end goal is to be able to detect duplicates of the same set of arguments. If we used enumerated() on the original collection that would be harder for clients to detect duplicates, and for the testing library itself to detect and report warnings for them (which I intend to do in a follow-on PR). I also worry that would tempt clients to abuse the discriminator value and interpret it as an index of the original collection, and that's explicitly not what I'm intending—particularly because the order may differ between successive runs (for example for hashed collections).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see—is there something we can do that's less heavyweight than a hash table?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure. The use of a dictionary doesn't seem that heavyweight to me in context. This isn't now causing values to be encoded where they wouldn't otherwise be, if that's the concern.

One small optimization I just realized I could make, though, is to specify a capacity for the dictionary based on the generator's underestimatedCount.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A concern I have is that this is going to scale at least linearly with the number of test cases in a test. As of right now, we try to avoid keeping information about all test cases in memory during a run. If the inputs are an artisanally crafted array of values, then that's one thing. But if there's a dynamically generated sequence of inputs that's particularly long, we're now going to pay a memory cost to track all of them.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dictionary only stores their IDs, not the test cases themselves, and I don't expect that to be onerous. I definitely share your view that we should not be holding on to test case instances themselves for the duration of the test run, or even for the lifetime of a Test.Case.Generator.

We already "accumulate" the IDs of Test instances in places like Event.HumanReadableOutputRecorder to keep track of events per-test. I imagine in the not-too-distant future we'll want to extend that pattern to test cases too, so that (for example) when running in verbose mode, our console output could log when test cases finish and how many failures each one had. That topic came up in #943, and was only partly solved by #972. One of my motivations for this PR is to allow us to properly implement that. I've been meaning to file a new issue to track that, so I just filed #1021.

testCase.discriminator = discriminator
state.testCaseIDs[testCaseID] = discriminator + 1
}

return testCase
}
}

var underestimatedCount: Int {
Expand Down
119 changes: 97 additions & 22 deletions Sources/Testing/Parameterization/Test.Case.ID.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,50 +15,125 @@ extension Test.Case {
/// parameterized test function. They are not necessarily unique across two
/// different ``Test`` instances.
@_spi(ForToolsIntegrationOnly)
public struct ID: Sendable, Equatable, Hashable {
public struct ID: Sendable {
/// The IDs of the arguments of this instance's associated ``Test/Case``, in
/// the order they appear in ``Test/Case/arguments``.
///
/// The value of this property is `nil` if _any_ of the associated test
/// case's arguments has a `nil` ID.
/// The value of this property is `nil` for the ID of the single test case
/// associated with a non-parameterized test function.
public var argumentIDs: [Argument.ID]?

public init(argumentIDs: [Argument.ID]?) {
/// A number used to distinguish this test case from others associated with
/// the same parameterized test function whose arguments have the same ID.
///
/// The value of this property is `nil` for the ID of the single test case
/// associated with a non-parameterized test function.
///
/// ## See Also
///
/// - ``Test/Case/discriminator``
public var discriminator: Int?

/// Whether or not this test case ID is considered stable across successive
/// runs.
public var isStable: Bool

init(argumentIDs: [Argument.ID]?, discriminator: Int?, isStable: Bool) {
precondition((argumentIDs == nil) == (discriminator == nil))

self.argumentIDs = argumentIDs
self.discriminator = discriminator
self.isStable = isStable
}
}

@_spi(ForToolsIntegrationOnly)
public var id: ID {
let argumentIDs = arguments.compactMap(\.id)
guard argumentIDs.count == arguments.count else {
return ID(argumentIDs: nil)
}

return ID(argumentIDs: argumentIDs)
ID(argumentIDs: arguments.map { $0.map(\.id) }, discriminator: discriminator, isStable: isStable)
}
}

// MARK: - CustomStringConvertible

extension Test.Case.ID: CustomStringConvertible {
public var description: String {
"argumentIDs: \(String(describing: argumentIDs))"
if let argumentIDs, let discriminator {
"Parameterized test case ID: argumentIDs: \(argumentIDs), discriminator: \(discriminator), isStable: \(isStable)"
} else {
"Non-parameterized test case ID"
}
}
}

// MARK: - Codable

extension Test.Case.ID: Codable {}
extension Test.Case.ID: Codable {
private enum CodingKeys: String, CodingKey {
/// A coding key for ``Test/Case/ID/argumentIDs``.
///
/// This case's string value is non-standard because ``legacyArgumentIDs``
/// already used "argumentIDs" and this needs to be different.
case argumentIDs = "argIDs"

// MARK: - Equatable
/// A coding key for ``Test/Case/ID/discriminator``.
case discriminator

// We cannot safely implement Equatable for Test.Case because its values are
// type-erased. It does conform to `Identifiable`, but its ID type is composed
// of the IDs of its arguments, and those IDs are not always available (for
// example, if the type of an argument is not Codable). Thus, we cannot check
// for equality of test cases based on this, because if two test cases had
// different arguments, but the type of those arguments is not Codable, they
// both will have a `nil` ID and would incorrectly be considered equal.
//
// `Test.Case.ID` is Equatable, however.
/// A coding key for ``Test/Case/ID/isStable``.
case isStable

/// A coding key for the legacy representation of ``Test/Case/ID/argumentIDs``.
///
/// This case's string value is non-standard in order to maintain
/// legacy compatibility with its original value.
case legacyArgumentIDs = "argumentIDs"
}

public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)

if container.contains(.isStable) {
// `isStable` is present, so we're decoding an instance encoded using the
// newest style: every property can be decoded straightforwardly.
try self.init(
argumentIDs: container.decodeIfPresent([Test.Case.Argument.ID].self, forKey: .argumentIDs),
discriminator: container.decodeIfPresent(Int.self, forKey: .discriminator),
isStable: container.decode(Bool.self, forKey: .isStable)
)
} else if container.contains(.legacyArgumentIDs) {
// `isStable` is absent, so we're decoding using the old style. Since the
// legacy `argumentIDs` is present, the representation should be
// considered stable.
let decodedArgumentIDs = try container.decode([Test.Case.Argument.ID].self, forKey: .legacyArgumentIDs)
let argumentIDs = decodedArgumentIDs.isEmpty ? nil : decodedArgumentIDs

// Discriminator should be `nil` for the ID of a non-parameterized test
// case, but can default to 0 for the ID of a parameterized test case.
let discriminator = argumentIDs == nil ? nil : 0

self.init(argumentIDs: argumentIDs, discriminator: discriminator, isStable: true)
} else {
// This is the old style, and since `argumentIDs` is absent, we know this
// ID represents a parameterized test case which is non-stable.
self.init(argumentIDs: [.init(bytes: [])], discriminator: 0, isStable: false)
}
}

public func encode(to encoder: any Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)

try container.encode(isStable, forKey: .isStable)
try container.encodeIfPresent(discriminator, forKey: .discriminator)
try container.encodeIfPresent(argumentIDs, forKey: .argumentIDs)

// Encode the legacy representation of `argumentIDs`.
if argumentIDs == nil {
try container.encode([Test.Case.Argument.ID](), forKey: .legacyArgumentIDs)
} else if isStable, let argumentIDs = argumentIDs {
try container.encode(argumentIDs, forKey: .legacyArgumentIDs)
}
}
}

// MARK: - Equatable, Hashable

extension Test.Case.ID: Equatable, Hashable {}
Loading