From 07123ed731671e800ab8d641006613612e954746 Mon Sep 17 00:00:00 2001 From: George Barnett Date: Wed, 18 Sep 2024 15:04:34 +0100 Subject: [PATCH] Adopt swift-testing for MethodConfig tests (#2055) Motivation: swift-testing has a number of advantages over XCTest (parameterisation, organisation, failure messages etc.), we should start using it instead of XCTest. Modifications: - Convert the MethodConfig coding tests - Fixed a couple of bugs found by additional testing Results: Fewer bugs, better tests --- Package@swift-6.swift | 3 + .../GRPCCore/Configuration/MethodConfig.swift | 1 + ...g.hedging_policy.invalid.max_attempts.json | 5 + .../Inputs/method_config.hedging_policy.json | 5 + .../Configuration/Inputs/method_config.json | 12 + .../Inputs/method_config.name.empty.json | 1 + .../Inputs/method_config.name.full.json | 4 + .../method_config.name.service_only.json | 3 + ...try_policy.invalid.backoff_multiplier.json | 7 + ....retry_policy.invalid.initial_backoff.json | 7 + ...fig.retry_policy.invalid.max_attempts.json | 7 + ...nfig.retry_policy.invalid.max_backoff.json | 7 + ...policy.invalid.retryable_status_codes.json | 7 + .../Inputs/method_config.retry_policy.json | 7 + .../Inputs/method_config.with_hedging.json | 20 + .../Inputs/method_config.with_retries.json | 22 + .../MethodConfigCodingTests.swift | 682 +++++++++--------- .../Configuration/MethodConfigTests.swift | 20 +- 18 files changed, 461 insertions(+), 359 deletions(-) create mode 100644 Tests/GRPCCoreTests/Configuration/Inputs/method_config.hedging_policy.invalid.max_attempts.json create mode 100644 Tests/GRPCCoreTests/Configuration/Inputs/method_config.hedging_policy.json create mode 100644 Tests/GRPCCoreTests/Configuration/Inputs/method_config.json create mode 100644 Tests/GRPCCoreTests/Configuration/Inputs/method_config.name.empty.json create mode 100644 Tests/GRPCCoreTests/Configuration/Inputs/method_config.name.full.json create mode 100644 Tests/GRPCCoreTests/Configuration/Inputs/method_config.name.service_only.json create mode 100644 Tests/GRPCCoreTests/Configuration/Inputs/method_config.retry_policy.invalid.backoff_multiplier.json create mode 100644 Tests/GRPCCoreTests/Configuration/Inputs/method_config.retry_policy.invalid.initial_backoff.json create mode 100644 Tests/GRPCCoreTests/Configuration/Inputs/method_config.retry_policy.invalid.max_attempts.json create mode 100644 Tests/GRPCCoreTests/Configuration/Inputs/method_config.retry_policy.invalid.max_backoff.json create mode 100644 Tests/GRPCCoreTests/Configuration/Inputs/method_config.retry_policy.invalid.retryable_status_codes.json create mode 100644 Tests/GRPCCoreTests/Configuration/Inputs/method_config.retry_policy.json create mode 100644 Tests/GRPCCoreTests/Configuration/Inputs/method_config.with_hedging.json create mode 100644 Tests/GRPCCoreTests/Configuration/Inputs/method_config.with_retries.json diff --git a/Package@swift-6.swift b/Package@swift-6.swift index e7b1c268a..89b509d2d 100644 --- a/Package@swift-6.swift +++ b/Package@swift-6.swift @@ -415,6 +415,9 @@ extension Target { .protobuf, .testing, ], + resources: [ + .copy("Configuration/Inputs") + ], swiftSettings: [.swiftLanguageMode(.v6), .enableUpcomingFeature("ExistentialAny")] ) } diff --git a/Sources/GRPCCore/Configuration/MethodConfig.swift b/Sources/GRPCCore/Configuration/MethodConfig.swift index b02d25f0e..d99c892df 100644 --- a/Sources/GRPCCore/Configuration/MethodConfig.swift +++ b/Sources/GRPCCore/Configuration/MethodConfig.swift @@ -453,6 +453,7 @@ extension MethodConfig: Codable { public func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(self.names, forKey: .name) + try container.encodeIfPresent(self.waitForReady, forKey: .waitForReady) try container.encodeIfPresent( self.timeout.map { GoogleProtobufDuration(duration: $0) }, forKey: .timeout diff --git a/Tests/GRPCCoreTests/Configuration/Inputs/method_config.hedging_policy.invalid.max_attempts.json b/Tests/GRPCCoreTests/Configuration/Inputs/method_config.hedging_policy.invalid.max_attempts.json new file mode 100644 index 000000000..9436d324b --- /dev/null +++ b/Tests/GRPCCoreTests/Configuration/Inputs/method_config.hedging_policy.invalid.max_attempts.json @@ -0,0 +1,5 @@ +{ + "maxAttempts": 1, + "hedgingDelay": "1s", + "nonFatalStatusCodes": ["ABORTED"] +} diff --git a/Tests/GRPCCoreTests/Configuration/Inputs/method_config.hedging_policy.json b/Tests/GRPCCoreTests/Configuration/Inputs/method_config.hedging_policy.json new file mode 100644 index 000000000..8dd9ed8e9 --- /dev/null +++ b/Tests/GRPCCoreTests/Configuration/Inputs/method_config.hedging_policy.json @@ -0,0 +1,5 @@ +{ + "maxAttempts": 3, + "hedgingDelay": "1s", + "nonFatalStatusCodes": ["ABORTED"] +} diff --git a/Tests/GRPCCoreTests/Configuration/Inputs/method_config.json b/Tests/GRPCCoreTests/Configuration/Inputs/method_config.json new file mode 100644 index 000000000..71c9c2cab --- /dev/null +++ b/Tests/GRPCCoreTests/Configuration/Inputs/method_config.json @@ -0,0 +1,12 @@ +{ + "name": [ + { + "service": "echo.Echo", + "method": "Get" + } + ], + "waitForReady": true, + "timeout": "1s", + "maxRequestMessageBytes": 1024, + "maxResponseMessageBytes": 2048 +} diff --git a/Tests/GRPCCoreTests/Configuration/Inputs/method_config.name.empty.json b/Tests/GRPCCoreTests/Configuration/Inputs/method_config.name.empty.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/Tests/GRPCCoreTests/Configuration/Inputs/method_config.name.empty.json @@ -0,0 +1 @@ +{} diff --git a/Tests/GRPCCoreTests/Configuration/Inputs/method_config.name.full.json b/Tests/GRPCCoreTests/Configuration/Inputs/method_config.name.full.json new file mode 100644 index 000000000..ed21cc360 --- /dev/null +++ b/Tests/GRPCCoreTests/Configuration/Inputs/method_config.name.full.json @@ -0,0 +1,4 @@ +{ + "service": "foo.bar", + "method": "baz" +} diff --git a/Tests/GRPCCoreTests/Configuration/Inputs/method_config.name.service_only.json b/Tests/GRPCCoreTests/Configuration/Inputs/method_config.name.service_only.json new file mode 100644 index 000000000..beb50d5e3 --- /dev/null +++ b/Tests/GRPCCoreTests/Configuration/Inputs/method_config.name.service_only.json @@ -0,0 +1,3 @@ +{ + "service": "foo.bar" +} diff --git a/Tests/GRPCCoreTests/Configuration/Inputs/method_config.retry_policy.invalid.backoff_multiplier.json b/Tests/GRPCCoreTests/Configuration/Inputs/method_config.retry_policy.invalid.backoff_multiplier.json new file mode 100644 index 000000000..a43451a94 --- /dev/null +++ b/Tests/GRPCCoreTests/Configuration/Inputs/method_config.retry_policy.invalid.backoff_multiplier.json @@ -0,0 +1,7 @@ +{ + "maxAttempts": 3, + "initialBackoff": "1s", + "maxBackoff": "3s", + "backoffMultiplier": -1.6, + "retryableStatusCodes": ["ABORTED", "UNAVAILABLE"] +} diff --git a/Tests/GRPCCoreTests/Configuration/Inputs/method_config.retry_policy.invalid.initial_backoff.json b/Tests/GRPCCoreTests/Configuration/Inputs/method_config.retry_policy.invalid.initial_backoff.json new file mode 100644 index 000000000..bb9691bbb --- /dev/null +++ b/Tests/GRPCCoreTests/Configuration/Inputs/method_config.retry_policy.invalid.initial_backoff.json @@ -0,0 +1,7 @@ +{ + "maxAttempts": 3, + "initialBackoff": "0s", + "maxBackoff": "3s", + "backoffMultiplier": 1.6, + "retryableStatusCodes": ["ABORTED", "UNAVAILABLE"] +} diff --git a/Tests/GRPCCoreTests/Configuration/Inputs/method_config.retry_policy.invalid.max_attempts.json b/Tests/GRPCCoreTests/Configuration/Inputs/method_config.retry_policy.invalid.max_attempts.json new file mode 100644 index 000000000..454ba94e9 --- /dev/null +++ b/Tests/GRPCCoreTests/Configuration/Inputs/method_config.retry_policy.invalid.max_attempts.json @@ -0,0 +1,7 @@ +{ + "maxAttempts": 1, + "initialBackoff": "1s", + "maxBackoff": "3s", + "backoffMultiplier": 1.6, + "retryableStatusCodes": ["ABORTED", "UNAVAILABLE"] +} diff --git a/Tests/GRPCCoreTests/Configuration/Inputs/method_config.retry_policy.invalid.max_backoff.json b/Tests/GRPCCoreTests/Configuration/Inputs/method_config.retry_policy.invalid.max_backoff.json new file mode 100644 index 000000000..6059280be --- /dev/null +++ b/Tests/GRPCCoreTests/Configuration/Inputs/method_config.retry_policy.invalid.max_backoff.json @@ -0,0 +1,7 @@ +{ + "maxAttempts": 3, + "initialBackoff": "1s", + "maxBackoff": "0s", + "backoffMultiplier": 1.6, + "retryableStatusCodes": ["ABORTED", "UNAVAILABLE"] +} diff --git a/Tests/GRPCCoreTests/Configuration/Inputs/method_config.retry_policy.invalid.retryable_status_codes.json b/Tests/GRPCCoreTests/Configuration/Inputs/method_config.retry_policy.invalid.retryable_status_codes.json new file mode 100644 index 000000000..d437878f0 --- /dev/null +++ b/Tests/GRPCCoreTests/Configuration/Inputs/method_config.retry_policy.invalid.retryable_status_codes.json @@ -0,0 +1,7 @@ +{ + "maxAttempts": 3, + "initialBackoff": "1s", + "maxBackoff": "3s", + "backoffMultiplier": 1.6, + "retryableStatusCodes": [] +} diff --git a/Tests/GRPCCoreTests/Configuration/Inputs/method_config.retry_policy.json b/Tests/GRPCCoreTests/Configuration/Inputs/method_config.retry_policy.json new file mode 100644 index 000000000..ef8744c2e --- /dev/null +++ b/Tests/GRPCCoreTests/Configuration/Inputs/method_config.retry_policy.json @@ -0,0 +1,7 @@ +{ + "maxAttempts": 3, + "initialBackoff": "1s", + "maxBackoff": "3s", + "backoffMultiplier": 1.6, + "retryableStatusCodes": ["ABORTED", "UNAVAILABLE"] +} diff --git a/Tests/GRPCCoreTests/Configuration/Inputs/method_config.with_hedging.json b/Tests/GRPCCoreTests/Configuration/Inputs/method_config.with_hedging.json new file mode 100644 index 000000000..1d9ecc6b7 --- /dev/null +++ b/Tests/GRPCCoreTests/Configuration/Inputs/method_config.with_hedging.json @@ -0,0 +1,20 @@ +{ + "name": [ + { + "service": "echo.Echo", + "method": "Get" + } + ], + "waitForReady": true, + "timeout": "1s", + "maxRequestMessageBytes": 1024, + "maxResponseMessageBytes": 2048, + "hedgingPolicy": { + "maxAttempts": 3, + "hedgingDelay": "42s", + "nonFatalStatusCodes": [ + "ABORTED", + "UNIMPLEMENTED" + ] + } +} diff --git a/Tests/GRPCCoreTests/Configuration/Inputs/method_config.with_retries.json b/Tests/GRPCCoreTests/Configuration/Inputs/method_config.with_retries.json new file mode 100644 index 000000000..41556e185 --- /dev/null +++ b/Tests/GRPCCoreTests/Configuration/Inputs/method_config.with_retries.json @@ -0,0 +1,22 @@ +{ + "name": [ + { + "service": "echo.Echo", + "method": "Get" + } + ], + "waitForReady": true, + "timeout": "1s", + "maxRequestMessageBytes": 1024, + "maxResponseMessageBytes": 2048, + "retryPolicy": { + "maxAttempts": 3, + "initialBackoff": "1s", + "maxBackoff": "3s", + "backoffMultiplier": 1.6, + "retryableStatusCodes": [ + "ABORTED", + "UNIMPLEMENTED" + ] + } +} diff --git a/Tests/GRPCCoreTests/Configuration/MethodConfigCodingTests.swift b/Tests/GRPCCoreTests/Configuration/MethodConfigCodingTests.swift index a0d309c25..c65fe1c54 100644 --- a/Tests/GRPCCoreTests/Configuration/MethodConfigCodingTests.swift +++ b/Tests/GRPCCoreTests/Configuration/MethodConfigCodingTests.swift @@ -16,415 +16,397 @@ import Foundation import SwiftProtobuf -import XCTest +import Testing @testable import GRPCCore -@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) -internal final class MethodConfigCodingTests: XCTestCase { - private let encoder = JSONEncoder() - private let decoder = JSONDecoder() - - private func testDecodeThrowsRuntimeError(json: String, as: D.Type) throws { - XCTAssertThrowsError( - ofType: RuntimeError.self, - try self.decoder.decode(D.self, from: Data(json.utf8)) - ) { error in - XCTAssertEqual(error.code, .invalidArgument) +@Suite("MethodConfig coding tests") +struct MethodConfigCodingTests { + @Suite("Encoding") + struct Encoding { + private func encodeToJSON(_ value: some Encodable) throws -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = .sortedKeys + let encoded = try encoder.encode(value) + let json = String(decoding: encoded, as: UTF8.self) + return json } - } - - func testDecodeMethodConfigName() throws { - let inputs: [(String, MethodConfig.Name)] = [ - (#"{"service": "foo.bar", "method": "baz"}"#, .init(service: "foo.bar", method: "baz")), - (#"{"service": "foo.bar"}"#, .init(service: "foo.bar", method: "")), - (#"{}"#, .init(service: "", method: "")), - ] - for (json, expected) in inputs { - let decoded = try self.decoder.decode(MethodConfig.Name.self, from: Data(json.utf8)) - XCTAssertEqual(decoded, expected) + @Test( + "Name", + arguments: [ + ( + MethodConfig.Name(service: "foo.bar", method: "baz"), + #"{"method":"baz","service":"foo.bar"}"# + ), + (MethodConfig.Name(service: "foo.bar", method: ""), #"{"method":"","service":"foo.bar"}"#), + (MethodConfig.Name(service: "", method: ""), #"{"method":"","service":""}"#), + ] as [(MethodConfig.Name, String)] + ) + func methodConfigName(name: MethodConfig.Name, expected: String) throws { + let json = try self.encodeToJSON(name) + #expect(json == expected) } - } - func testEncodeDecodeMethodConfigName() throws { - let inputs: [MethodConfig.Name] = [ - MethodConfig.Name(service: "foo.bar", method: "baz"), - MethodConfig.Name(service: "foo.bar", method: ""), - MethodConfig.Name(service: "", method: ""), - ] - - // We can't do encode-only tests as the output is non-deterministic (the ordering of - // service/method in the JSON object) - for name in inputs { - let encoded = try self.encoder.encode(name) - let decoded = try self.decoder.decode(MethodConfig.Name.self, from: encoded) - XCTAssertEqual(decoded, name) + @Test( + "GoogleProtobufDuration", + arguments: [ + (.seconds(1), #""1.0s""#), + (.zero, #""0.0s""#), + (.milliseconds(100_123), #""100.123s""#), + ] as [(Duration, String)] + ) + func protobufDuration(duration: Duration, expected: String) throws { + let json = try self.encodeToJSON(GoogleProtobufDuration(duration: duration)) + #expect(json == expected) } - } - func testDecodeProtobufDuration() throws { - let inputs: [(String, Duration)] = [ - ("1.0s", .seconds(1)), - ("1s", .seconds(1)), - ("1.000000s", .seconds(1)), - ("0s", .zero), - ("100.123s", .milliseconds(100_123)), - ] + @Test( + "GoogleRPCCode", + arguments: [ + (.ok, #""OK""#), + (.cancelled, #""CANCELLED""#), + (.unknown, #""UNKNOWN""#), + (.invalidArgument, #""INVALID_ARGUMENT""#), + (.deadlineExceeded, #""DEADLINE_EXCEEDED""#), + (.notFound, #""NOT_FOUND""#), + (.alreadyExists, #""ALREADY_EXISTS""#), + (.permissionDenied, #""PERMISSION_DENIED""#), + (.resourceExhausted, #""RESOURCE_EXHAUSTED""#), + (.failedPrecondition, #""FAILED_PRECONDITION""#), + (.aborted, #""ABORTED""#), + (.outOfRange, #""OUT_OF_RANGE""#), + (.unimplemented, #""UNIMPLEMENTED""#), + (.internalError, #""INTERNAL""#), + (.unavailable, #""UNAVAILABLE""#), + (.dataLoss, #""DATA_LOSS""#), + (.unauthenticated, #""UNAUTHENTICATED""#), + ] as [(Status.Code, String)] + ) + func rpcCode(code: Status.Code, expected: String) throws { + let json = try self.encodeToJSON(GoogleRPCCode(code: code)) + #expect(json == expected) + } - for (input, expected) in inputs { - let json = "\"\(input)\"" - let protoDuration = try self.decoder.decode( - GoogleProtobufDuration.self, - from: Data(json.utf8) + @Test("RetryPolicy") + func retryPolicy() throws { + let policy = RetryPolicy( + maximumAttempts: 3, + initialBackoff: .seconds(1), + maximumBackoff: .seconds(3), + backoffMultiplier: 1.6, + retryableStatusCodes: [.aborted] ) - let components = protoDuration.duration.components - - // Conversion is lossy as we go from floating point seconds to integer seconds and - // attoseconds. Allow for millisecond precision. - let divisor: Int64 = 1_000_000_000_000_000 - XCTAssertEqual(components.seconds, expected.components.seconds) - XCTAssertEqual(components.attoseconds / divisor, expected.components.attoseconds / divisor) + let json = try self.encodeToJSON(policy) + let expected = + #"{"backoffMultiplier":1.6,"initialBackoff":"1.0s","maxAttempts":3,"maxBackoff":"3.0s","retryableStatusCodes":["ABORTED"]}"# + #expect(json == expected) } - } - func testEncodeProtobufDuration() throws { - let inputs: [(Duration, String)] = [ - (.seconds(1), "\"1.0s\""), - (.zero, "\"0.0s\""), - (.milliseconds(100_123), "\"100.123s\""), - ] + @Test("HedgingPolicy") + func hedgingPolicy() throws { + let policy = HedgingPolicy( + maximumAttempts: 3, + hedgingDelay: .seconds(1), + nonFatalStatusCodes: [.aborted] + ) - for (input, expected) in inputs { - let duration = GoogleProtobufDuration(duration: input) - let encoded = try self.encoder.encode(duration) - let json = String(decoding: encoded, as: UTF8.self) - XCTAssertEqual(json, expected) + let json = try self.encodeToJSON(policy) + let expected = #"{"hedgingDelay":"1.0s","maxAttempts":3,"nonFatalStatusCodes":["ABORTED"]}"# + #expect(json == expected) } } - func testDecodeInvalidProtobufDuration() throws { - for timestamp in ["1", "1ss", "1S", "1.0S"] { - let json = "\"\(timestamp)\"" - try self.testDecodeThrowsRuntimeError(json: json, as: GoogleProtobufDuration.self) - } - } + @Suite("Decoding") + struct Decoding { + private func decodeFromFile( + _ name: String, + as: Decoded.Type + ) throws -> Decoded { + let input = Bundle.module.url( + forResource: name, + withExtension: "json", + subdirectory: "Inputs" + ) - func testDecodeRPCCodeFromCaseName() throws { - let inputs: [(String, Status.Code)] = [ - ("OK", .ok), - ("CANCELLED", .cancelled), - ("UNKNOWN", .unknown), - ("INVALID_ARGUMENT", .invalidArgument), - ("DEADLINE_EXCEEDED", .deadlineExceeded), - ("NOT_FOUND", .notFound), - ("ALREADY_EXISTS", .alreadyExists), - ("PERMISSION_DENIED", .permissionDenied), - ("RESOURCE_EXHAUSTED", .resourceExhausted), - ("FAILED_PRECONDITION", .failedPrecondition), - ("ABORTED", .aborted), - ("OUT_OF_RANGE", .outOfRange), - ("UNIMPLEMENTED", .unimplemented), - ("INTERNAL", .internalError), - ("UNAVAILABLE", .unavailable), - ("DATA_LOSS", .dataLoss), - ("UNAUTHENTICATED", .unauthenticated), - ] + let url = try #require(input) + let data = try Data(contentsOf: url) - for (name, expected) in inputs { - let json = "\"\(name)\"" - let code = try self.decoder.decode(GoogleRPCCode.self, from: Data(json.utf8)) - XCTAssertEqual(code.code, expected) + let decoder = JSONDecoder() + return try decoder.decode(Decoded.self, from: data) } - } - func testDecodeRPCCodeFromRawValue() throws { - let inputs: [(Int, Status.Code)] = [ - (0, .ok), - (1, .cancelled), - (2, .unknown), - (3, .invalidArgument), - (4, .deadlineExceeded), - (5, .notFound), - (6, .alreadyExists), - (7, .permissionDenied), - (8, .resourceExhausted), - (9, .failedPrecondition), - (10, .aborted), - (11, .outOfRange), - (12, .unimplemented), - (13, .internalError), - (14, .unavailable), - (15, .dataLoss), - (16, .unauthenticated), - ] - - for (rawValue, expected) in inputs { - let json = "\(rawValue)" - let code = try self.decoder.decode(GoogleRPCCode.self, from: Data(json.utf8)) - XCTAssertEqual(code.code, expected) + private func decodeFromJSONString( + _ json: String, + as: Decoded.Type + ) throws -> Decoded { + let data = Data(json.utf8) + let decoder = JSONDecoder() + return try decoder.decode(Decoded.self, from: data) } - } - func testEncodeDecodeRPCCode() throws { - let codes: [Status.Code] = [ - .ok, - .cancelled, - .unknown, - .invalidArgument, - .deadlineExceeded, - .notFound, - .alreadyExists, - .permissionDenied, - .resourceExhausted, - .failedPrecondition, - .aborted, - .outOfRange, - .unimplemented, - .internalError, - .unavailable, - .dataLoss, - .unauthenticated, + private static let codeNames: [String] = [ + "OK", + "CANCELLED", + "UNKNOWN", + "INVALID_ARGUMENT", + "DEADLINE_EXCEEDED", + "NOT_FOUND", + "ALREADY_EXISTS", + "PERMISSION_DENIED", + "RESOURCE_EXHAUSTED", + "FAILED_PRECONDITION", + "ABORTED", + "OUT_OF_RANGE", + "UNIMPLEMENTED", + "INTERNAL", + "UNAVAILABLE", + "DATA_LOSS", + "UNAUTHENTICATED", ] - for code in codes { - let encoded = try self.encoder.encode(GoogleRPCCode(code: code)) - let decoded = try self.decoder.decode(GoogleRPCCode.self, from: encoded) - XCTAssertEqual(decoded.code, code) + @Test( + "Name", + arguments: [ + ("method_config.name.full", MethodConfig.Name(service: "foo.bar", method: "baz")), + ("method_config.name.service_only", MethodConfig.Name(service: "foo.bar", method: "")), + ("method_config.name.empty", MethodConfig.Name(service: "", method: "")), + ] as [(String, MethodConfig.Name)] + ) + func name(_ fileName: String, expected: MethodConfig.Name) throws { + let decoded = try self.decodeFromFile(fileName, as: MethodConfig.Name.self) + #expect(decoded == expected) } - } - func testDecodeRetryPolicy() throws { - let json = """ - { - "maxAttempts": 3, - "initialBackoff": "1s", - "maxBackoff": "3s", - "backoffMultiplier": 1.6, - "retryableStatusCodes": ["ABORTED", "UNAVAILABLE"] - } - """ - - let expected = RetryPolicy( - maximumAttempts: 3, - initialBackoff: .seconds(1), - maximumBackoff: .seconds(3), - backoffMultiplier: 1.6, - retryableStatusCodes: [.aborted, .unavailable] + @Test( + "GoogleProtobufDuration", + arguments: [ + ("1.0s", .seconds(1)), + ("1s", .seconds(1)), + ("1.000000s", .seconds(1)), + ("0s", .zero), + ("100.123s", .milliseconds(100_123)), + ] as [(String, Duration)] ) + func googleProtobufDuration(duration: String, expectedDuration: Duration) throws { + let json = "\"\(duration)\"" + let decoded = try self.decodeFromJSONString(json, as: GoogleProtobufDuration.self) - let decoded = try self.decoder.decode(RetryPolicy.self, from: Data(json.utf8)) - XCTAssertEqual(decoded, expected) - } + // Conversion is lossy as we go from floating point seconds to integer seconds and + // attoseconds. Allow for millisecond precision. + let divisor: Int64 = 1_000_000_000_000_000 - func testEncodeDecodeRetryPolicy() throws { - let policy = RetryPolicy( - maximumAttempts: 3, - initialBackoff: .seconds(1), - maximumBackoff: .seconds(3), - backoffMultiplier: 1.6, - retryableStatusCodes: [.aborted] - ) + let duration = decoded.duration.components + let expected = expectedDuration.components - let encoded = try self.encoder.encode(policy) - let decoded = try self.decoder.decode(RetryPolicy.self, from: encoded) - XCTAssertEqual(decoded, policy) - } + #expect(duration.seconds == expected.seconds) + #expect(duration.attoseconds / divisor == expected.attoseconds / divisor) + } - func testDecodeRetryPolicyWithInvalidRetryMaxAttempts() throws { - let cases = ["-1", "0", "1"] - for maxAttempts in cases { - let json = """ - { - "maxAttempts": \(maxAttempts), - "initialBackoff": "1s", - "maxBackoff": "3s", - "backoffMultiplier": 1.6, - "retryableStatusCodes": ["ABORTED"] - } - """ - - try self.testDecodeThrowsRuntimeError(json: json, as: RetryPolicy.self) + @Test("Invalid GoogleProtobufDuration", arguments: ["1", "1ss", "1S", "1.0S"]) + func googleProtobufDuration(invalidDuration: String) throws { + let json = "\"\(invalidDuration)\"" + #expect { + try self.decodeFromJSONString(json, as: GoogleProtobufDuration.self) + } throws: { error in + guard let error = error as? RuntimeError else { return false } + return error.code == .invalidArgument + } } - } - func testDecodeRetryPolicyWithInvalidInitialBackoff() throws { - let cases = ["0s", "-1s"] - for backoff in cases { - let json = """ - { - "maxAttempts": 3, - "initialBackoff": "\(backoff)", - "maxBackoff": "3s", - "backoffMultiplier": 1.6, - "retryableStatusCodes": ["ABORTED"] - } - """ - try self.testDecodeThrowsRuntimeError(json: json, as: RetryPolicy.self) + @Test("GoogleRPCCode from case name", arguments: zip(Self.codeNames, Status.Code.all)) + func rpcCode(name: String, expected: Status.Code) throws { + let json = "\"\(name)\"" + let decoded = try self.decodeFromJSONString(json, as: GoogleRPCCode.self) + #expect(decoded.code == expected) } - } - func testDecodeRetryPolicyWithInvalidMaxBackoff() throws { - let cases = ["0s", "-1s"] - for backoff in cases { - let json = """ - { - "maxAttempts": 3, - "initialBackoff": "1s", - "maxBackoff": "\(backoff)", - "backoffMultiplier": 1.6, - "retryableStatusCodes": ["ABORTED"] - } - """ - try self.testDecodeThrowsRuntimeError(json: json, as: RetryPolicy.self) + @Test("GoogleRPCCode from rawValue", arguments: zip(0 ... 16, Status.Code.all)) + func rpcCode(rawValue: Int, expected: Status.Code) throws { + let json = "\(rawValue)" + let decoded = try self.decodeFromJSONString(json, as: GoogleRPCCode.self) + #expect(decoded.code == expected) } - } - func testDecodeRetryPolicyWithInvalidBackoffMultiplier() throws { - let cases = ["0", "-1.5"] - for multiplier in cases { - let json = """ - { - "maxAttempts": 3, - "initialBackoff": "1s", - "maxBackoff": "3s", - "backoffMultiplier": \(multiplier), - "retryableStatusCodes": ["ABORTED"] - } - """ - try self.testDecodeThrowsRuntimeError(json: json, as: RetryPolicy.self) + @Test("RetryPolicy") + func retryPolicy() throws { + let decoded = try self.decodeFromFile("method_config.retry_policy", as: RetryPolicy.self) + let expected = RetryPolicy( + maximumAttempts: 3, + initialBackoff: .seconds(1), + maximumBackoff: .seconds(3), + backoffMultiplier: 1.6, + retryableStatusCodes: [.aborted, .unavailable] + ) + #expect(decoded == expected) } - } - func testDecodeRetryPolicyWithEmptyRetryableStatusCodes() throws { - let json = """ - { - "maxAttempts": 3, - "initialBackoff": "1s", - "maxBackoff": "3s", - "backoffMultiplier": 1, - "retryableStatusCodes": [] + @Test( + "RetryPolicy with invalid values", + arguments: [ + "method_config.retry_policy.invalid.backoff_multiplier", + "method_config.retry_policy.invalid.initial_backoff", + "method_config.retry_policy.invalid.max_backoff", + "method_config.retry_policy.invalid.max_attempts", + "method_config.retry_policy.invalid.retryable_status_codes", + ] + ) + func invalidRetryPolicy(fileName: String) throws { + #expect(throws: RuntimeError.self) { + try self.decodeFromFile(fileName, as: RetryPolicy.self) } - """ - try self.testDecodeThrowsRuntimeError(json: json, as: RetryPolicy.self) - } + } - func testDecodeHedgingPolicy() throws { - let json = """ - { - "maxAttempts": 3, - "hedgingDelay": "1s", - "nonFatalStatusCodes": ["ABORTED"] - } - """ + @Test("HedgingPolicy") + func hedgingPolicy() throws { + let decoded = try self.decodeFromFile("method_config.hedging_policy", as: HedgingPolicy.self) + let expected = HedgingPolicy( + maximumAttempts: 3, + hedgingDelay: .seconds(1), + nonFatalStatusCodes: [.aborted] + ) + #expect(decoded == expected) + } - let expected = HedgingPolicy( - maximumAttempts: 3, - hedgingDelay: .seconds(1), - nonFatalStatusCodes: [.aborted] + @Test( + "HedgingPolicy with invalid values", + arguments: [ + "method_config.hedging_policy.invalid.max_attempts" + ] ) + func invalidHedgingPolicy(fileName: String) throws { + #expect(throws: RuntimeError.self) { + try self.decodeFromFile(fileName, as: HedgingPolicy.self) + } + } - let decoded = try self.decoder.decode(HedgingPolicy.self, from: Data(json.utf8)) - XCTAssertEqual(decoded, expected) - } - - func testEncodeDecodeHedgingPolicy() throws { - let policy = HedgingPolicy( - maximumAttempts: 3, - hedgingDelay: .seconds(1), - nonFatalStatusCodes: [.aborted] - ) + @Test("MethodConfig") + func methodConfig() throws { + let expected = MethodConfig( + names: [ + MethodConfig.Name( + service: "echo.Echo", + method: "Get" + ) + ], + waitForReady: true, + timeout: .seconds(1), + maxRequestMessageBytes: 1024, + maxResponseMessageBytes: 2048 + ) - let encoded = try self.encoder.encode(policy) - let decoded = try self.decoder.decode(HedgingPolicy.self, from: encoded) - XCTAssertEqual(decoded, policy) - } + let decoded = try self.decodeFromFile("method_config", as: MethodConfig.self) + #expect(decoded == expected) + } - func testMethodConfigDecodeFromJSON() throws { - let config = Grpc_ServiceConfig_MethodConfig.with { - $0.name = [ - .with { - $0.service = "echo.Echo" - $0.method = "Get" - } - ] + @Test("MethodConfig with hedging") + func methodConfigWithHedging() throws { + let expected = MethodConfig( + names: [ + MethodConfig.Name( + service: "echo.Echo", + method: "Get" + ) + ], + waitForReady: true, + timeout: .seconds(1), + maxRequestMessageBytes: 1024, + maxResponseMessageBytes: 2048, + executionPolicy: .hedge( + HedgingPolicy( + maximumAttempts: 3, + hedgingDelay: .seconds(42), + nonFatalStatusCodes: [.aborted, .unimplemented] + ) + ) + ) - $0.waitForReady = true + let decoded = try self.decodeFromFile("method_config.with_hedging", as: MethodConfig.self) + #expect(decoded == expected) + } - $0.timeout = .with { - $0.seconds = 1 - $0.nanos = 0 - } + @Test("MethodConfig with retries") + func methodConfigWithRetries() throws { + let expected = MethodConfig( + names: [ + MethodConfig.Name( + service: "echo.Echo", + method: "Get" + ) + ], + waitForReady: true, + timeout: .seconds(1), + maxRequestMessageBytes: 1024, + maxResponseMessageBytes: 2048, + executionPolicy: .retry( + RetryPolicy( + maximumAttempts: 3, + initialBackoff: .seconds(1), + maximumBackoff: .seconds(3), + backoffMultiplier: 1.6, + retryableStatusCodes: [.aborted, .unimplemented] + ) + ) + ) - $0.maxRequestMessageBytes = 1024 - $0.maxResponseMessageBytes = 2048 + let decoded = try self.decodeFromFile("method_config.with_retries", as: MethodConfig.self) + #expect(decoded == expected) } + } - // Test the 'regular' config. - do { - let jsonConfig = try config.jsonUTF8Data() - let decoded = try self.decoder.decode(MethodConfig.self, from: jsonConfig) - XCTAssertEqual(decoded.names, [MethodConfig.Name(service: "echo.Echo", method: "Get")]) - XCTAssertEqual(decoded.waitForReady, true) - XCTAssertEqual(decoded.timeout, Duration(secondsComponent: 1, attosecondsComponent: 0)) - XCTAssertEqual(decoded.maxRequestMessageBytes, 1024) - XCTAssertEqual(decoded.maxResponseMessageBytes, 2048) - XCTAssertNil(decoded.executionPolicy) - } + @Suite("Round-trip tests") + struct RoundTrip { + private func decodeFromFile( + _ name: String, + as: Decoded.Type + ) throws -> Decoded { + let input = Bundle.module.url( + forResource: name, + withExtension: "json", + subdirectory: "Inputs" + ) - // Test the hedging policy. - do { - var config = config - config.hedgingPolicy = .with { - $0.maxAttempts = 3 - $0.hedgingDelay = .with { $0.seconds = 42 } - $0.nonFatalStatusCodes = [ - .aborted, - .unimplemented, - ] - } + let url = try #require(input) + let data = try Data(contentsOf: url) - let jsonConfig = try config.jsonUTF8Data() - let decoded = try self.decoder.decode(MethodConfig.self, from: jsonConfig) + let decoder = JSONDecoder() + return try decoder.decode(Decoded.self, from: data) + } - switch decoded.executionPolicy?.wrapped { - case let .some(.hedge(policy)): - XCTAssertEqual(policy.maximumAttempts, 3) - XCTAssertEqual(policy.hedgingDelay, .seconds(42)) - XCTAssertEqual(policy.nonFatalStatusCodes, [.aborted, .unimplemented]) - default: - XCTFail("Expected hedging policy") - } + private func decodeFromJSONString( + _ json: String, + as: Decoded.Type + ) throws -> Decoded { + let data = Data(json.utf8) + let decoder = JSONDecoder() + return try decoder.decode(Decoded.self, from: data) } - // Test the retry policy. - do { - var config = config - config.retryPolicy = .with { - $0.maxAttempts = 3 - $0.initialBackoff = .with { $0.seconds = 1 } - $0.maxBackoff = .with { $0.seconds = 3 } - $0.backoffMultiplier = 1.6 - $0.retryableStatusCodes = [ - .aborted, - .unimplemented, - ] - } + private func encodeToJSON(_ value: some Encodable) throws -> String { + let encoder = JSONEncoder() + let encoded = try encoder.encode(value) + let json = String(decoding: encoded, as: UTF8.self) + return json + } - let jsonConfig = try config.jsonUTF8Data() - let decoded = try self.decoder.decode(MethodConfig.self, from: jsonConfig) - - switch decoded.executionPolicy?.wrapped { - case let .some(.retry(policy)): - XCTAssertEqual(policy.maximumAttempts, 3) - XCTAssertEqual(policy.initialBackoff, .seconds(1)) - XCTAssertEqual(policy.maximumBackoff, .seconds(3)) - XCTAssertEqual(policy.backoffMultiplier, 1.6) - XCTAssertEqual(policy.retryableStatusCodes, [.aborted, .unimplemented]) - default: - XCTFail("Expected hedging policy") - } + private func roundTrip(type: T.Type = T.self, fileName: String) throws { + let decoded = try self.decodeFromFile(fileName, as: T.self) + let encoded = try self.encodeToJSON(decoded) + let decodedAgain = try self.decodeFromJSONString(encoded, as: T.self) + #expect(decoded == decodedAgain) + } + + @Test( + "MethodConfig", + arguments: [ + "method_config", + "method_config.with_retries", + "method_config.with_hedging", + ] + ) + func roundTripCodingAndDecoding(fileName: String) throws { + try self.roundTrip(type: MethodConfig.self, fileName: fileName) } } } diff --git a/Tests/GRPCCoreTests/Configuration/MethodConfigTests.swift b/Tests/GRPCCoreTests/Configuration/MethodConfigTests.swift index 0b3406efb..eca712cf5 100644 --- a/Tests/GRPCCoreTests/Configuration/MethodConfigTests.swift +++ b/Tests/GRPCCoreTests/Configuration/MethodConfigTests.swift @@ -13,12 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import GRPCCore -import XCTest +import Testing -@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) -final class MethodConfigTests: XCTestCase { - func testRetryPolicyClampsMaxAttempts() { +struct MethodConfigTests { + @Test("RetryPolicy clamps max attempts") + func retryPolicyClampsMaxAttempts() { var policy = RetryPolicy( maximumAttempts: 10, initialBackoff: .seconds(1), @@ -28,13 +29,14 @@ final class MethodConfigTests: XCTestCase { ) // Should be clamped on init - XCTAssertEqual(policy.maximumAttempts, 5) + #expect(policy.maximumAttempts == 5) // and when modifying policy.maximumAttempts = 10 - XCTAssertEqual(policy.maximumAttempts, 5) + #expect(policy.maximumAttempts == 5) } - func testHedgingPolicyClampsMaxAttempts() { + @Test("HedgingPolicy clamps max attempts") + func hedgingPolicyClampsMaxAttempts() { var policy = HedgingPolicy( maximumAttempts: 10, hedgingDelay: .seconds(1), @@ -42,9 +44,9 @@ final class MethodConfigTests: XCTestCase { ) // Should be clamped on init - XCTAssertEqual(policy.maximumAttempts, 5) + #expect(policy.maximumAttempts == 5) // and when modifying policy.maximumAttempts = 10 - XCTAssertEqual(policy.maximumAttempts, 5) + #expect(policy.maximumAttempts == 5) } }