From d0d81318300525ac1b4e5f3fc07a3f0984a02af3 Mon Sep 17 00:00:00 2001 From: Suzy Ratcliff Date: Mon, 20 Jul 2026 11:09:35 -0700 Subject: [PATCH] Combine a parameterized test case's argument IDs into a single hashed ID (#1799) ## Combine a parameterized test case's argument IDs into a single hashed ID ### Motivation: `Test.Case.ID.argumentIDs` previously stored one `Argument.ID` per argument, in order. This meant the ID grew with the number of parameters. I want to simplify this to a single combined ID that identifies the whole combination of arguments. rdar://182725918 ### 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. --------- Co-authored-by: Suzy Silver --- .../CustomTestArgumentEncodable.swift | 19 ++++++++ .../Parameterization/Test.Case.ID.swift | 12 +++--- Tests/TestingTests/Test.CaseTests.swift | 43 +++++++++++++++++++ 3 files changed, 69 insertions(+), 5 deletions(-) diff --git a/Sources/Testing/Parameterization/CustomTestArgumentEncodable.swift b/Sources/Testing/Parameterization/CustomTestArgumentEncodable.swift index 0a96775b1..f1f3f4d2a 100644 --- a/Sources/Testing/Parameterization/CustomTestArgumentEncodable.swift +++ b/Sources/Testing/Parameterization/CustomTestArgumentEncodable.swift @@ -99,6 +99,25 @@ extension Test.Case.Argument.ID { } } +extension Test.Case.Argument.ID { + /// Initialize an ID from the IDs of a test case's arguments. + /// + /// - Parameters: + /// - argumentIDs: The argument IDs to combine, in order. + /// + /// The argument IDs are concatenated and hashed to form a single ID. A stable + /// argument's ID is itself a fixed-size hash, so the concatenation is + /// unambiguous. A single argument's ID already identifies the case, so it is + /// used directly rather than combined. + init(combining argumentIDs: some Collection) { + if let argumentID = argumentIDs.first, argumentIDs.count == 1 { + self = argumentID + } else { + self.init(bytes: SHA256.hash(argumentIDs.flatMap(\.bytes))) + } + } +} + #if !SWT_NO_CODABLE extension Test.Case.Argument.ID { /// Encode the specified test argument value and store its encoded diff --git a/Sources/Testing/Parameterization/Test.Case.ID.swift b/Sources/Testing/Parameterization/Test.Case.ID.swift index 172699c41..2c63ffe8e 100644 --- a/Sources/Testing/Parameterization/Test.Case.ID.swift +++ b/Sources/Testing/Parameterization/Test.Case.ID.swift @@ -16,11 +16,12 @@ extension Test.Case { /// different ``Test`` instances. @_spi(ForToolsIntegrationOnly) 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 IDs of the arguments of this instance's associated ``Test/Case``. /// - /// The value of this property is `nil` for the ID of the single test case - /// associated with a non-parameterized test function. + /// For a parameterized test case, this array contains a single element: an + /// ``Test/Case/Argument/ID-swift.struct`` that combines the IDs of every + /// argument. 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]? /// A number used to distinguish this test case from others associated with @@ -49,7 +50,8 @@ extension Test.Case { @_spi(ForToolsIntegrationOnly) public var id: ID { - ID(argumentIDs: arguments.map { $0.map(\.id) }, discriminator: discriminator, isStable: isStable) + let argumentIDs = arguments.map { [Argument.ID(combining: $0.map(\.id))] } + return ID(argumentIDs: argumentIDs, discriminator: discriminator, isStable: isStable) } } diff --git a/Tests/TestingTests/Test.CaseTests.swift b/Tests/TestingTests/Test.CaseTests.swift index 73a1e73ba..13ae5bc95 100644 --- a/Tests/TestingTests/Test.CaseTests.swift +++ b/Tests/TestingTests/Test.CaseTests.swift @@ -92,6 +92,49 @@ struct Test_CaseTests { #expect(testCaseID.discriminator == 0) } } + + @Suite("Combined argument ID Tests") + struct CombinedArgumentIDTests { + private func makeID(_ values: [any Sendable]) -> Test.Case.ID { + let parameters = values.indices.map { + Test.Parameter(index: $0, firstName: "p\($0)", type: Int.self) + } + return Test.Case(values: values, parameters: parameters, body: {}).id + } + + @Test("Multiple arguments are folded into a single combined ID") + func multipleArgumentsFoldIntoOneID() { + let id = makeID([1, 2, 3]) + #expect(id.argumentIDs?.count == 1) + } + + @Test("Equal arguments produce equal combined IDs") + func equalArgumentsAreDeterministic() { + #expect(makeID([1, 2]).argumentIDs == makeID([1, 2]).argumentIDs) + } + + @Test("Reordered arguments produce different combined IDs") + func combinedIDIsOrderSensitive() { + #expect(makeID([1, 2]).argumentIDs != makeID([2, 1]).argumentIDs) + } + + @Test("A single argument's ID is used directly without combining") + func singleArgumentIDIsUsedDirectly() throws { + let parameter = Test.Parameter(index: 0, firstName: "x", type: Int.self) + let argumentID = try #require(try Test.Case.Argument.ID(identifying: 1, parameter: parameter)) + #expect(makeID([1]).argumentIDs == [argumentID]) + } + + @Test("Combining is stable when all arguments are stable") + func combinedIDIsStableForStableArguments() { + #expect(makeID([1, 2]).isStable) + } + + @Test("Combining preserves the nil ID for non-parameterized cases") + func nonParameterizedCaseHasNilArgumentIDs() { + #expect(Test.Case(body: {}).id.argumentIDs == nil) + } + } #endif }