When working with remote APIs we often want to implement some sort of caching on the client for multiple reasons, e.g. performance or saving the bandwidth. We may also want to use the cache only when it is not outdated (as per our specific use case) and still hit the remote API even when the cache exists. Let’s look at a possible way on how to approach this using Combine.
To set the scene, let’s imagine we want to load weather data into our app. By nature it usually takes a while for the weather to change drastically (barring places like Melbourne and its four seasons in a day), so chances are we’ll want to cache the remote data and use that cache until a meaningful period has passed, after which it makes sense to request the remote data again.
Model
Let’s begin by creating a simple model to represent the weather data:
struct Weather {
let lastUpdate: Date
let temperature: Float
let condition: String
}
A proper weather app will surely provide more information about the weather, but this model will serve just fine for the purposes of this article. The lastUpdate
property will be used to determine how recent the weather data is, while the other two properties just describe the weather conditions.
Cache and API Services
Now let’s define the interface for the cache service. For this demo it will include only a method for loading from the cache, but obviously we’d want to have a way to save to the cache in an actual app.
import Combine
protocol WeatherCache {
var cacheDurationSeconds: TimeInterval { get }
func loadLocationWeather(latitude: Double, longitude: Double) -> AnyPublisher<Weather?, Error>
}
We simply want to load any cached weather data for the given combination of latitude and longitude (i.e. location). Note the publisher’s output type is optional, since it’s possible to not have any cached data. The cacheDurationSeconds
property determines for how long a cache is valid.
The interface for the service loading the remote data will also have a single method. We won’t win any awards for the best network layer design but that’s not the point of this article.
import Combine
protocol WeatherAPIService {
func fetchWeather(latitude: Double, longitude: Double) -> AnyPublisher<Weather, Error>
}
This time the publisher’s output type is not optional, since we always expect the service to respond with some data, or an error.
Weather Loading Service
Finally we need a service that will make use of both the cache and the remote API service. It will handle all the tricky parts and we’ll be able to load the weather data with a single method call, without having to worry about cache or making an HTTP request at the call site. Here’s what a stub for the service could look like:
import Combine
struct WeatherLoadingService {
let cache: WeatherCache
let apiService: WeatherAPIService
func loadWeather(latitude: Double, longitude: Double) -> AnyPublisher<Weather, Error> {
fatalError("not implemented")
}
}
Ideally we’d abstract this API behind a protocol as well, but we won’t bother with that here. Before we move on to implementing the loadWeather
method, let’s write tests for this service first to have our implementation easily verifiable. The implementation part continues here if you wish to go straight to that.
Tests
When working with Combine, writing the tests often involves setting up test expectations which is a bit verbose at times. We’ll help ourselves with a neat extension for XCTestCase
adapted from this article on Swift by Sundell. Thanks to this extension we can write a lot of the publisher related code in a synchronous fashion, resulting in tests that are much easier to reason about:
import Combine
import XCTest
extension XCTestCase {
func awaitPublisher<T: Publisher>(
_ publisher: T,
timeout: TimeInterval = 0.5,
file: StaticString = #file,
line: UInt = #line
) throws -> Result<T.Output, Error> {
var result: Result<T.Output, Error>?
let expectation = self.expectation(description: "Awaiting publisher")
let cancellable = publisher.sink(
receiveCompletion: { completion in
switch completion {
case .failure(let error):
result = .failure(error)
case .finished:
break
}
expectation.fulfill()
},
receiveValue: { value in
result = .success(value)
}
)
waitForExpectations(timeout: timeout)
cancellable.cancel()
let unwrappedResult = try XCTUnwrap(
result,
"Awaited publisher did not produce any output",
file: file,
line: line
)
return unwrappedResult
}
}
One modification I made is that the extension returns the whole Result
enum instead of just its success value. Feel free to pause here and read the original article if you want to learn more about the idea behind the extension.
Test Mocks
Next we’ll add a mock for our cache service. We want to control the mock’s response and cache duration. Additionally we’ll also want to test the error response so we add MockError
enum as well:
import Combine
@testable import WeatherDemo
enum MockError: Error {
case forcedError
}
class CacheMock: WeatherCache {
var numLoadCalls: Int = 0
var cacheDurationSeconds: TimeInterval = 0
var response: Weather?
var shouldFail: Bool = false
func loadLocationWeather(latitude: Double, longitude: Double) -> AnyPublisher<Weather?, Error> {
Deferred {
Future<Weather?, Error> { future in
self.numLoadCalls += 1
if self.shouldFail {
future(.failure(MockError.forcedError))
} else {
future(.success(self.response))
}
}
}.eraseToAnyPublisher()
}
}
The mock for the API service is very similar:
import Combine
@testable import WeatherDemo
class APIServiceMock: WeatherAPIService {
var numLoadCalls: Int = 0
var response: Weather?
var shouldFail: Bool = false
func fetchWeather(latitude: Double, longitude: Double) -> AnyPublisher<Weather, Error> {
Deferred {
Future<Weather, Error> { future in
self.numLoadCalls += 1
if self.shouldFail {
future(.failure(MockError.forcedError))
} else {
future(.success(self.response!))
}
}
}.eraseToAnyPublisher()
}
}
Testing WeatherLoadingService
Now we can get to the actual testing. Let’s define the test cases to have a better idea about what we need to test:
- When no cache exists, the remote API request must be made.
- When cache exists but is outdated, the remote API request must be made.
- When cache exists and is recent, the remote API request must not be made.
- When cache responds with an error, the remote API request must be made.
- If the remote API request is made and it fails, an error should be returned even if outdated cache exists.
Let’s create a new test class and add the test subject along with the mocks:
import XCTest
@testable import WeatherDemo
class WeatherLoadingServiceTests: XCTestCase {
private var cache: CacheMock!
private var apiService: APIServiceMock!
private var sut: WeatherLoadingService!
override func setUpWithError() throws {
self.cache = CacheMock()
self.apiService = APIServiceMock()
self.sut = WeatherLoadingService(cache: cache, apiService: apiService)
}
override func tearDownWithError() throws {
self.cache = nil
self.apiService = nil
self.sut = nil
}
}
No Cache Test
In the first test we want to make sure the weather service correctly makes the call to the remote API when no cached data exist:
func test_api_request_is_made_when_cache_does_not_exist() throws {
XCTAssertEqual(self.cache.numLoadCalls, 0)
XCTAssertEqual(self.apiService.numLoadCalls, 0)
let lastUpdate = Date()
self.apiService.response = Weather(lastUpdate: lastUpdate, temperature: 10, condition: "Clear sky")
let result = try awaitPublisher(self.sut.loadWeather(latitude: 10, longitude: 10))
let weather = try result.get()
XCTAssertEqual(weather.lastUpdate, lastUpdate)
XCTAssertEqual(weather.temperature, 10)
XCTAssertEqual(weather.condition, "Clear sky")
XCTAssertEqual(self.cache.numLoadCalls, 1)
XCTAssertEqual(self.apiService.numLoadCalls, 1)
}
Outdated Cache Test
Now we want to test that the API service is still triggered when the cache exists but is outdated:
func test_api_request_is_made_when_outdated_cache_exists() throws {
XCTAssertEqual(self.cache.numLoadCalls, 0)
XCTAssertEqual(self.apiService.numLoadCalls, 0)
let outdated = Date(timeIntervalSince1970: 0)
self.cache.cacheDurationSeconds = 10 * 60
self.cache.response = Weather(lastUpdate: outdated, temperature: 5, condition: "Rain")
let lastUpdate = Date()
self.apiService.response = Weather(lastUpdate: lastUpdate, temperature: 10, condition: "Clear sky")
let result = try awaitPublisher(self.sut.loadWeather(latitude: 10, longitude: 10))
let weather = try result.get()
XCTAssertEqual(weather.lastUpdate, lastUpdate)
XCTAssertEqual(weather.temperature, 10)
XCTAssertEqual(weather.condition, "Clear sky")
XCTAssertEqual(self.cache.numLoadCalls, 1)
XCTAssertEqual(self.apiService.numLoadCalls, 1)
}
Recent Cache Test
In this scenario we test that the weather service correctly uses the cache and avoids making unnecessary remote API requests:
func test_api_request_is_not_made_when_recent_cache_exists() throws {
XCTAssertEqual(self.cache.numLoadCalls, 0)
XCTAssertEqual(self.apiService.numLoadCalls, 0)
let lastUpdate = Date()
self.cache.cacheDurationSeconds = 10 * 60
self.cache.response = Weather(lastUpdate: lastUpdate, temperature: 5, condition: "Rain")
let result = try awaitPublisher(self.sut.loadWeather(latitude: 10, longitude: 10))
let weather = try result.get()
XCTAssertEqual(weather.lastUpdate, lastUpdate)
XCTAssertEqual(weather.temperature, 5)
XCTAssertEqual(weather.condition, "Rain")
XCTAssertEqual(self.cache.numLoadCalls, 1)
XCTAssertEqual(self.apiService.numLoadCalls, 0)
}
Cache Error Test
Next we want to ensure the remote data is fetched when the cache responds with an error:
func test_api_request_is_made_when_cache_fails() throws {
XCTAssertEqual(self.cache.numLoadCalls, 0)
XCTAssertEqual(self.apiService.numLoadCalls, 0)
let lastUpdate = Date()
self.cache.shouldFail = true
self.apiService.response = Weather(lastUpdate: lastUpdate, temperature: 10, condition: "Clear sky")
let result = try awaitPublisher(self.sut.loadWeather(latitude: 10, longitude: 10))
let weather = try result.get()
XCTAssertEqual(weather.lastUpdate, lastUpdate)
XCTAssertEqual(weather.temperature, 10)
XCTAssertEqual(weather.condition, "Clear sky")
XCTAssertEqual(self.cache.numLoadCalls, 1)
XCTAssertEqual(self.apiService.numLoadCalls, 1)
}
Remote API Error Test
Finally we test that we receive an error when fetching the remote API fails, even if we have an outdated cache data:
func test_error_is_returned_when_api_request_fails() throws {
XCTAssertEqual(self.cache.numLoadCalls, 0)
XCTAssertEqual(self.apiService.numLoadCalls, 0)
let outdated = Date(timeIntervalSince1970: 0)
self.cache.cacheDurationSeconds = 10 * 60
self.cache.response = Weather(lastUpdate: outdated, temperature: 5, condition: "Rain")
self.apiService.shouldFail = true
let result = try awaitPublisher(self.sut.loadWeather(latitude: 10, longitude: 10))
if case .success = result {
XCTFail("expected an error")
}
XCTAssertEqual(self.cache.numLoadCalls, 1)
XCTAssertEqual(self.apiService.numLoadCalls, 1)
}
Implementing Weather Loading Service
Now onto the main bit. We go back to the WeatherLoadingService.swift
file and begin implementing the loadWeather
method.
Since we always want to go through the cache first, that’s what we’ll start with:
func loadWeather(latitude: Double, longitude: Double) -> AnyPublisher<Weather, Error> {
return self.cache.loadLocationWeather(latitude: latitude, longitude: longitude)
}
Now we’ll want to check whether the cache (if any) is recent, if not then we want to ignore it. We can use the map
operator in combination with another method to check the cache’s date:
func loadWeather(latitude: Double, longitude: Double) -> AnyPublisher<Weather, Error> {
return self.cache.loadLocationWeather(latitude: latitude, longitude: longitude)
.map({ (weather: Weather?) -> Weather? in
if self.isCacheRecent(weather) {
return weather
}
return nil
})
}
private func isCacheRecent(_ weather: Weather?) -> Bool {
guard let weather = weather else { return false }
let threshold: TimeInterval = Date().timeIntervalSince1970 - self.cache.cacheDurationSeconds
return weather.lastUpdate.timeIntervalSince1970 >= threshold
}
Next we want to ignore potential error response from the cache with the catch
operator. We simply return Just
publisher with the nil
value, which essentially says there is no cache available:
func loadWeather(latitude: Double, longitude: Double) -> AnyPublisher<Weather, Error> {
return self.cache.loadLocationWeather(...)
.map({ ... })
.catch({ _ in
Just(nil)
})
}
At this point we are dealing with an optional value of type Weather
, which is nil
in these cases:
- The cache does not exist.
- The cache exists but is outdated.
- The cache responded with an error.
In any other case the value won’t be nil and we can treat it as a valid cache response. However if it is nil
we still want to make the request to the remote API, and that’s where flatMap
joins the party:
func loadWeather(latitude: Double, longitude: Double) -> AnyPublisher<Weather, Error> {
return self.cache.loadLocationWeather(...)
.map({ ... })
.catch({ ... })
.flatMap({ (response: Weather?) -> AnyPublisher<Weather, Error> in
// Valid cache, use it
if let response = response {
return Just(response)
.setFailureType(to: Error.self) // (1)
.eraseToAnyPublisher()
}
// No cache, hit the remote API
return self.apiService.fetchWeather(latitude: latitude, longitude: longitude)
})
.eraseToAnyPublisher()
}
As mentioned we use the cached response whenever it’s not nil
by wrapping it in the Just
publisher. Since this publisher never errors out, we need to explicitly set the failure type (1)
to match error type of the API service publisher, which is used only when the cached value is nil
. And with that our implementation is done, we can now run the tests to confirm the outputs match our expectations!
iOS 13 Compatibility
If we want to deploy to iOS 13, the compiler will complain about our usage of flatMap
.
The reason for this is that the error types of the upstream publisher (the one returned by catch
) does not match the error type of the publisher returned by the flatMap
. Since we catch any errors returned by the cache publisher, the resulting type is Publisher<Weather?, Never>
, whereas the flatMap
returns Publisher<Weather, Error>
. As we can see the error types are not the same and the compiler has trouble figuring out how to put these two together.
To fix this, we need to explicitly change the error type of the upstream publisher before using flatMap
:
func loadWeather(latitude: Double, longitude: Double) -> AnyPublisher<Weather, Error> {
return self.cache.loadLocationWeather(...)
.map({ ... })
.catch({ ... })
.setFailureType(to: Error.self)
.flatMap({ ... })
.eraseToAnyPublisher()
}
You can learn more about this problem in this more in-depth article by Donny Wals.