Kolos65 / Mockable

A Swift macro driven auto-mocking library.
MIT License
199 stars 14 forks source link

Unable to use willThrow on mocked generic function #47

Closed kelvinharron closed 2 months ago

kelvinharron commented 2 months ago

I have one blocker that might be a limitation of my implementation, so I'd like to sanity check it here. In our project, we have the following BaseAPIClient protocol.

@Mockable
public protocol BaseApiClient {
    func fetch<T: Codable>(using request: URLRequest) async throws -> T
    func cancel() async
}

public final class ApiClient: BaseApiClient {
    ...

    public func fetch<T: Codable>(using request: URLRequest) async throws -> T {
        logger.trace("API request made to \(request.detailedDescription(), privacy: .private)")
        let (data, response) = try await urlSession.data(for: request)

        if let httpResponse = response as? HTTPURLResponse {
            switch httpResponse.statusCode {
            case 200 ..< 300:
                ...
        } else {
            throw ApiError.unknownError(message: response.description)
        }
    }
}

Implementers of the class declare the BaseAPIClient type and explicitly define the `T: Codable`` return type, we get a very flexible and reusable client.

let authenticationDataResult: AuthenticationResponse = try await apiClient.fetch(using: loginRequest)

The problem is the use of given in the mock is unable to compile when I want to define a willThrow for it.

@testable import MockableGenericExample
import MockableTest
import XCTest

enum TestError: Error, Codable {
    case generic
}

final class MockableGenericExampleTests: XCTestCase {
    let baseAPIClient = MockBaseAPIClient()

    ...

    func testExample() throws {
        given(baseAPIClient)
            .fetch(using: .any)
            .willReturn("") // will compile

        given(baseAPIClient)
            .fetch(using: .any)  // won't compile due to 'Generic parameter 'T' could not be inferred'
            .willThrow(TestError.generic)

        given(baseAPIClient)
            .fetch(using: .any) // won't compile 'Type '()' cannot conform to 'Decodable'
            .willProduce { _ in
                throw TestError.generic
            }
    }
}

Have you encountered this before @Kolos65 and have any recommendations as a work around? Or is there a limitation I could help contribute a resolution towards? The alternative approach to my code is to provide an associatedType in the protocol and define that as the response without generics, which means explicit API clients that do duplicating the logic around handling the various HTTP response and error handling. I have attached an example project to help illustrate the issue. Thank you.

MockableGenericExample.zip

Kolos65 commented 2 months ago

The type system must be able to infer all generic parameters and return values so in this case you are limited to using willProduce and need to specify the return value in the closure signature:

given(baseAPIClient)
    .fetch(using: .any)
    .willProduce { _ -> String in
        throw TestError.generic
    }
do {
    let url = URL(string: "apple.com")!
    let request = URLRequest(url: url)
    let _: String = try await mock.fetch(using: request)
} catch {
    XCTAssertEqual(error as! TestError, TestError.generic)
}
kelvinharron commented 2 months ago

You're a saint Kolos, thank you! I found that if my tested code is calling a mocked object like

let authenticationDataResult: AuthenticationResponse = try await apiClient.fetch(using: loginRequest)

Then I need to define the mock as

given(apiClient)
    .fetch(using: .any)
    .willProduce { _ -> AuthenticationResponse in
        throw ApiError.httpResponseFailure
    }

where it works perfectly! Would you welcome a pull request to note this use case in the documentation?

Kolos65 commented 2 months ago

I will add a "working with generics" section where I would list this as well as other edge cases and limitations regarding generics.

Glad I could help @kelvinharron 🚀