And so this one single conformance seems to have replaced the previous 3 conformances, and so that seems like a win. But again, we are back to the situation where we only have two conformances for this protocol: a live one and a mock one. And the mock implementation requires quite a bit of boilerplate to make it versatile.
Turns out we have essentially just recreated a technique that we have discussed a number of times on Point-Free, first in our episodes on dependency injection (made simple, made comfortable) and then later in our episodes on protocol witnesses. For the times that a protocol is not sufficiently abstracting away some functionality, which is most evident in those cases where we only have 1 or 2 conformances, it can be advantageous to scrap the protocols and just use a simple, concrete data type. That is basically what this MockWeatherClient
type is now.
So, let’s just take this all the way. Let’s comment out the protocol:
// protocol WeatherClientProtocol {
// func weather() -> AnyPublisher<WeatherResponse, Error>
// func searchLocations(coordinate: CLLocationCoordinate2D)
// -> AnyPublisher<[Location], Error>
// }
This will break some things we need to fix. First we have the live weather client, the one that actually makes the API requests. We are going to fix this in a moment so let’s skip it for now.
Next we have the MockWeatherClient
. Instead of thinking of this type as our “mock” we are now going to think of it as our interface to a weather client’s functionality. One will construct instances of this type to represent a weather client, rather than create types that conform to a protocol.
So, we are going to get rid of the protocol conformance, and we can even get rid of the methods and underscores:
struct WeatherClient {
var weather: () -> AnyPublisher<WeatherResponse, Error>
var searchLocations:
(CLLocationCoordinate2D) -> AnyPublisher<[Location], Error>
}
And now, instead of creating conformances of the WeatherClient
protocol, we will be creating instances of the WeatherClient
struct.
We can first create the “live” version of this dependency, which is the one that actually makes the API requests. We don’t current need the searchLocations
endpoint so I’ll just use a fatalError
in there for now:
extension WeatherClient {
static let live = Self(
weather: {
URLSession.shared
.dataTaskPublisher(
for: URL(
string: "https://www.metaweather.com/api/location/2459115"
)!
)
.map { data, _ in data }
.decode(type: WeatherResponse.self, decoder: weatherJsonDecoder)
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
},
searchLocations: { coordinate in
fatalError()
}
)
}
We can recreate the other 3 conformances of the protocol by simply creating instances of this type. A natural place to house these values is as statics inside the WeatherClient
type:
extension WeatherClient {
static let empty = Self(
weather: {
Just(WeatherResponse(consolidatedWeather: []))
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
},
searchLocations: { _ in
Just([])
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
}
)
static let happyPath = Self(
weather: {
Just(
WeatherResponse(
consolidatedWeather: [
.init(
applicableDate: Date(),
id: 1,
maxTemp: 30,
minTemp: 10,
theTemp: 20
),
.init(
applicableDate: Date().addingTimeInterval(86400),
id: 2,
maxTemp: -10,
minTemp: -30,
theTemp: -20
)
]
)
)
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
},
searchLocations: { _ in
Just([])
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
}
)
static let failed = Self(
weather: {
Fail(error: NSError(domain: "", code: 1))
.eraseToAnyPublisher()
},
searchLocations: { _ in
Just([])
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
}
)
}
Only a few more errors to fix. Next is in the view model’s initializer, where we are explicitly requiring that whoever creates the view model must pass in a conformance to the WeatherClientProtocol
. Well, now we can require just a WeatherClient
and we can default it to the live one:
init(
isConnected: Bool = true,
weatherClient: WeatherClient = .live
) {
And finally in our SwiftUI preview, instead of constructing a MockWeatherClient
from scratch we can just use the live one:
ContentView(
viewModel: AppViewModel(
weatherClient: .live
)
)
Or we could use any of the other ones too:
// weatherClient: .live
// weatherClient: .happyPath
weatherClient: .failed
We can even do fun stuff like start with the happy path client, and then change one of the endpoints to be a failure:
var client = WeatherClient.happyPath
client.searchLocations = { _ in
Fail(error: NSError(domain: "", code: 1))
.eraseToAnyPublisher()
}
return client
This kind of transformation is completely new to this style of designing this dependency, and was not easily done with the protocol style.
We could even just open up a scope to make a custom client based off the live client right inline:
weatherClient: {
var client = WeatherClient.live
client.searchLocations = { _ in
Fail(error: NSError(domain: "", code: 1))
.eraseToAnyPublisher()
}
return client
}()
It’s a little noisy, but it’s also incredibly powerful. It is super easy to create all new weather clients from existing ones, and that just isn’t possible with protocols and their conformances. And we have seen this many times in the past, such as when we developed a snapshot testing library in this style and could create all new snapshot strategies from existing ones, allowing us to build up a complex library of strategies with very little work.