返回

Cuckoo测试泛型函数Stub难题及解决方案

IOS

使用 Cuckoo 测试泛型函数时无法进行 Stub

使用 Cuckoo 进行单元测试时,对泛型函数进行 Stub 有时会遇到问题。特别是当函数签名包含泛型类型参数时,编译器可能无法自动推断类型,导致测试无法正确执行。本文讨论这类问题,并提供切实可行的解决方案。

问题分析

考虑一个使用泛型方法 executeNetworkManager 协议。

protocol NetworkManager {
  func execute<R: NetworkRequest, T: Decodable>(
    with request: R,
    parameters: [NetworkParameter]?
  ) async throws -> T
}

如测试示例所示,我们期望使用Cuckoo 来对该函数进行打桩(stubbing)。在 stub(mockNetworkManager) 部分尝试使用 when(stub.execute(with: any(PARRequest.self), parameters: any())).thenThrow(expectedError) 对函数进行stub 时,报错信息提示 “Generic parameter ‘T’ could not be inferred”, 表明 Cuckoo 无法推断出 execute 方法的 T 类型。这是由于 Cuckoo 的类型推断机制对泛型方法的推断较为有限。在生成测试桩代码的时候, 需要明确指定泛型类型。

解决方案

为了解决这个问题,需要显式地提供泛型类型参数给 Cuckoo。以下是两种有效的方案。

方案一:指定明确的返回值类型

原理:

此方案直接使用函数返回的特定类型(在本例中是AuthorizationResponseDTO)明确指定了泛型方法,以便 Cuckoo 正确进行类型推断。在实际开发过程中, 几乎每一个API请求都有自己特定的DTO返回值, 在绝大多数情况下我们可以提前预知并且定义好的返回模型. 所以这是一个比较直接并且推荐使用的方案。

步骤:

  1. .execute 调用时要期望返回的类型 (比如 AuthorizationResponseDTO.self ) 一并进行指定,以明确T的类型。

代码示例:

func testExecutePARRefreshURL_ThrowsError() async {
    let testURL = "https://test.com"
    let expectedError = NSError(domain: "TestErrorDomain", code: 1, userInfo: nil)
    let request = PARRequest(baseURL: testURL)

    stub(mockNetworkManager) { stub in
      when(stub.execute(with: any(PARRequest.self), parameters: any()) as AuthorizationResponseDTO)
        .thenThrow(expectedError)
    }

    do {
      _ = try await sut.executePARRefreshURL(with: testURL)
      XCTFail("Expected error but got success")
    } catch let error as NSError {
      XCTAssertEqual(error.domain, expectedError.domain)
    }

    verify(mockNetworkManager).execute(with: request, parameters: nil)
  }

分析:
上述代码中, as AuthorizationResponseDTO 部分明确了泛型类型参数 T 的具体类型为 AuthorizationResponseDTO, 从而解决了Cuckoo无法进行类型推断的问题。Cuckoo 将会使用这个信息生成对应的 Stub 代码,测试逻辑就可以顺利进行。

方案二:自定义一个辅助类型和扩展方法

原理:

创建一个封装类型 AnyDecodableAnyNetworkRequest,可以辅助我们进行 Cuckoo 类型定义。此方案稍微繁琐一些,当 execute 方法的返回模型无法被准确预知时, 使用该方案进行Stub. AnyDecodableAnyNetworkRequest 起到抹平 T 以及R 类型差异性的作用。

步骤:

  1. 定义一个类型抹平的AnyDecodable结构体, 并为其提供构造器和对应的协议实现:
 struct AnyDecodable: Decodable {}

 protocol AnyNetworkRequest: NetworkRequest {
    associatedtype Response = AnyDecodable
    func asAnyNetworkRequest() -> AnyNetworkRequest
 }
 extension AnyNetworkRequest{
   func asAnyNetworkRequest() -> AnyNetworkRequest { self }
 }
  1. 使需要 stubNetworkRequest 都实现 AnyNetworkRequest 协议:
   struct PARRequest: NetworkRequest, AnyNetworkRequest {
      ...
      func asAnyNetworkRequest() -> AnyNetworkRequest { self }
      typealias Response = AuthorizationResponseDTO
    }
  1. 在测试方法中,利用AnyNetworkRequestAnyDecodable 作为参数执行stub:
func testExecutePARRefreshURL_ThrowsError() async {
    let testURL = "https://test.com"
    let expectedError = NSError(domain: "TestErrorDomain", code: 1, userInfo: nil)
    let request = PARRequest(baseURL: testURL)

    stub(mockNetworkManager) { stub in
      when(stub.execute(with: any(AnyNetworkRequest.self), parameters: any()) as AnyDecodable)
         .thenThrow(expectedError)
    }

    do {
      _ = try await sut.executePARRefreshURL(with: testURL)
      XCTFail("Expected error but got success")
    } catch let error as NSError {
      XCTAssertEqual(error.domain, expectedError.domain)
    }

    verify(mockNetworkManager).execute(with: request, parameters: nil)
  }

分析:
通过自定义类型,可以减少类型擦除操作带来的代码维护成本。在执行 stub 的时候使用 any(AnyNetworkRequest.self)AnyDecodable 类型定义,能使 Cuckoo 准确生成对应的 Stub 代码。asAnyNetworkRequest() 扩展方法可以帮助 NetworkRequest 快速转化为 AnyNetworkRequest 类型。

安全提示

  • 当测试 thenThrow 场景时, 保证错误处理代码覆盖到了各种可能的异常类型。
  • 始终验证交互行为,确保 mock 对象的方法调用与预期一致。 使用 verify 来验证 mock 对象上是否有按照预期执行了相应方法调用。
  • 在真实项目中,保证在执行 Cuckoo stub 方法前定义好各种需要用到的协议。确保生成 mock 文件中的函数签名参数正确。

总结

使用 Cuckoo 测试泛型方法时遇到 “Generic parameter could not be inferred” 的错误通常是因为 Cuckoo 无法自动推断出泛型类型。通过明确指定泛型参数类型, 或者自定义 AnyNetworkRequest 这种可以抹除类型差异性的泛型类型。这两种方案都可以解决这一问题。选择哪种方案取决于实际场景和开发团队的偏好。优先考虑使用类型更为明确方案,能够更好地表达意图并且能使代码更简洁易懂。