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

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

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -43,50 +43,76 @@ public protocol CustomTestArgumentEncodable: Sendable {
func encodeTestArgument(to encoder: some Encoder) throws
}

/// Get the best encodable representation of a test argument value, if any.
///
/// - Parameters:
/// - value: The value for which an encodable representation is requested.
///
/// - Returns: The best encodable representation of `value`, if one is available,
/// otherwise `nil`.
///
/// For a description of the heuristics used to obtain an encodable
/// representation of an argument value, see <doc:ParameterizedTesting>.
func encodableArgumentValue(for value: some Sendable) -> (any Encodable)? {
#if canImport(Foundation)
// Helper for opening an existential.
func customArgumentWrapper(for value: some CustomTestArgumentEncodable) -> some Encodable {
_CustomArgumentWrapper(rawValue: value)
}

return if let customEncodable = value as? any CustomTestArgumentEncodable {
customArgumentWrapper(for: customEncodable)
} else if let rawRepresentable = value as? any RawRepresentable, let encodableRawValue = rawRepresentable.rawValue as? any Encodable {
encodableRawValue
} else if let encodable = value as? any Encodable {
encodable
} else if let identifiable = value as? any Identifiable, let encodableID = identifiable.id as? any Encodable {
encodableID
} else {
nil
}
#else
return nil
#endif
}

extension Test.Case.Argument.ID {
/// Initialize this instance with an ID for the specified test argument.
///
/// - Parameters:
/// - value: The value of a test argument for which to get an ID.
/// - encodableValue: An encodable representation of `value`, if any, with
/// which to attempt to encode a stable representation.
/// - 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
/// argument value.
///
/// - Throws: Any error encountered while attempting to encode `value`.
/// If a representation of `value` can be successfully encoded, the value of
/// this instance's `bytes` property will be the the bytes of that encoded
/// JSON representation and the value of its `isStable` property will be
/// `true`. Otherwise, the value of its `bytes` property will be the bytes of
/// a textual description of `value` and the value of `isStable` will be
/// `false` to reflect that the representation is not considered stable.
///
/// This function is not part of the public interface of the testing library.
///
/// ## See Also
///
/// - ``CustomTestArgumentEncodable``
init?(identifying value: some Sendable, parameter: Test.Parameter) throws {
init(identifying value: some Sendable, encodableValue: (any Encodable)?, parameter: Test.Parameter) {
#if canImport(Foundation)
func customArgumentWrapper(for value: some CustomTestArgumentEncodable) -> some Encodable {
_CustomArgumentWrapper(rawValue: value)
}

let encodableValue: (any Encodable)? = if let customEncodable = value as? any CustomTestArgumentEncodable {
customArgumentWrapper(for: customEncodable)
} else if let rawRepresentable = value as? any RawRepresentable, let encodableRawValue = rawRepresentable.rawValue as? any Encodable {
encodableRawValue
} else if let encodable = value as? any Encodable {
encodable
} else if let identifiable = value as? any Identifiable, let encodableID = identifiable.id as? any Encodable {
encodableID
} else {
nil
}

guard let encodableValue else {
return nil
if let encodableValue {
do {
self = .init(bytes: try Self._encode(encodableValue, parameter: parameter), isStable: true)
return
} catch {
// FIXME: Capture the error and propagate to the user, not as a test
// failure but as an advisory warning. A missing argument ID will
// prevent re-running the test case, but is not a blocking issue.
}
}

self = .init(bytes: try Self._encode(encodableValue, parameter: parameter))
#else
nil
#endif

self = .init(bytes: String(describingForTest: value).utf8, isStable: false)
}

#if canImport(Foundation)
Expand Down
24 changes: 22 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,27 @@ extension Test.Case {

extension Test.Case.Generator: Sequence {
func makeIterator() -> some IteratorProtocol<Test.Case> {
_sequence.lazy.map(_mapElement).makeIterator()
sequence(state: (
iterator: _sequence.makeIterator(),
testCaseIDs: [Test.Case.ID: Int]()
)) { state in
guard let element = state.iterator.next() else {
return nil
}

var testCase = _mapElement(element)

// 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.
testCase.discriminator = state.testCaseIDs[testCaseID, default: 0]
state.testCaseIDs[testCaseID] = testCase.discriminator + 1

return testCase
}
}

var underestimatedCount: Int {
Expand Down
65 changes: 42 additions & 23 deletions Sources/Testing/Parameterization/Test.Case.ID.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,50 +15,69 @@ 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``.
public var argumentIDs: [Argument.ID]

/// A number used to distinguish this test case from others associated with
/// the same test function whose arguments have the same ID.
///
/// ## See Also
///
/// The value of this property is `nil` if _any_ of the associated test
/// case's arguments has a `nil` ID.
public var argumentIDs: [Argument.ID]?
/// - ``Test/Case/discriminator``
public var discriminator: Int

public init(argumentIDs: [Argument.ID]?) {
public init(argumentIDs: [Argument.ID], discriminator: Int) {
self.argumentIDs = argumentIDs
self.discriminator = discriminator
}

/// Whether or not this test case ID is considered stable across successive
/// runs.
///
/// The value of this property is `true` if all of the argument IDs for this
/// instance are stable, otherwise it is `false`.
public var isStable: Bool {
argumentIDs.allSatisfy(\.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(\.id), discriminator: discriminator)
}
}

// MARK: - CustomStringConvertible

extension Test.Case.ID: CustomStringConvertible {
public var description: String {
"argumentIDs: \(String(describing: argumentIDs))"
"argumentIDs: \(argumentIDs), discriminator: \(discriminator)"
}
}

// MARK: - Codable

extension Test.Case.ID: Codable {}
extension Test.Case.ID: Codable {
public init(from decoder: some Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)

// MARK: - Equatable
// The `argumentIDs` property was optional when this type was first
// introduced, and a `nil` value represented a non-stable test case ID.
// To maintain previous behavior, if this value is absent when decoding,
// default to a single argument ID marked as non-stable.
let argumentIDs = try container.decodeIfPresent([Test.Case.Argument.ID].self, forKey: .argumentIDs)
?? [Test.Case.Argument.ID(bytes: [], isStable: false)]

// 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.
// The `discriminator` property was added after this type was first
// introduced. It can safely default to zero when absent.
let discriminator = try container.decodeIfPresent(type(of: discriminator), forKey: .discriminator) ?? 0

self.init(argumentIDs: argumentIDs, discriminator: discriminator)
}
}

// MARK: - Equatable, Hashable

extension Test.Case.ID: Hashable {}
Loading