diff --git a/Sources/Testing/ABI/Encoded/ABI.EncodedTest.swift b/Sources/Testing/ABI/Encoded/ABI.EncodedTest.swift index 51d01781d..cda558f83 100644 --- a/Sources/Testing/ABI/Encoded/ABI.EncodedTest.swift +++ b/Sources/Testing/ABI/Encoded/ABI.EncodedTest.swift @@ -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: ", ") diff --git a/Sources/Testing/Events/Recorder/Event.HumanReadableOutputRecorder.swift b/Sources/Testing/Events/Recorder/Event.HumanReadableOutputRecorder.swift index 0e856facf..f585495a9 100644 --- a/Sources/Testing/Events/Recorder/Event.HumanReadableOutputRecorder.swift +++ b/Sources/Testing/Events/Recorder/Event.HumanReadableOutputRecorder.swift @@ -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) @@ -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)" ) ] diff --git a/Sources/Testing/Parameterization/CustomTestArgumentEncodable.swift b/Sources/Testing/Parameterization/CustomTestArgumentEncodable.swift index 58d738f11..90b3ff292 100644 --- a/Sources/Testing/Parameterization/CustomTestArgumentEncodable.swift +++ b/Sources/Testing/Parameterization/CustomTestArgumentEncodable.swift @@ -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 @@ -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 diff --git a/Sources/Testing/Parameterization/Test.Case.Generator.swift b/Sources/Testing/Parameterization/Test.Case.Generator.swift index d4d583e48..d30e3a7d3 100644 --- a/Sources/Testing/Parameterization/Test.Case.Generator.swift +++ b/Sources/Testing/Parameterization/Test.Case.Generator.swift @@ -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) } } @@ -257,7 +257,32 @@ extension Test.Case { extension Test.Case.Generator: Sequence { func makeIterator() -> some IteratorProtocol { - _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] + testCase.discriminator = discriminator + state.testCaseIDs[testCaseID] = discriminator + 1 + } + + return testCase + } } var underestimatedCount: Int { diff --git a/Sources/Testing/Parameterization/Test.Case.ID.swift b/Sources/Testing/Parameterization/Test.Case.ID.swift index 26b57fdf8..b29268104 100644 --- a/Sources/Testing/Parameterization/Test.Case.ID.swift +++ b/Sources/Testing/Parameterization/Test.Case.ID.swift @@ -15,27 +15,41 @@ 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) } } @@ -43,22 +57,83 @@ extension Test.Case { 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 {} diff --git a/Sources/Testing/Parameterization/Test.Case.swift b/Sources/Testing/Parameterization/Test.Case.swift index 80ff101da..ab9183cf8 100644 --- a/Sources/Testing/Parameterization/Test.Case.swift +++ b/Sources/Testing/Parameterization/Test.Case.swift @@ -15,6 +15,30 @@ extension Test { /// Tests that are _not_ parameterized map to a single instance of /// ``Test/Case``. public struct Case: Sendable { + /// An enumeration describing the various kinds of test cases. + private enum _Kind: Sendable { + /// A test case associated with a non-parameterized test function. + /// + /// There is only one test case with this kind associated with each + /// non-parameterized test function. + case nonParameterized + + /// A test case associated with a parameterized test function. + /// + /// - Parameters: + /// - arguments: The arguments passed to the parameterized test function + /// this test case is associated with. + /// - discriminator: A number used to distinguish this test case from + /// others associated with the same parameterized test function whose + /// arguments have the same ID. + /// - isStable: Whether or not this test case is considered stable + /// across successive runs. + case parameterized(arguments: [Argument], discriminator: Int, isStable: Bool) + } + + /// The kind of this test case. + private var _kind: _Kind + /// A type representing an argument passed to a parameter of a parameterized /// test function. @_spi(Experimental) @_spi(ForToolsIntegrationOnly) @@ -26,41 +50,36 @@ extension Test { /// The raw bytes of this instance's identifier. public var bytes: [UInt8] - public init(bytes: [UInt8]) { - self.bytes = bytes + init(bytes: some Sequence) { + self.bytes = Array(bytes) } } - /// The ID of this parameterized test argument, if any. + /// The value of this parameterized test argument. + public var value: any Sendable + + /// The ID of this parameterized test argument. /// /// The uniqueness of this value is narrow: it is considered unique only /// within the scope of the parameter of the test function this argument /// was passed to. /// - /// The value of this property is `nil` when an ID cannot be formed. This - /// may occur if the type of ``value`` does not conform to one of the - /// protocols used for encoding a stable and unique representation of the - /// value. - /// /// ## See Also /// /// - ``CustomTestArgumentEncodable`` - @_spi(ForToolsIntegrationOnly) - public var id: ID? { - // 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. - try? Argument.ID(identifying: value, parameter: parameter) - } - - /// The value of this parameterized test argument. - public var value: any Sendable + public var id: ID /// The parameter of the test function to which this argument was passed. public var parameter: Parameter + + init(id: ID, value: any Sendable, parameter: Parameter) { + self.id = id + self.value = value + self.parameter = parameter + } } - /// The arguments passed to this test case. + /// The arguments passed to this test case, if any. /// /// If the argument was a tuple but its elements were passed to distinct /// parameters of the test function, each element of the tuple will be @@ -70,19 +89,87 @@ extension Test { /// represented as one ``Argument`` instance. /// /// Non-parameterized test functions will have a single test case instance, - /// and the value of this property will be an empty array for such test - /// cases. + /// and the value of this property will be `nil` for such test cases. @_spi(Experimental) @_spi(ForToolsIntegrationOnly) - public var arguments: [Argument] + public var arguments: [Argument]? { + switch _kind { + case .nonParameterized: + nil + case let .parameterized(arguments, _, _): + arguments + } + } - init( - arguments: [Argument], - body: @escaping @Sendable () async throws -> Void - ) { - self.arguments = arguments + /// A number used to distinguish this test case from others associated with + /// the same parameterized test function whose arguments have the same ID. + /// + /// As an example, imagine the same argument is passed more than once to a + /// parameterized test: + /// + /// ```swift + /// @Test(arguments: [1, 1]) + /// func example(x: Int) { ... } + /// ``` + /// + /// There will be two ``Test/Case`` instances associated with this test + /// function. Each will represent one instance of the repeated argument `1`, + /// and each will have a different value for this property. + /// + /// The value of this property for successive runs of the same test are not + /// guaranteed to be the same. The value of this property may be equal for + /// two test cases associated with the same test if the IDs of their + /// arguments are different. The value of this property is `nil` for the + /// single test case associated with a non-parameterized test function. + @_spi(Experimental) @_spi(ForToolsIntegrationOnly) + public internal(set) var discriminator: Int? { + get { + switch _kind { + case .nonParameterized: + nil + case let .parameterized(_, discriminator, _): + discriminator + } + } + set { + switch _kind { + case .nonParameterized: + precondition(newValue == nil, "A non-nil discriminator may only be set for a test case which is parameterized.") + case let .parameterized(arguments, _, isStable): + guard let newValue else { + preconditionFailure("A nil discriminator may only be set for a test case which is not parameterized.") + } + _kind = .parameterized(arguments: arguments, discriminator: newValue, isStable: isStable) + } + } + } + + /// Whether or not this test case is considered stable across successive + /// runs. + @_spi(Experimental) @_spi(ForToolsIntegrationOnly) + public var isStable: Bool { + switch _kind { + case .nonParameterized: + true + case let .parameterized(_, _, isStable): + isStable + } + } + + private init(kind: _Kind, body: @escaping @Sendable () async throws -> Void) { + self._kind = kind self.body = body } + /// Initialize a test case for a non-parameterized test function. + /// + /// - Parameters: + /// - body: The body closure of this test case. + /// + /// The resulting test case will have zero arguments. + init(body: @escaping @Sendable () async throws -> Void) { + self.init(kind: .nonParameterized, body: body) + } + /// Initialize a test case by pairing values with their corresponding /// parameters to form the ``arguments`` array. /// @@ -95,15 +182,50 @@ extension Test { parameters: [Parameter], body: @escaping @Sendable () async throws -> Void ) { + var isStable = true + let arguments = zip(values, parameters).map { value, parameter in - Argument(value: value, parameter: parameter) + var stableArgumentID: Argument.ID? + + // Attempt to get a stable, encoded representation of this value if no + // such attempts for previous values have failed. + if isStable { + do { + stableArgumentID = try .init(identifying: value, parameter: parameter) + } catch { + // FIXME: Capture the error and propagate to the user, not as a test + // failure but as an advisory warning. A missing stable argument ID + // will prevent re-running the test case, but isn't a blocking issue. + } + } + + let argumentID: Argument.ID + if let stableArgumentID { + argumentID = stableArgumentID + } else { + // If we couldn't get a stable representation of at least one value, + // give up and consider the overall test case non-stable. This allows + // skipping unnecessary work later: if any individual argument doesn't + // have a stable ID, there's no point encoding the values which _are_ + // encodable. + isStable = false + argumentID = .init(bytes: String(describingForTest: value).utf8) + } + + return Argument(id: argumentID, value: value, parameter: parameter) } - self.init(arguments: arguments, body: body) + + self.init(kind: .parameterized(arguments: arguments, discriminator: 0, isStable: isStable), body: body) } /// Whether or not this test case is from a parameterized test. public var isParameterized: Bool { - !arguments.isEmpty + switch _kind { + case .nonParameterized: + false + case .parameterized: + true + } } /// The body closure of this test case. @@ -188,7 +310,11 @@ extension Test.Case { /// - testCase: The original test case to snapshot. public init(snapshotting testCase: borrowing Test.Case) { id = testCase.id - arguments = testCase.arguments.map(Test.Case.Argument.Snapshot.init) + arguments = if let arguments = testCase.arguments { + arguments.map(Test.Case.Argument.Snapshot.init) + } else { + [] + } } } } diff --git a/Tests/TestingTests/EventTests.swift b/Tests/TestingTests/EventTests.swift index 941dcadb9..653ad2a87 100644 --- a/Tests/TestingTests/EventTests.swift +++ b/Tests/TestingTests/EventTests.swift @@ -57,7 +57,7 @@ struct EventTests { let testID = Test.ID(moduleName: "ModuleName", nameComponents: ["NameComponent1", "NameComponent2"], sourceLocation: #_sourceLocation) - let testCaseID = Test.Case.ID(argumentIDs: nil) + let testCaseID = Test.Case.ID(argumentIDs: nil, discriminator: nil, isStable: true) let event = Event(kind, testID: testID, testCaseID: testCaseID, instant: .now) let eventSnapshot = Event.Snapshot(snapshotting: event) let decoded = try JSON.encodeAndDecode(eventSnapshot) diff --git a/Tests/TestingTests/Test.Case.Argument.IDTests.swift b/Tests/TestingTests/Test.Case.Argument.IDTests.swift index ced76adac..052213912 100644 --- a/Tests/TestingTests/Test.Case.Argument.IDTests.swift +++ b/Tests/TestingTests/Test.Case.Argument.IDTests.swift @@ -20,10 +20,10 @@ struct Test_Case_Argument_IDTests { ) { _ in } let testCases = try #require(test.testCases) let testCase = try #require(testCases.first { _ in true }) - #expect(testCase.arguments.count == 1) - let argument = try #require(testCase.arguments.first) - let argumentID = try #require(argument.id) - #expect(String(decoding: argumentID.bytes, as: UTF8.self) == "123") + let arguments = try #require(testCase.arguments) + #expect(arguments.count == 1) + let argument = try #require(arguments.first) + #expect(String(decoding: argument.id.bytes, as: UTF8.self) == "123") } @Test("One CustomTestArgumentEncodable parameter") @@ -34,11 +34,11 @@ struct Test_Case_Argument_IDTests { ) { _ in } let testCases = try #require(test.testCases) let testCase = try #require(testCases.first { _ in true }) - #expect(testCase.arguments.count == 1) - let argument = try #require(testCase.arguments.first) - let argumentID = try #require(argument.id) + let arguments = try #require(testCase.arguments) + #expect(arguments.count == 1) + let argument = try #require(arguments.first) #if canImport(Foundation) - let decodedArgument = try argumentID.bytes.withUnsafeBufferPointer { argumentID in + let decodedArgument = try argument.id.bytes.withUnsafeBufferPointer { argumentID in try JSON.decode(MyCustomTestArgument.self, from: .init(argumentID)) } #expect(decodedArgument == MyCustomTestArgument(x: 123, y: "abc")) @@ -53,10 +53,10 @@ struct Test_Case_Argument_IDTests { ) { _ in } let testCases = try #require(test.testCases) let testCase = try #require(testCases.first { _ in true }) - #expect(testCase.arguments.count == 1) - let argument = try #require(testCase.arguments.first) - let argumentID = try #require(argument.id) - #expect(String(decoding: argumentID.bytes, as: UTF8.self) == #""abc""#) + let arguments = try #require(testCase.arguments) + #expect(arguments.count == 1) + let argument = try #require(arguments.first) + #expect(String(decoding: argument.id.bytes, as: UTF8.self) == #""abc""#) } @Test("One RawRepresentable parameter") @@ -67,10 +67,10 @@ struct Test_Case_Argument_IDTests { ) { _ in } let testCases = try #require(test.testCases) let testCase = try #require(testCases.first { _ in true }) - #expect(testCase.arguments.count == 1) - let argument = try #require(testCase.arguments.first) - let argumentID = try #require(argument.id) - #expect(String(decoding: argumentID.bytes, as: UTF8.self) == #""abc""#) + let arguments = try #require(testCase.arguments) + #expect(arguments.count == 1) + let argument = try #require(arguments.first) + #expect(String(decoding: argument.id.bytes, as: UTF8.self) == #""abc""#) } } diff --git a/Tests/TestingTests/Test.Case.ArgumentTests.swift b/Tests/TestingTests/Test.Case.ArgumentTests.swift index a5c5e7462..4ea9925d6 100644 --- a/Tests/TestingTests/Test.Case.ArgumentTests.swift +++ b/Tests/TestingTests/Test.Case.ArgumentTests.swift @@ -19,10 +19,10 @@ struct Test_Case_ArgumentTests { guard case .testCaseStarted = event.kind else { return } - let testCase = try #require(context.testCase) - try #require(testCase.arguments.count == 1) + let arguments = try #require(context.testCase?.arguments) + try #require(arguments.count == 1) - let argument = testCase.arguments[0] + let argument = arguments[0] #expect(argument.value as? String == "value") #expect(argument.parameter.index == 0) #expect(argument.parameter.firstName == "x") @@ -38,17 +38,17 @@ struct Test_Case_ArgumentTests { guard case .testCaseStarted = event.kind else { return } - let testCase = try #require(context.testCase) - try #require(testCase.arguments.count == 2) + let arguments = try #require(context.testCase?.arguments) + try #require(arguments.count == 2) do { - let argument = testCase.arguments[0] + let argument = arguments[0] #expect(argument.value as? String == "value") #expect(argument.parameter.index == 0) #expect(argument.parameter.firstName == "x") } do { - let argument = testCase.arguments[1] + let argument = arguments[1] #expect(argument.value as? Int == 123) #expect(argument.parameter.index == 1) #expect(argument.parameter.firstName == "y") @@ -65,10 +65,10 @@ struct Test_Case_ArgumentTests { guard case .testCaseStarted = event.kind else { return } - let testCase = try #require(context.testCase) - try #require(testCase.arguments.count == 1) + let arguments = try #require(context.testCase?.arguments) + try #require(arguments.count == 1) - let argument = testCase.arguments[0] + let argument = arguments[0] #expect(argument.value as? (String) == ("value")) #expect(argument.parameter.index == 0) #expect(argument.parameter.firstName == "x") @@ -84,10 +84,10 @@ struct Test_Case_ArgumentTests { guard case .testCaseStarted = event.kind else { return } - let testCase = try #require(context.testCase) - try #require(testCase.arguments.count == 1) + let arguments = try #require(context.testCase?.arguments) + try #require(arguments.count == 1) - let argument = testCase.arguments[0] + let argument = arguments[0] let value = try #require(argument.value as? (String, Int)) #expect(value.0 == "value") #expect(value.1 == 123) @@ -105,17 +105,17 @@ struct Test_Case_ArgumentTests { guard case .testCaseStarted = event.kind else { return } - let testCase = try #require(context.testCase) - try #require(testCase.arguments.count == 2) + let arguments = try #require(context.testCase?.arguments) + try #require(arguments.count == 2) do { - let argument = testCase.arguments[0] + let argument = arguments[0] #expect(argument.value as? String == "value") #expect(argument.parameter.index == 0) #expect(argument.parameter.firstName == "x") } do { - let argument = testCase.arguments[1] + let argument = arguments[1] #expect(argument.value as? Int == 123) #expect(argument.parameter.index == 1) #expect(argument.parameter.firstName == "y") @@ -132,10 +132,10 @@ struct Test_Case_ArgumentTests { guard case .testCaseStarted = event.kind else { return } - let testCase = try #require(context.testCase) - try #require(testCase.arguments.count == 1) + let arguments = try #require(context.testCase?.arguments) + try #require(arguments.count == 1) - let argument = testCase.arguments[0] + let argument = arguments[0] let value = try #require(argument.value as? (String, Int)) #expect(value.0 == "value") #expect(value.1 == 123) diff --git a/Tests/TestingTests/Test.Case.GeneratorTests.swift b/Tests/TestingTests/Test.Case.GeneratorTests.swift new file mode 100644 index 000000000..0c2afd0ef --- /dev/null +++ b/Tests/TestingTests/Test.Case.GeneratorTests.swift @@ -0,0 +1,31 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for Swift project authors +// + +@testable @_spi(Experimental) @_spi(ForToolsIntegrationOnly) import Testing + +@Suite("Test.Case.Generator Tests") +struct Test_Case_GeneratorTests { + @Test func uniqueDiscriminators() throws { + let generator = Test.Case.Generator( + arguments: [1, 1, 1], + parameters: [Test.Parameter(index: 0, firstName: "x", type: Int.self)], + testFunction: { _ in } + ) + + let testCases = Array(generator) + #expect(testCases.count == 3) + + let firstCase = try #require(testCases.first) + #expect(firstCase.id.discriminator == 0) + + let discriminators = Set(testCases.map(\.id.discriminator)) + #expect(discriminators.count == 3) + } +} diff --git a/Tests/TestingTests/Test.CaseTests.swift b/Tests/TestingTests/Test.CaseTests.swift new file mode 100644 index 000000000..f9e955fe9 --- /dev/null +++ b/Tests/TestingTests/Test.CaseTests.swift @@ -0,0 +1,177 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for Swift project authors +// + +@testable @_spi(Experimental) @_spi(ForToolsIntegrationOnly) import Testing + +#if canImport(Foundation) +private import Foundation +#endif + +@Suite("Test.Case Tests") +struct Test_CaseTests { + @Test func nonParameterized() throws { + let testCase = Test.Case(body: {}) + #expect(testCase.id.argumentIDs == nil) + #expect(testCase.id.discriminator == nil) + } + + @Test func singleStableArgument() throws { + let testCase = Test.Case( + values: [1], + parameters: [Test.Parameter(index: 0, firstName: "x", type: Int.self)], + body: {} + ) + #expect(testCase.id.isStable) + } + + @Test func twoStableArguments() throws { + let testCase = Test.Case( + values: [1, "a"], + parameters: [ + Test.Parameter(index: 0, firstName: "x", type: Int.self), + Test.Parameter(index: 1, firstName: "y", type: String.self), + ], + body: {} + ) + #expect(testCase.id.isStable) + } + + @Test("Two arguments: one non-stable, followed by one stable") + func nonStableAndStableArgument() throws { + let testCase = Test.Case( + values: [NonCodable(), IssueRecordingEncodable()], + parameters: [ + Test.Parameter(index: 0, firstName: "x", type: NonCodable.self), + Test.Parameter(index: 1, firstName: "y", type: IssueRecordingEncodable.self), + ], + body: {} + ) + #expect(!testCase.id.isStable) + } + + @Suite("Test.Case.ID Tests") + struct IDTests { +#if canImport(Foundation) + @Test(arguments: [ + Test.Case.ID(argumentIDs: nil, discriminator: nil, isStable: true), + Test.Case.ID(argumentIDs: [.init(bytes: "x".utf8)], discriminator: 0, isStable: false), + Test.Case.ID(argumentIDs: [.init(bytes: #""abc""#.utf8)], discriminator: 0, isStable: true), + ]) + func roundTripping(id: Test.Case.ID) throws { + #expect(try JSON.encodeAndDecode(id) == id) + } + + @Test func legacyDecoding_stable() throws { + let encodedData = Data(""" + {"argumentIDs": [ + {"bytes": [1]} + ]} + """.utf8) + let testCaseID = try JSON.decode(Test.Case.ID.self, from: encodedData) + #expect(testCaseID.isStable) + + let argumentIDs = try #require(testCaseID.argumentIDs) + #expect(argumentIDs.count == 1) + } + + @Test func legacyDecoding_nonStable() throws { + let encodedData = Data("{}".utf8) + let testCaseID = try JSON.decode(Test.Case.ID.self, from: encodedData) + #expect(!testCaseID.isStable) + + let argumentIDs = try #require(testCaseID.argumentIDs) + #expect(argumentIDs.count == 1) + } + + @Test func legacyDecoding_nonParameterized() throws { + let encodedData = Data(#"{"argumentIDs": []}"#.utf8) + let testCaseID = try JSON.decode(Test.Case.ID.self, from: encodedData) + #expect(testCaseID.isStable) + #expect(testCaseID.argumentIDs == nil) + #expect(testCaseID.discriminator == nil) + } + + @Test func newDecoding_nonParameterized() throws { + let encodedData = Data(#"{"isStable": true}"#.utf8) + let testCaseID = try JSON.decode(Test.Case.ID.self, from: encodedData) + #expect(testCaseID.isStable) + #expect(testCaseID.argumentIDs == nil) + #expect(testCaseID.discriminator == nil) + } + + @Test func newDecoding_parameterizedStable() throws { + let encodedData = Data(""" + { + "isStable": true, + "argIDs": [ + {"bytes": [1]} + ], + "discriminator": 0 + } + """.utf8) + let testCaseID = try JSON.decode(Test.Case.ID.self, from: encodedData) + #expect(testCaseID.isStable) + #expect(testCaseID.argumentIDs?.count == 1) + #expect(testCaseID.discriminator == 0) + } + + @Test func newEncoding_nonParameterized() throws { + let id = Test.Case.ID(argumentIDs: nil, discriminator: nil, isStable: true) + let legacyID = try JSON.withEncoding(of: id) { data in + try JSON.decode(_LegacyTestCaseID.self, from: data) + } + let argumentIDs = try #require(legacyID.argumentIDs) + #expect(argumentIDs.isEmpty) + } + + @Test func newEncoding_parameterizedNonStable() throws { + let id = Test.Case.ID( + argumentIDs: [.init(bytes: "x".utf8)], + discriminator: 0, + isStable: false + ) + let legacyID = try JSON.withEncoding(of: id) { data in + try JSON.decode(_LegacyTestCaseID.self, from: data) + } + #expect(legacyID.argumentIDs == nil) + } + + @Test func newEncoding_parameterizedStable() throws { + let id = Test.Case.ID( + argumentIDs: [.init(bytes: #""abc""#.utf8)], + discriminator: 0, + isStable: true + ) + let legacyID = try JSON.withEncoding(of: id) { data in + try JSON.decode(_LegacyTestCaseID.self, from: data) + } + let argumentIDs = try #require(legacyID.argumentIDs) + #expect(argumentIDs.count == 1) + let argumentID = try #require(argumentIDs.first) + #expect(String(decoding: argumentID.bytes, as: UTF8.self) == #""abc""#) + } +#endif + } +} + +// MARK: - Fixtures, helpers + +private struct NonCodable {} + +private struct IssueRecordingEncodable: Encodable { + func encode(to encoder: any Encoder) throws { + Issue.record("Unexpected attempt to encode an instance of \(Self.self)") + } +} + +/// A fixture type which implements legacy decoding for ``Test/Case/ID``. +private struct _LegacyTestCaseID: Decodable { + var argumentIDs: [Test.Case.Argument.ID]? +} diff --git a/Tests/TestingTests/TestCaseSelectionTests.swift b/Tests/TestingTests/TestCaseSelectionTests.swift index de8b10c66..d24ddf8f6 100644 --- a/Tests/TestingTests/TestCaseSelectionTests.swift +++ b/Tests/TestingTests/TestCaseSelectionTests.swift @@ -85,8 +85,9 @@ struct TestCaseSelectionTests { } let selectedTestCase = try #require(fixtureTest.testCases?.first { testCase in - guard let firstArg = testCase.arguments.first?.value as? String, - let secondArg = testCase.arguments.last?.value as? Int + guard let arguments = testCase.arguments, + let firstArg = arguments.first?.value as? String, + let secondArg = arguments.last?.value as? Int else { return false } diff --git a/Tests/TestingTests/TestSupport/TestingAdditions.swift b/Tests/TestingTests/TestSupport/TestingAdditions.swift index 5a0121444..6807fd62a 100644 --- a/Tests/TestingTests/TestSupport/TestingAdditions.swift +++ b/Tests/TestingTests/TestSupport/TestingAdditions.swift @@ -9,10 +9,15 @@ // @testable @_spi(Experimental) @_spi(ForToolsIntegrationOnly) import Testing + #if canImport(XCTest) import XCTest #endif +#if canImport(Foundation) +import Foundation +#endif + extension Tag { /// A tag indicating that a test is related to a trait. @Tag static var traitRelated: Self @@ -348,6 +353,24 @@ extension JSON { try JSON.decode(T.self, from: data) } } + +#if canImport(Foundation) + /// Decode a value from JSON data. + /// + /// - Parameters: + /// - type: The type of value to decode. + /// - jsonRepresentation: Data of the JSON encoding of the value to decode. + /// + /// - Returns: An instance of `T` decoded from `jsonRepresentation`. + /// + /// - Throws: Whatever is thrown by the decoding process. + @_disfavoredOverload + static func decode(_ type: T.Type, from jsonRepresentation: Data) throws -> T where T: Decodable { + try jsonRepresentation.withUnsafeBytes { bytes in + try JSON.decode(type, from: bytes) + } + } +#endif } @available(_clockAPI, *)