Testing Networking Code With Combine
In this article
Combine makes writing networking code simple and expressive. It greatly simplifies the large amount of boilerplate code needed to make a network call using Foundation's URLSession and dataTask. Combine brings dataTaskPublisher in to wrap dataTask for use in a publisher chain.
Combine & networking code
Not only does Combine make writing networking code easy, it also helps with testing networking code. Consider the following example, which uses the Dog API to list all dog breeds in the data set.
Note: This article assumes familiarity with Combine.
import Combine
import XCTest
protocol Requestable {
func make<T: Decodable>(
_ request: URLRequest,
_ decoder: JSONDecoder
) -> AnyPublisher<T, Error>
}
struct ApiClient: Requestable {
func make<T: Decodable>(
_ request: URLRequest,
_ decoder: JSONDecoder
) -> AnyPublisher<T, Error> {
URLSession.shared
.dataTaskPublisher(for: request)
.map(\.data)
.decode(type: T.self, decoder: decoder)
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}
This code sets up an interface to make any URLRequest, and it exposes the ability to pass in a custom decoder. We conform to the protocol in ApiClient with a chain of operators to make the request.
struct Response: Decodable, Equatable {
let message: [String: [String]]
}
struct DogProvider {
let apiClient: Requestable
private let url = URL(string: "https://dog.ceo/api/breeds/list/all")!
func allDogs() -> AnyPublisher<Response, Error> {
apiClient.make(URLRequest(url: url), JSONDecoder())
}
}
Next, we set up the API response, which conforms to Decodable and Equatable. The response format mirrors the API's JSON response structure.
The DogProvider layer then interfaces with the Dog API. It contains an apiClient to make requests with and a hardcoded url property. It also has an allDogs() function, which returns a publisher including a response or error. That is all the setup code that is needed, and now we can begin writing tests.
final class DogProviderTest: XCTestCase {
var dogProvider: DogProvider!
override func setUp() {
dogProvider = DogProvider(apiClient: ApiClient())
}
func test_allDogsReturnsResponse() {
let expectation = XCTestExpectation(description: "allDogs")
_ = dogProvider.allDogs().sink(receiveCompletion: { _ in }) { response in
XCTAssertTrue(!response.message.isEmpty)
expectation.fulfill()
}
wait(for: [expectation], timeout: 1)
}
}
DogProviderTest.defaultTestSuite.run()
To test the allDogs method, we need to create a DogProvider, which takes a Requestable conforming type in its initializer. In the test, we use an XCTestExpecatation to wait for the asynchronous response. Inside the receiveValue closure of the sink function, we fulfill the expectation and make our assertions.
Right now, we can assert that the response is not empty. The last line is necessary to run unit tests because we are using a playground. If you are using an Xcode project you can omit that line.
This could be considered an integration test because we are testing how our networking layer interacts with an actual network. While this is a valuable test, it is not a unit test due to lack of isolation from other "real" layers. This type of test can sometimes be flaky because it depends on many external factors surrounding the network connection.
In order to create an isolated test, we will need to mock the ApiClient to return the data/error we want.
struct MockApiClient: Requestable {
func make<T: Decodable>(
_ request: URLRequest,
_ decoder: JSONDecoder = JSONDecoder()
) -> AnyPublisher<T, Error> {
Just(Response(message: ["Labradoodle": []]) as! T)
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
}
}
This struct conforms to Requestable, and it returns a hardcoded dataset. Alternatively, you could set it up to return either a response or error, but we are keeping this simple for example purposes. The Just response type returns one value and has a failure type of Never, which is why the .setFailureType is necessary. Finally, we eraseToAnyPublisher per usual.
Note: If you set the mock up to return an error, make sure to test within the receiveCompletion closure in the sink() function.
final class DogProviderTest: XCTestCase {
var dogProvider: DogProvider!
var mockApiClient: MockApiClient!
override func setUp() {
mockApiClient = MockApiClient()
dogProvider = DogProvider(apiClient: mockApiClient)
}
func test_allDogsReturnsResponse() {
let expectedResponse = Response(message: ["Labradoodle": []])
let expectation = XCTestExpectation(description: "allDogs")
_ = dogProvider.allDogs().sink(receiveCompletion: { _ in }) { response in
XCTAssertEqual(response, expectedResponse)
expectation.fulfill()
}
wait(for: [expectation], timeout: 1)
}
}
DogProviderTest.defaultTestSuite.run()
The big difference here is that we are using the MockApiClient rather than the real ApiClient. This allows us to assert equality on the response and test the allDogs() function in isolation.
As you can see, the test code is straightforward in that you subscribe to values from the publisher and assert that the values are as expected. Because the mock sends just one value, it should run very quickly.
Writing and testing networking code with Combine is lightweight and concise. It's amazing to write a networking layer and an accompanying test all in under 100 lines of code!