(NB: accidentally early posted so I deleted my first message)
Hi @Fogmeister, this question gets at a pretty complicated aspect of designing dependencies that we haven't yet really discussed publicly.
It boils down to there being a spectrum on which a dependency can lie: it is either super generic, but then difficult to mock for tests, or it is super specific, and then very easy to mock for tests.
Suppose for a moment that it was possible to use generics for properties. Then you could design your Apollo client like so:
struct ApolloClient {
var fetch<Query: GraphQLQuery>: (Query) async throws -> Query.Data
}
You would construct the "live" version of this client by calling out to the real Apollo code under the hood, but how can you make a "mock" version for tests or previews? You would need to do something like this:
extension ApolloClient {
static let mock = Self(
fetch: { query in
// ???
}
)
You would need to somehow understand what query represents so that you could return some sensible mock data. But query is a very complex object since it is a full GraphQL query. In order to pick apart of a query object you would have to literally implement your own ad-hoc GraphQL query interpreter.
So, the client is super generic and makes for great call sites in your reducer since you can just construct a GraphQL query and hand it off to the fetch endpoint, but it essentially destroys the ability to mock it for tests and previews.
On the other hand, if you had designed a super specific client, something with an endpoint for each kind of query you want to make:
struct ApolloClient {
var fetchUsers: () async throws -> [User]
var fetchFriends: (User.ID) async throws -> [User]
var updateUser: (User) async throws -> Void
}
Then the "live" implementation of this client could still make use of Apollo under the hood by constructing a GraphQL query, but then in tests and previews you can very easily mock:
extension ApolloClient {
static let mock = Self(
var fetchUsers: { [.mock] },
var fetchFriends: { _ in [.mock] },
var updateUser: { _ in }
)
}
So, this client is extremely specific in that you have to add an additional endpoint for each kind of interaction you want to have with the API, but then it becomes extremely easy to mock for tests and previews.
This principle holds for other kinds of dependencies too. For example, say you needed a client for interacting with a database. You could model that as a super generic client that can take a SQL query string:
struct DatabaseClient {
var query: (SQLQuery) -> any Codable
}
But then to mock this you would have to literally create your own SQLQuery interpreter to know what is being requested and how to construct some data to send back.
Or you could model it as a client with an endpoint for each kind of interaction you want to have with the database:
extension DatabaseClient {
static let mock = Self(
var fetchUsers: { [.mock] },
var fetchFriends: { _ in [.mock] },
var updateUser: { _ in }
)
}
This is the exact approach we take in both isowords and Point-Free's server codebase specifically because it is so amenable to testing. It does mean a little more work to maintain the endpoints, but we think the benefits are huge. (This style also works very nicely with "unimplemented" dependencies where you get to prove exactly which parts of a dependency are used in a specific execution flow.)
One last thing I want to mention is the style of API client you mentioned at the beginning of your post where you have a single generic endpoint like this:
struct APIClient {
var request: (URLRequest) async throws -> (Data, URLResponse)
}
This style of dependency has the same problems I have discussed above, but it's a little less pernicious. The "interpretation" of a URLRequest is a lot simpler, and so you may not notice it being a problem. You can just pluck off a few pieces of the request to roughly figure out what is being requested and then return the data:
extension APIClient {
static let mock = Self(
request: { request in
if request.pathComponents.last == "users" {
return try JSONEncoder().encoder([User.mock])
}
…
}
)
}
You still are interpreting a kind of "query", but it's just a URLRequest and so a bit simpler.
So, you have to decide where do you want the complexity: when mocking for tests and previews, or when maintaining the endpoints for the client?
If you made it this far and you still want a super generic client, then you can, you just need to abandon the struct style of designing your dependency. And considering the complications with testing such dependencies, I'm not sure the struct style is buying you much.
So, instead of holding a concrete struct client in the @Dependency you will just hold an any ApolloClient, and then you can create conformances as you need.