Browse Source

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
George Barnett 1 year ago
parent
commit
07123ed731
18 changed files with 461 additions and 359 deletions
  1. 3 0
      Package@swift-6.swift
  2. 1 0
      Sources/GRPCCore/Configuration/MethodConfig.swift
  3. 5 0
      Tests/GRPCCoreTests/Configuration/Inputs/method_config.hedging_policy.invalid.max_attempts.json
  4. 5 0
      Tests/GRPCCoreTests/Configuration/Inputs/method_config.hedging_policy.json
  5. 12 0
      Tests/GRPCCoreTests/Configuration/Inputs/method_config.json
  6. 1 0
      Tests/GRPCCoreTests/Configuration/Inputs/method_config.name.empty.json
  7. 4 0
      Tests/GRPCCoreTests/Configuration/Inputs/method_config.name.full.json
  8. 3 0
      Tests/GRPCCoreTests/Configuration/Inputs/method_config.name.service_only.json
  9. 7 0
      Tests/GRPCCoreTests/Configuration/Inputs/method_config.retry_policy.invalid.backoff_multiplier.json
  10. 7 0
      Tests/GRPCCoreTests/Configuration/Inputs/method_config.retry_policy.invalid.initial_backoff.json
  11. 7 0
      Tests/GRPCCoreTests/Configuration/Inputs/method_config.retry_policy.invalid.max_attempts.json
  12. 7 0
      Tests/GRPCCoreTests/Configuration/Inputs/method_config.retry_policy.invalid.max_backoff.json
  13. 7 0
      Tests/GRPCCoreTests/Configuration/Inputs/method_config.retry_policy.invalid.retryable_status_codes.json
  14. 7 0
      Tests/GRPCCoreTests/Configuration/Inputs/method_config.retry_policy.json
  15. 20 0
      Tests/GRPCCoreTests/Configuration/Inputs/method_config.with_hedging.json
  16. 22 0
      Tests/GRPCCoreTests/Configuration/Inputs/method_config.with_retries.json
  17. 332 350
      Tests/GRPCCoreTests/Configuration/MethodConfigCodingTests.swift
  18. 11 9
      Tests/GRPCCoreTests/Configuration/MethodConfigTests.swift

+ 3 - 0
Package@swift-6.swift

@@ -415,6 +415,9 @@ extension Target {
         .protobuf,
         .testing,
       ],
+      resources: [
+        .copy("Configuration/Inputs")
+      ],
       swiftSettings: [.swiftLanguageMode(.v6), .enableUpcomingFeature("ExistentialAny")]
     )
   }

+ 1 - 0
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

+ 5 - 0
Tests/GRPCCoreTests/Configuration/Inputs/method_config.hedging_policy.invalid.max_attempts.json

@@ -0,0 +1,5 @@
+{
+  "maxAttempts": 1,
+  "hedgingDelay": "1s",
+  "nonFatalStatusCodes": ["ABORTED"]
+}

+ 5 - 0
Tests/GRPCCoreTests/Configuration/Inputs/method_config.hedging_policy.json

@@ -0,0 +1,5 @@
+{
+  "maxAttempts": 3,
+  "hedgingDelay": "1s",
+  "nonFatalStatusCodes": ["ABORTED"]
+}

+ 12 - 0
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
+}

+ 1 - 0
Tests/GRPCCoreTests/Configuration/Inputs/method_config.name.empty.json

@@ -0,0 +1 @@
+{}

+ 4 - 0
Tests/GRPCCoreTests/Configuration/Inputs/method_config.name.full.json

@@ -0,0 +1,4 @@
+{
+  "service": "foo.bar",
+  "method": "baz"
+}

+ 3 - 0
Tests/GRPCCoreTests/Configuration/Inputs/method_config.name.service_only.json

@@ -0,0 +1,3 @@
+{
+  "service": "foo.bar"
+}

+ 7 - 0
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"]
+}

+ 7 - 0
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"]
+}

+ 7 - 0
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"]
+}

+ 7 - 0
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"]
+}

+ 7 - 0
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": []
+}

+ 7 - 0
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"]
+}

+ 20 - 0
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"
+    ]
+  }
+}

+ 22 - 0
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"
+    ]
+  }
+}

+ 332 - 350
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<D: Decodable>(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<Decoded: Decodable>(
+      _ 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<Decoded: Decodable>(
+      _ 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<Decoded: Decodable>(
+      _ 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<Decoded: Decodable>(
+      _ 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<T: Codable & Equatable>(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)
     }
   }
 }

+ 11 - 9
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)
   }
 }